From 829f1ef5970640ac8a6df1c930d5ddc7d84618f5 Mon Sep 17 00:00:00 2001 From: gelusus <15012029+gelusus@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:55:28 +0800 Subject: [PATCH 001/348] fix(agent): improve LLM retry mechanism (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 LLM 重试机制的多个问题,避免 429 限流时一次失败即退出。 核心修复: - MaxRetries 默认值 bug: 零值现在使用 DefaultMaxRetries(9) 而非静默 禁用重试(导致 429 时仅尝试 1 次就退出) - 指数退避策略: 0.5s 基数、2^n 增长、封顶 32s,加 25% 加性抖动 (additive jitter),避免多 agent 并发时惊群。参考 Claude Code 设计。 - Retry-After 头解析: 优先使用服务器返回的等待时间,绕过退避公式 与封顶。新增 APIError.Header 字段在 http.go 两处构造点填充。 - 可重试状态码扩展到 408(Request Timeout) 和 409(Conflict) 非破坏性: - RetryDelay() 公开函数保持原行为(1s·2^n, cap 10s),runner/webagent 等外部调用方不受影响。新策略仅由 LLM 重试循环内部使用。 测试: - 退避序列、jitter 边界、Retry-After 解析、可重试状态码全覆盖 Co-authored-by: Your Name --- pkg/agent/provider/http.go | 4 +- pkg/agent/provider/types.go | 13 ++-- pkg/agent/retry.go | 65 +++++++++++++++++- pkg/agent/retry_test.go | 130 ++++++++++++++++++++++++++++++++++++ pkg/agent/types.go | 2 +- 5 files changed, 204 insertions(+), 10 deletions(-) diff --git a/pkg/agent/provider/http.go b/pkg/agent/provider/http.go index ae7f3b2a..b38f89cb 100644 --- a/pkg/agent/provider/http.go +++ b/pkg/agent/provider/http.go @@ -81,7 +81,7 @@ func (r *apiRequest) do(ctx context.Context, method, endpoint string, body []byt return nil, wrapReadError(parentCtx, callTimedOut.Load(), r.timeout, "read response", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, &APIError{StatusCode: resp.StatusCode, Message: string(data)} + return nil, &APIError{StatusCode: resp.StatusCode, Message: string(data), Header: resp.Header.Clone()} } return data, nil } @@ -146,7 +146,7 @@ func streamSSE( if readErr != nil { return nil, wrapReadError(ctx, timedOut, timeout, "read response", readErr) } - return nil, &APIError{StatusCode: resp.StatusCode, Message: string(respBody)} + return nil, &APIError{StatusCode: resp.StatusCode, Message: string(respBody), Header: resp.Header.Clone()} } var stallDetected atomic.Bool diff --git a/pkg/agent/provider/types.go b/pkg/agent/provider/types.go index 977bb328..cf868ad7 100644 --- a/pkg/agent/provider/types.go +++ b/pkg/agent/provider/types.go @@ -3,8 +3,8 @@ package provider import ( "encoding/json" "fmt" + "net/http" "strings" - ) // CacheRetention controls prompt caching behavior across providers. @@ -236,10 +236,11 @@ func (u *Usage) UnmarshalJSON(data []byte) error { } type APIError struct { - Message string `json:"message"` - Type string `json:"type"` - Code string `json:"code"` - StatusCode int `json:"-"` + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + StatusCode int `json:"-"` + Header http.Header `json:"-"` } func (e *APIError) Error() string { @@ -254,7 +255,7 @@ func (e *APIError) Error() string { func (e *APIError) IsRetryable() bool { switch e.StatusCode { - case 429, 500, 502, 503, 529: + case 408, 409, 429, 500, 502, 503, 529: return true default: return false diff --git a/pkg/agent/retry.go b/pkg/agent/retry.go index b596dacf..2b35d997 100644 --- a/pkg/agent/retry.go +++ b/pkg/agent/retry.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "net" + "strconv" "strings" "time" @@ -18,6 +20,12 @@ type imageDisabler interface { var errEmptyResponse = errors.New("empty response from LLM") +const ( + baseRetryDelay = 500 * time.Millisecond + maxRetryDelay = 32 * time.Second + retryJitterFactor = 0.25 +) + func isRetryableError(err error) bool { if err == nil { return false @@ -73,6 +81,10 @@ func isRetryableByMessage(err error) bool { return false } +// RetryDelay returns the backoff duration for the given attempt index (0-based). +// It keeps the original conservative policy (1s·2^attempt, capped at 10s) for +// backward compatibility with external callers such as runner and webagent +// reconnect logic. func RetryDelay(attempt int) time.Duration { delay := time.Second << uint(attempt) if delay > 10*time.Second { @@ -81,6 +93,57 @@ func RetryDelay(attempt int) time.Duration { return delay } +// retryDelayFor computes the backoff for an LLM call retry. It honors a +// Retry-After header when the error carries one (server directive wins and +// bypasses both the backoff formula and the cap); otherwise it falls back to +// exponential backoff with additive jitter: min(base·2^attempt, maxDelay) + jitter. +func retryDelayFor(attempt int, err error) time.Duration { + if after := retryAfterFromError(err); after > 0 { + return after + } + return computeRetryDelay(attempt, rand.Float64()) +} + +// retryAfterFromError parses a Retry-After header (integer seconds form) from +// an APIError, if present. Returns 0 when absent or unparseable. +func retryAfterFromError(err error) time.Duration { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return 0 + } + if apiErr.Header == nil { + return 0 + } + val := strings.TrimSpace(apiErr.Header.Get("Retry-After")) + if val == "" { + return 0 + } + secs, perr := strconv.Atoi(val) + if perr != nil || secs < 0 { + return 0 + } + return time.Duration(secs) * time.Second +} + +// computeRetryDelay is the exponential backoff + additive jitter core used by +// the LLM retry loop. Formula: min(baseRetryDelay·2^attempt, maxRetryDelay), +// then add random jitter in [0, retryJitterFactor·delay). +func computeRetryDelay(attempt int, jitterFrac float64) time.Duration { + if attempt < 0 { + attempt = 0 + } + // baseDelay·2^attempt, capped at maxDelay + delay := baseRetryDelay << uint(attempt) + if delay > maxRetryDelay || delay <= 0 { + delay = maxRetryDelay + } + if jitterFrac > 0 { + // additive jitter: delay += random·[0, jitterFactor·delay) + delay += time.Duration(jitterFrac * retryJitterFactor * float64(delay)) + } + return delay +} + func requestWithRetry(ctx context.Context, cfg Config, bus emitter, messages []ChatMessage, tools []ToolDefinition, turn int) (ChatMessage, *Usage, error) { var lastErr error maxAttempts := cfg.MaxRetries + 1 @@ -89,7 +152,7 @@ func requestWithRetry(ctx context.Context, cfg Config, bus emitter, messages []C } for attempt := 0; attempt < maxAttempts; attempt++ { if attempt > 0 { - delay := RetryDelay(attempt - 1) + delay := retryDelayFor(attempt-1, lastErr) cfg.Logger.Warnf("retrying LLM call (attempt %d/%d) after %s: %v", attempt+1, maxAttempts, delay, lastErr) select { case <-time.After(delay): diff --git a/pkg/agent/retry_test.go b/pkg/agent/retry_test.go index 3229fd40..e65207a1 100644 --- a/pkg/agent/retry_test.go +++ b/pkg/agent/retry_test.go @@ -3,8 +3,10 @@ package agent import ( "context" "fmt" + "net/http" "strings" "testing" + "time" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/pkg/agent/provider" @@ -379,3 +381,131 @@ func TestInferImageSupportModelRegistry(t *testing.T) { }) } } + +// --- Backoff & Retry-After parsing tests --- + +func TestRetryDelayBackoffSequence(t *testing.T) { + // RetryDelay keeps the original conservative policy (1s·2^attempt, cap 10s) + // for backward compatibility with external callers (runner, webagent). + want := []time.Duration{ + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, + 8 * time.Second, + 10 * time.Second, // cap reached + 10 * time.Second, // stays capped + } + for i, w := range want { + if got := RetryDelay(i); got != w { + t.Errorf("attempt %d: RetryDelay = %s, want %s", i, got, w) + } + } +} + +func TestComputeRetryDelaySequence(t *testing.T) { + // New LLM retry policy: 0.5s base, doubling, capped at 32s (no jitter here). + want := []time.Duration{ + 500 * time.Millisecond, + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, + 8 * time.Second, + 16 * time.Second, + 32 * time.Second, // cap reached + 32 * time.Second, // stays capped + } + for i, w := range want { + if got := computeRetryDelay(i, 0); got != w { + t.Errorf("attempt %d: computeRetryDelay = %s, want %s", i, got, w) + } + } +} + +func TestRetryDelayJitterBounds(t *testing.T) { + // With jitter, delay must fall in [base, base + 0.25·base] (inclusive upper + // bound because jitterFrac can equal 1.0 in the test). + for attempt := 0; attempt < 8; attempt++ { + base := baseRetryDelay << uint(attempt) + if base > maxRetryDelay { + base = maxRetryDelay + } + upper := base + time.Duration(retryJitterFactor*float64(base)) + for i := 0; i < 50; i++ { + got := computeRetryDelay(attempt, 1.0) // max jitter + if got < base || got > upper { + t.Errorf("attempt %d sample %d: got %s, want in [%s, %s]", attempt, i, got, base, upper) + } + } + } +} + +func TestRetryAfterFromError(t *testing.T) { + tests := []struct { + name string + err error + want time.Duration + }{ + { + name: "seconds form", + err: &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"30"}}}, + want: 30 * time.Second, + }, + { + name: "header absent", + err: &APIError{StatusCode: 429}, + want: 0, + }, + { + name: "non-integer", + err: &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"Wed, 21 Oct 2015 07:28:00 GMT"}}}, + want: 0, + }, + { + name: "wrapped APIError", + err: fmt.Errorf("call failed: %w", &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"5"}}}), + want: 5 * time.Second, + }, + { + name: "non-APIError", + err: fmt.Errorf("plain error"), + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := retryAfterFromError(tt.err); got != tt.want { + t.Errorf("retryAfterFromError() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestRetryDelayForHonorsRetryAfter(t *testing.T) { + err := &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"60"}}} + // Retry-After bypasses both the formula and the 32s cap. + if got := retryDelayFor(0, err); got != 60*time.Second { + t.Errorf("retryDelayFor with Retry-After=60 = %s, want 60s", got) + } +} + +func TestRetryDelayForFallsBackToBackoffWhenNoHeader(t *testing.T) { + err := &APIError{StatusCode: 500} // no Header + got := retryDelayFor(2, err) + // attempt 2 base = 2s; with jitter it must stay in [2s, 2.5s) + if got < 2*time.Second || got >= 2500*time.Millisecond { + t.Errorf("retryDelayFor(attempt=2, no header) = %s, want in [2s, 2.5s)", got) + } +} + +func TestIsRetryableNowIncludes408409(t *testing.T) { + for _, code := range []int{408, 409, 429, 500, 502, 503, 529} { + if !isRetryableError(&APIError{StatusCode: code}) { + t.Errorf("status %d should be retryable", code) + } + } + for _, code := range []int{400, 401, 403, 404} { + if isRetryableError(&APIError{StatusCode: code}) { + t.Errorf("status %d should NOT be retryable", code) + } + } +} diff --git a/pkg/agent/types.go b/pkg/agent/types.go index 4bec4ee9..b90ccba5 100644 --- a/pkg/agent/types.go +++ b/pkg/agent/types.go @@ -232,7 +232,7 @@ func (c Config) init() Config { if c.Logger == nil { c.Logger = telemetry.NopLogger() } - if c.MaxRetries < 0 { + if c.MaxRetries <= 0 { c.MaxRetries = DefaultMaxRetries } if c.MaxResultSize <= 0 { From 4eb0a23196cebabf96fae1e20f4c319a26d55618 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Wed, 8 Jul 2026 12:57:24 -0700 Subject: [PATCH 002/348] =?UTF-8?q?feat(agent):=20=E5=A2=9E=E5=8A=A0=20age?= =?UTF-8?q?nt-core=20=E7=9A=84=20JS/WASM=20=E5=85=A5=E5=8F=A3=EF=BC=88RFC?= =?UTF-8?q?=20#189=20=E6=96=B9=E6=A1=88=20A=20=E9=AA=8C=E8=AF=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 cmd/wasm:把 pkg/agent 的 loop/evaluator 当"脑"跑进浏览器扩展, 三座 JSON 缝接到 JS host: - Provider -> __aiscanLLM(reqJSON) (host 现有 provider) - Tools -> __aiscanTool(name, args) (host stub,chrome.scripting 在真实 tab 执行) - eventbus -> onEvent(eventJSON) 外加 aiscanRunAgent / aiscanCancelAgent 导出 + aiscanAgentReady 就绪标志。 GATE 结论: - pkg/agent(+evaluator/provider/commands)GOOS=js GOARCH=wasm 直接编过, 无需剥离(只 import pkg/commands,不碰 pkg/tools)。 - 端到端可跑:cmd/wasm/testdata/smoke.mjs 21/21(LLM↔工具↔事件↔取消)。 - 体积:strip 17MB / gzip 4.2MB < 现网 finger.wasm 29MB,体积可接受。 不含:插件 host 侧(agent-manager.js 等)、session 持久化 host 化、TinyGo 瘦身。 Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 23 +++++ cmd/wasm/README.md | 104 +++++++++++++++++++ cmd/wasm/bridge.go | 187 ++++++++++++++++++++++++++++++++++ cmd/wasm/main.go | 196 ++++++++++++++++++++++++++++++++++++ cmd/wasm/provider.go | 48 +++++++++ cmd/wasm/stub.go | 16 +++ cmd/wasm/testdata/smoke.mjs | 149 +++++++++++++++++++++++++++ cmd/wasm/tools.go | 65 ++++++++++++ 8 files changed, 788 insertions(+) create mode 100644 Makefile create mode 100644 cmd/wasm/README.md create mode 100644 cmd/wasm/bridge.go create mode 100644 cmd/wasm/main.go create mode 100644 cmd/wasm/provider.go create mode 100644 cmd/wasm/stub.go create mode 100644 cmd/wasm/testdata/smoke.mjs create mode 100644 cmd/wasm/tools.go diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..7bf55b68 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +# aiscan build targets. + +GO ?= go +WASM_DIR ?= dist/wasm +WASM_OUT ?= $(WASM_DIR)/agent.wasm + +.PHONY: agent-wasm agent-wasm-size clean-wasm + +## agent-wasm: build the agent-core js/wasm module (RFC #189, 方案 A) + wasm_exec.js +agent-wasm: + @mkdir -p $(WASM_DIR) + GOOS=js GOARCH=wasm $(GO) build -trimpath -ldflags="-s -w" -o $(WASM_OUT) ./cmd/wasm + @cp "$$($(GO) env GOROOT)/lib/wasm/wasm_exec.js" $(WASM_DIR)/wasm_exec.js + @gzip -9 -c $(WASM_OUT) > $(WASM_OUT).gz + @$(MAKE) --no-print-directory agent-wasm-size + +## agent-wasm-size: print raw and gzipped module size (the GATE metric) +agent-wasm-size: + @echo "── agent.wasm size ──" + @ls -lh $(WASM_OUT) $(WASM_OUT).gz 2>/dev/null | awk '{printf " %-6s %s\n", $$5, $$9}' + +clean-wasm: + @rm -rf $(WASM_DIR) diff --git a/cmd/wasm/README.md b/cmd/wasm/README.md new file mode 100644 index 00000000..ffbab122 --- /dev/null +++ b/cmd/wasm/README.md @@ -0,0 +1,104 @@ +# cmd/wasm — aiscan agent-core as js/wasm + +The **承重 spike (GATE)** for RFC [#189](https://github.com/chainreactors/CyberHub/issues/189) +方案 A: run aiscan's `pkg/agent` loop/evaluator as the *brain* inside a browser +extension, with the JS host owning every side effect. + +> **要单一化的是"脑(harness)",不是"手(浏览器操作)"。** The loop makes every +> decision; go-rod / playwright / os-exec never enter the wasm. Browser tools are +> registered as host-dispatched **stubs** (schema only) and executed by the host +> on the user's real logged-in tab via `chrome.scripting`. + +``` + brain (this wasm) seam (syscall/js, JSON) hand (JS host) + ┌────────────────────┐ ┌──────────────────────────┐ ┌────────────────────┐ + │ agent-core │ LLM │ __aiscanLLM(reqJSON) │ --> │ real provider │ + │ loop / evaluator │────▶│ __aiscanTool(name, args) │ --> │ chrome.scripting │ + │ (agent.wasm) │◀────│ onEvent(eventJSON) │ <-- │ UI / progress │ + └────────────────────┘ └──────────────────────────┘ └────────────────────┘ +``` + +## GATE result (this spike) + +| check | result | +|---|---| +| `pkg/agent` (+ evaluator, provider, commands) compiles `GOOS=js GOARCH=wasm` | ✅ **as-is, no strip needed** — it imports `pkg/commands`, never `pkg/tools` | +| runs end-to-end (LLM ↔ tool ↔ event ↔ cancel) | ✅ `testdata/smoke.mjs` — 21/21 | +| size, stripped (`-s -w`) | **~17 MB** | +| size, stripped + `gzip -9` | **~4.2 MB** | +| baseline: `finger.wasm` already shipping in the extension | 29 MB | + +Standard Go wasm already lands **under** the fingerprint wasm the extension ships +today, so "体积可接受" holds without TinyGo. TinyGo (to shrink further) is a +follow-up, not a blocker — its `encoding/json` reflection is the known snag. + +## Build + +```sh +make agent-wasm # -> dist/wasm/{agent.wasm, agent.wasm.gz, wasm_exec.js} + size +``` + +Needs the matching `wasm_exec.js` (copied by the target from +`$(go env GOROOT)/lib/wasm/wasm_exec.js`) — it must come from the **same Go +version** that built the module. + +## Smoke test + +```sh +make agent-wasm +node cmd/wasm/testdata/smoke.mjs dist/wasm/agent.wasm dist/wasm/wasm_exec.js +``` + +## Wire protocol + +The host installs two function globals and passes a per-run event callback: + +- `__aiscanLLM(reqJSON) -> Promise` — `reqJSON` is an OpenAI-shaped + `ChatCompletionRequest`; resolve a `ChatCompletionResponse` JSON string. The + host owns provider choice, keys, caching and fallback. +- `__aiscanTool(name, argsJSON) -> Promise` — `result` is either a + string (result text) or `{ text, is_error, terminate }`. + +Exports (installed by `main`, gated on `aiscanAgentReady === true`): + +- `aiscanRunAgent(payloadJSON, onEvent?) -> Promise` +- `aiscanCancelAgent(runId) -> bool` — aborts an in-flight run by `run_id`. + +`onEvent(eventJSON)` receives each `agent.Event` (see `pkg/agent/event_json.go`). + +### payload + +```jsonc +{ + "run_id": "abc", // key for aiscanCancelAgent + "prompt": "task...", + "system_prompt": "...", + "model": "...", + "messages": [ /* prior transcript to hydrate */ ], + "tools": [ { "name": "...", "description": "...", "parameters": { /* JSON schema */ } } ], + "max_turns": 20, + "max_parallel_tools": 4, + "max_tokens": 0, + "temperature": 0.0, + "token_budget": 0, + "eval": { "criteria": "acceptance criteria", "max_rounds": 3 } // omit for plain loop +} +``` + +### result + +```jsonc +{ "output": "...", "messages": [...], "turns": 2, "stop": "completed", + "usage": { "total_tokens": 41, ... }, "error": "" } +``` + +A mid-flight failure still **resolves** with `error` + partial transcript; only a +malformed payload rejects. + +## Scope / not in this spike + +- **Plugin host side** (`agent-manager.js`, tool dispatcher, `buildDomTree.js` + perception, security guardrails) — CyberHubCopilot repo, next step. +- **Session persistence host-out** — `session.go`'s `os`/`filepath` compile under + wasm but do nothing; the host hydrates via `messages` in/out instead. +- **TinyGo** size pass. diff --git a/cmd/wasm/bridge.go b/cmd/wasm/bridge.go new file mode 100644 index 00000000..200f9038 --- /dev/null +++ b/cmd/wasm/bridge.go @@ -0,0 +1,187 @@ +//go:build js && wasm + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "syscall/js" + + "github.com/chainreactors/aiscan/pkg/agent" +) + +// ---- wire types (host <-> wasm) -------------------------------------------- + +// runPayload is the JSON argument to aiscanRunAgent. It carries the task, the +// prior transcript to hydrate, and the tool schemas the host can execute. +type runPayload struct { + RunID string `json:"run_id"` + Prompt string `json:"prompt"` + SystemPrompt string `json:"system_prompt"` + Model string `json:"model"` + Messages []agent.ChatMessage `json:"messages"` + Tools []toolSchema `json:"tools"` + MaxTurns int `json:"max_turns"` + MaxParallelTools int `json:"max_parallel_tools"` + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature"` + TokenBudget int `json:"token_budget"` + Eval *evalSpec `json:"eval"` +} + +// toolSchema is a host-executed tool advertised to the LLM. The wasm only holds +// the schema; execution is delegated to the JS host via __aiscanTool. +type toolSchema struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +func (t toolSchema) definition() agent.ToolDefinition { + return agent.ToolDefinition{ + Type: "function", + Function: agent.FunctionDefinition{ + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + }, + } +} + +// evalSpec enables the evaluator (Goal) loop when Criteria is non-empty. +type evalSpec struct { + Criteria string `json:"criteria"` + MaxRounds int `json:"max_rounds"` +} + +// runResult is the JSON the run Promise resolves with. +type runResult struct { + Output string `json:"output"` + Messages []agent.ChatMessage `json:"messages"` + NewMessages []agent.ChatMessage `json:"new_messages,omitempty"` + Turns int `json:"turns"` + Stop string `json:"stop"` + Usage agent.Usage `json:"usage"` + ContextTokens int `json:"context_tokens,omitempty"` + Error string `json:"error,omitempty"` +} + +func marshalResult(result *agent.Result, runErr error) (string, error) { + var out runResult + if result != nil { + out.Output = result.Output + out.Messages = result.Messages + out.NewMessages = result.NewMessages + out.Turns = result.Turns + out.Stop = string(result.Stop) + out.Usage = result.TotalUsage + out.ContextTokens = result.ContextTokens + if result.Err != nil { + out.Error = result.Err.Error() + } + } + if runErr != nil && out.Error == "" { + out.Error = runErr.Error() + } + data, err := json.Marshal(out) + if err != nil { + return "", fmt.Errorf("marshal result: %w", err) + } + return string(data), nil +} + +// ---- promise plumbing (Go <-> JS) ------------------------------------------ + +// newPromise returns a JS Promise whose executor runs `run` on a goroutine, so +// the loop can block on channels (awaiting LLM/tool promises) while the JS event +// loop keeps turning. A panic in the goroutine rejects the promise rather than +// tearing down the whole wasm instance. +func newPromise(run func(resolve, reject func(any))) js.Value { + var executor js.Func + executor = js.FuncOf(func(_ js.Value, args []js.Value) any { + resolve, reject := args[0], args[1] + go func() { + defer executor.Release() + defer func() { + if r := recover(); r != nil { + reject.Invoke(fmt.Sprintf("wasm panic: %v", r)) + } + }() + run( + func(v any) { resolve.Invoke(v) }, + func(v any) { reject.Invoke(v) }, + ) + }() + return nil + }) + return js.Global().Get("Promise").New(executor) +} + +// awaitValue blocks until a JS thenable settles and returns its value (or +// error). A non-thenable is returned as-is, so a host callback may answer +// synchronously. Honors ctx: on cancel it returns ctx.Err() and releases the +// callbacks once the promise eventually settles (no leak, no goroutine spin). +func awaitValue(ctx context.Context, v js.Value) (js.Value, error) { + if v.Type() != js.TypeObject || v.Get("then").Type() != js.TypeFunction { + return v, nil + } + + type outcome struct { + val js.Value + err error + } + ch := make(chan outcome, 1) + + var onOK, onErr js.Func + release := func() { + onOK.Release() + onErr.Release() + } + onOK = js.FuncOf(func(_ js.Value, args []js.Value) any { + val := js.Undefined() + if len(args) > 0 { + val = args[0] + } + ch <- outcome{val: val} + return nil + }) + onErr = js.FuncOf(func(_ js.Value, args []js.Value) any { + ch <- outcome{err: jsError(args)} + return nil + }) + v.Call("then", onOK, onErr) + + select { + case o := <-ch: + release() + return o.val, o.err + case <-ctx.Done(): + // The promise is still pending; drain it whenever it settles so the + // js.Funcs are released rather than leaked. + go func() { + <-ch + release() + }() + return js.Undefined(), ctx.Err() + } +} + +// jsError turns a promise rejection value into a Go error, preferring an Error's +// .message when present. +func jsError(args []js.Value) error { + if len(args) == 0 { + return errors.New("promise rejected") + } + e := args[0] + switch e.Type() { + case js.TypeString: + return errors.New(e.String()) + case js.TypeObject: + if msg := e.Get("message"); msg.Type() == js.TypeString { + return errors.New(msg.String()) + } + } + return fmt.Errorf("promise rejected: %v", e) +} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 00000000..946ebe5b --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,196 @@ +//go:build js && wasm + +// Command wasm is the js/wasm entry point that runs aiscan's agent-core as the +// "brain" embedded in a browser extension (CyberHub Copilot), per RFC #189 +// (方案 A). The Go loop/evaluator make every decision; the JS host owns every +// side effect. Three seams connect them, all crossing the boundary as JSON: +// +// brain (this wasm) seam (syscall/js) hand (JS host) +// ---------------- ----------------- -------------- +// agent.Config.Provider -> __aiscanLLM(reqJSON) -> real OpenAI/Anthropic provider +// commands.Registry -> __aiscanTool(name, args) -> chrome.scripting on the real tab +// eventbus.Bus[Event] -> onEvent(eventJSON) -> UI / progress stream +// +// The wasm never imports go-rod / playwright / os-exec and never touches the +// DOM: browser tools are registered as host-dispatched stubs (schema only, +// execution delegated to __aiscanTool). See README.md for the wire protocol. +// +// Exported globals: +// +// aiscanAgentReady : bool // set true once exports are installed +// aiscanRunAgent(payloadJSON, onEvent?) -> Promise +// aiscanCancelAgent(runId) -> bool // aborts an in-flight run by run_id +package main + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "syscall/js" + + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/agent/evaluator" + "github.com/chainreactors/aiscan/pkg/commands" +) + +// Names of the JS globals the host installs for the LLM and tool seams. The +// event seam is passed per-run as the second argument to aiscanRunAgent. +const ( + globalLLMFn = "__aiscanLLM" + globalToolFn = "__aiscanTool" + globalReadyCB = "__aiscanOnReady" +) + +// cancels tracks the CancelFunc for every in-flight run keyed by run_id so the +// host can abort a specific run across the JS/WASM boundary. +var ( + cancelMu sync.Mutex + cancels = map[string]context.CancelFunc{} +) + +func registerCancel(runID string, cancel context.CancelFunc) { + if runID == "" { + return + } + cancelMu.Lock() + cancels[runID] = cancel + cancelMu.Unlock() +} + +func unregisterCancel(runID string) { + if runID == "" { + return + } + cancelMu.Lock() + delete(cancels, runID) + cancelMu.Unlock() +} + +// cancelRun implements aiscanCancelAgent(runId) -> bool. +func cancelRun(_ js.Value, args []js.Value) any { + if len(args) == 0 || args[0].Type() != js.TypeString { + return false + } + cancelMu.Lock() + cancel := cancels[args[0].String()] + cancelMu.Unlock() + if cancel == nil { + return false + } + cancel() + return true +} + +// runAgent implements aiscanRunAgent(payloadJSON, onEvent?) -> Promise. +// It returns immediately with a Promise; the loop runs on a goroutine so the JS +// event loop stays free to resolve the LLM/tool promises the loop awaits. +func runAgent(_ js.Value, args []js.Value) any { + payloadJSON := "" + if len(args) > 0 && args[0].Type() == js.TypeString { + payloadJSON = args[0].String() + } + var onEvent js.Value + if len(args) > 1 { + onEvent = args[1] + } + return newPromise(func(resolve, reject func(any)) { + out, err := doRun(payloadJSON, onEvent) + if err != nil { + reject(err.Error()) + return + } + resolve(out) + }) +} + +// doRun builds an agent-core from the payload, wires the three JS seams, runs it +// (plain loop or evaluator loop), and returns the result as a JSON string. A run +// that errors mid-flight still resolves with a result carrying the error and any +// partial transcript; only a malformed payload rejects. +func doRun(payloadJSON string, onEvent js.Value) (string, error) { + var p runPayload + if err := json.Unmarshal([]byte(payloadJSON), &p); err != nil { + return "", fmt.Errorf("parse payload: %w", err) + } + + reg := commands.NewRegistry() + for _, ts := range p.Tools { + reg.RegisterTool(&jsTool{def: ts.definition()}) + } + + bus := eventbus.New[agent.Event]() + if onEvent.Type() == js.TypeFunction { + unsub := bus.Subscribe(func(ev agent.Event) { + data, err := json.Marshal(ev) + if err != nil { + return + } + onEvent.Invoke(string(data)) + }) + defer unsub() + } + + cfg := agent.Config{ + Provider: &jsProvider{}, + Tools: reg, + Model: p.Model, + SystemPrompt: p.SystemPrompt, + Messages: p.Messages, + MaxTokens: p.MaxTokens, + Temperature: p.Temperature, + MaxTurns: p.MaxTurns, + MaxParallelTools: p.MaxParallelTools, + TokenBudget: p.TokenBudget, + Bus: bus, + SessionID: p.RunID, + // The JS provider is non-streaming; the loop falls back to + // ChatCompletion (retry.go) automatically, but be explicit. + Stream: false, + } + + ctx, cancel := context.WithCancel(context.Background()) + registerCancel(p.RunID, cancel) + defer unregisterCancel(p.RunID) + defer cancel() + + ag := agent.NewAgent(cfg) + + var ( + result *agent.Result + runErr error + ) + if p.Eval != nil && p.Eval.Criteria != "" { + // Goal mode: judge the natural-language criteria each round and re-drive + // with feedback, reusing the same host-backed provider as the judge. + evalCfg := evaluator.EvalLoopConfig{ + Evaluator: evaluator.New(evaluator.Config{ + Provider: cfg.Provider, + Model: cfg.Model, + }), + MaxEvalRounds: p.Eval.MaxRounds, + Goal: p.Prompt, + Criteria: p.Eval.Criteria, + Bus: bus, + } + result, _, runErr = evaluator.RunWithEval(ctx, ag, evalCfg) + } else { + result, runErr = ag.Run(ctx, p.Prompt) + } + + return marshalResult(result, runErr) +} + +func main() { + js.Global().Set("aiscanRunAgent", js.FuncOf(runAgent)) + js.Global().Set("aiscanCancelAgent", js.FuncOf(cancelRun)) + js.Global().Set("aiscanAgentReady", js.ValueOf(true)) + if cb := js.Global().Get(globalReadyCB); cb.Type() == js.TypeFunction { + cb.Invoke() + } + // Keep the instance alive so the exported funcs remain callable. With + // registered js.Funcs pending, the Go wasm runtime yields to the JS event + // loop here instead of reporting a deadlock. + select {} +} diff --git a/cmd/wasm/provider.go b/cmd/wasm/provider.go new file mode 100644 index 00000000..0bfaa659 --- /dev/null +++ b/cmd/wasm/provider.go @@ -0,0 +1,48 @@ +//go:build js && wasm + +package main + +import ( + "context" + "encoding/json" + "fmt" + "syscall/js" + + "github.com/chainreactors/aiscan/pkg/agent" +) + +// jsProvider implements agent.Provider by bridging every ChatCompletion to the +// JS global __aiscanLLM(reqJSON) -> Promise. The host owns provider +// selection, keys, caching and fallback; the brain just asks for a completion. +type jsProvider struct{} + +func (p *jsProvider) Name() string { return "wasm-host" } + +func (p *jsProvider) ChatCompletion(ctx context.Context, req *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) { + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal LLM request: %w", err) + } + + fn := js.Global().Get(globalLLMFn) + if fn.Type() != js.TypeFunction { + return nil, fmt.Errorf("host LLM bridge %q is not defined", globalLLMFn) + } + + val, err := awaitValue(ctx, fn.Invoke(string(reqJSON))) + if err != nil { + return nil, err + } + if val.Type() != js.TypeString { + return nil, fmt.Errorf("host LLM bridge must resolve a JSON string, got %s", val.Type()) + } + + var resp agent.ChatCompletionResponse + if err := json.Unmarshal([]byte(val.String()), &resp); err != nil { + return nil, fmt.Errorf("parse LLM response: %w", err) + } + if resp.Error != nil { + return nil, resp.Error + } + return &resp, nil +} diff --git a/cmd/wasm/stub.go b/cmd/wasm/stub.go new file mode 100644 index 00000000..2cff961e --- /dev/null +++ b/cmd/wasm/stub.go @@ -0,0 +1,16 @@ +//go:build !(js && wasm) + +// This package only builds for GOOS=js GOARCH=wasm. The stub keeps `go build +// ./...` and `go vet ./...` green on native targets; build the real thing with +// `make agent-wasm`. +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "cmd/wasm builds only with GOOS=js GOARCH=wasm; run: make agent-wasm") + os.Exit(1) +} diff --git a/cmd/wasm/testdata/smoke.mjs b/cmd/wasm/testdata/smoke.mjs new file mode 100644 index 00000000..5f8434cb --- /dev/null +++ b/cmd/wasm/testdata/smoke.mjs @@ -0,0 +1,149 @@ +// End-to-end smoke test for the agent-core wasm module (RFC #189, 方案 A). +// +// Instantiates agent.wasm under Node with stubbed host seams (__aiscanLLM, +// __aiscanTool, onEvent) and drives one full tool-using turn plus a cancel, to +// prove the three JS<->WASM seams work — not just that the module compiles. +// +// node smoke.mjs +// (or set AISCAN_WASM / WASM_EXEC) +// +// Exit code 0 = all assertions pass. +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import process from 'node:process'; + +const wasmPath = process.env.AISCAN_WASM || process.argv[2]; +const wasmExecPath = process.env.WASM_EXEC || process.argv[3]; +if (!wasmPath || !wasmExecPath) { + console.error('usage: node smoke.mjs (or AISCAN_WASM / WASM_EXEC)'); + process.exit(2); +} + +let passes = 0; +let failures = 0; +function check(cond, msg) { + if (cond) { + passes++; + console.log(` ok ${msg}`); + } else { + failures++; + console.error(` FAIL ${msg}`); + } +} +const timeout = (ms, why) => + new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout: ${why}`)), ms)); + +// Go's wasm_exec.js is a plain script that installs globalThis.Go. +vm.runInThisContext(readFileSync(wasmExecPath, 'utf8'), { filename: wasmExecPath }); + +// ---- host seams (stubs) ---------------------------------------------------- +const llmRequests = []; +const toolCalls = []; +let llmCall = 0; + +globalThis.__aiscanLLM = async (reqJSON) => { + const req = JSON.parse(reqJSON); + llmRequests.push(req); + llmCall++; + if (llmCall === 1) { + // Turn 1: instruct the brain to call the echo tool. + return JSON.stringify({ + id: 'resp-1', + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { name: 'echo', arguments: JSON.stringify({ text: 'hello' }) }, + }], + }, + finish_reason: 'tool_calls', + }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + } + // Turn 2: no tool calls -> the loop completes. + const echoed = toolCalls[0]?.result ?? ''; + return JSON.stringify({ + id: 'resp-2', + choices: [{ message: { role: 'assistant', content: `done: ${echoed}` }, finish_reason: 'stop' }], + usage: { prompt_tokens: 20, completion_tokens: 6, total_tokens: 26 }, + }); +}; + +globalThis.__aiscanTool = async (name, argsJSON) => { + const result = `echoed:${JSON.parse(argsJSON).text}`; + toolCalls.push({ name, args: argsJSON, result }); + return result; // exercise the plain-string result path +}; + +// ---- instantiate ----------------------------------------------------------- +const go = new globalThis.Go(); +const { instance } = await WebAssembly.instantiate(readFileSync(wasmPath), go.importObject); +go.run(instance); // do NOT await: main() blocks on select{} to stay resident + +for (let i = 0; i < 400 && !globalThis.aiscanAgentReady; i++) { + await new Promise((r) => setTimeout(r, 5)); +} +check(globalThis.aiscanAgentReady === true, 'aiscanAgentReady is true after load'); +check(typeof globalThis.aiscanRunAgent === 'function', 'aiscanRunAgent exported'); +check(typeof globalThis.aiscanCancelAgent === 'function', 'aiscanCancelAgent exported'); + +// ---- phase 1: happy path (LLM -> tool -> LLM -> done) ---------------------- +const events = []; +const payload = { + run_id: 'smoke-1', + prompt: 'please echo hello', + system_prompt: 'You are a test agent.', + model: 'test-model', + max_turns: 5, + tools: [{ + name: 'echo', + description: 'Echo the given text', + parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, + }], +}; + +const resultJSON = await Promise.race([ + globalThis.aiscanRunAgent(JSON.stringify(payload), (evJSON) => events.push(JSON.parse(evJSON))), + timeout(10000, 'happy-path run did not settle'), +]); +const result = JSON.parse(resultJSON); + +check(llmCall === 2, `LLM called twice (got ${llmCall})`); +check(toolCalls.length === 1 && toolCalls[0].name === 'echo', 'echo tool dispatched once via __aiscanTool'); +check(JSON.parse(toolCalls[0].args).text === 'hello', 'tool received the arguments from the LLM'); +check( + (llmRequests[0].tools || []).some((t) => t.function?.name === 'echo'), + 'tool schema was advertised to the LLM', +); +check(llmRequests[0].messages?.[0]?.role === 'system', 'system prompt reached the provider'); +check(result.stop === 'completed', `run stop=completed (got ${result.stop})`); +check(result.turns === 2, `run took 2 turns (got ${result.turns})`); +check(result.output === 'done: echoed:hello', `final output threaded tool result (got ${JSON.stringify(result.output)})`); +check(Array.isArray(result.messages) && result.messages.length >= 4, `transcript has >=4 messages (got ${result.messages?.length})`); +check(result.usage?.total_tokens === 41, `usage summed across turns (got ${result.usage?.total_tokens})`); + +const evTypes = new Set(events.map((e) => e.type)); +for (const t of ['agent_start', 'llm_request', 'tool_execution_start', 'tool_execution_end', 'agent_end']) { + check(evTypes.has(t), `event stream emitted ${t}`); +} + +// ---- phase 2: cancellation over the JS/WASM boundary ----------------------- +globalThis.__aiscanLLM = () => new Promise(() => {}); // never resolves +const hangPromise = globalThis.aiscanRunAgent( + JSON.stringify({ run_id: 'cancel-1', prompt: 'hang', model: 'm', max_turns: 3, tools: [] }), + () => {}, +); +await new Promise((r) => setTimeout(r, 60)); +const cancelled = globalThis.aiscanCancelAgent('cancel-1'); +check(cancelled === true, 'aiscanCancelAgent returns true for a live run'); +const cancelResult = JSON.parse(await Promise.race([hangPromise, timeout(5000, 'cancel run did not settle')])); +check(cancelResult.stop === 'canceled', `cancelled run stop=canceled (got ${cancelResult.stop})`); +check(globalThis.aiscanCancelAgent('no-such-run') === false, 'cancel of unknown run returns false'); + +// ---- summary --------------------------------------------------------------- +console.log(`\n${failures === 0 ? 'PASS' : 'FAIL'}: ${passes} passed, ${failures} failed`); +process.exit(failures === 0 ? 0 : 1); diff --git a/cmd/wasm/tools.go b/cmd/wasm/tools.go new file mode 100644 index 00000000..64248d1f --- /dev/null +++ b/cmd/wasm/tools.go @@ -0,0 +1,65 @@ +//go:build js && wasm + +package main + +import ( + "context" + "syscall/js" + + "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/commands" +) + +// jsTool is a host-dispatched tool: the wasm advertises the schema to the LLM +// but delegates execution to the JS global __aiscanTool(name, argsJSON) -> +// Promise. This is the seam that keeps browser drivers (go-rod / +// playwright) out of the wasm — browser tools run in the host on the user's real +// tab, the brain only decides which tool to call. +type jsTool struct { + def agent.ToolDefinition +} + +func (t *jsTool) Name() string { return t.def.Function.Name } +func (t *jsTool) Description() string { return t.def.Function.Description } +func (t *jsTool) Definition() commands.ToolDefinition { return t.def } + +func (t *jsTool) Execute(ctx context.Context, arguments string) (commands.ToolResult, error) { + fn := js.Global().Get(globalToolFn) + if fn.Type() != js.TypeFunction { + return commands.ErrorResult("host tool bridge " + globalToolFn + " is not defined"), nil + } + val, err := awaitValue(ctx, fn.Invoke(t.Name(), arguments)) + if err != nil { + // A ctx cancel or host rejection: surface as a tool error so the loop + // can heal the dangling tool_call rather than aborting the whole run. + return commands.ToolResult{}, err + } + return toolResultFromJS(val), nil +} + +// toolResultFromJS accepts either a plain string (the result text) or an object +// { text, is_error, terminate } so the host can signal errors and let a tool end +// the run (e.g. a finish/submit action). +func toolResultFromJS(v js.Value) commands.ToolResult { + switch v.Type() { + case js.TypeString: + return commands.TextResult(v.String()) + case js.TypeObject: + text := "" + if t := v.Get("text"); t.Type() == js.TypeString { + text = t.String() + } + res := commands.TextResult(text) + if e := v.Get("is_error"); e.Type() == js.TypeBoolean { + res.IsError = e.Bool() + } + if tm := v.Get("terminate"); tm.Type() == js.TypeBoolean { + res.Terminate = tm.Bool() + } + return res + case js.TypeUndefined, js.TypeNull: + return commands.TextResult("") + default: + return commands.TextResult(v.String()) + } +} From b7dfc23d82641d505152db6a2e68c763fa0d5799 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Wed, 8 Jul 2026 13:30:36 -0700 Subject: [PATCH 003/348] =?UTF-8?q?feat(agent):=20=E5=AF=B9=E9=BD=90=20Cyb?= =?UTF-8?q?erHub=20Copilot=20=E7=9A=84=20WASM=20=E9=80=9A=E4=BF=A1?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐插件 host (CyberHubCopilot src/offscreen/wasm-agent.ts) 的既有契约, 让编出的 agent.wasm 直接 drop-in 到 public/wasm/,无需改插件: - 导出 runAgent (host 调用名) + aiscanRunAgent 别名 + aiscanCancelAgent - payload 改 camelCase:{task, systemPrompt, model, maxTurns, messages, tools, context:{tabId,url,runId}};context 原样透传给工具桥 - __aiscanTool(name, args, ctx) 三参,解析 {content,isError,terminate} 信封 - 修复多轮:用 ag.LoadMessages() 注入历史 transcript(Run 从 state 取种子, 而非 Config.Messages) - 结果 {output,messages,turns,stop};StopReason 映射到 host 词表(stopped/ budget -> max_turns) - smoke.mjs 按真实契约重写(23/23:三缝 + 多轮 hydration + 取消) Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/wasm/README.md | 95 ++++++++++++++++----------------- cmd/wasm/bridge.go | 80 ++++++++++++++++++---------- cmd/wasm/main.go | 61 +++++++++++++-------- cmd/wasm/testdata/smoke.mjs | 73 ++++++++++++++++++-------- cmd/wasm/tools.go | 102 ++++++++++++++++++++++++++++-------- 5 files changed, 267 insertions(+), 144 deletions(-) diff --git a/cmd/wasm/README.md b/cmd/wasm/README.md index ffbab122..e113f54d 100644 --- a/cmd/wasm/README.md +++ b/cmd/wasm/README.md @@ -1,8 +1,8 @@ # cmd/wasm — aiscan agent-core as js/wasm -The **承重 spike (GATE)** for RFC [#189](https://github.com/chainreactors/CyberHub/issues/189) -方案 A: run aiscan's `pkg/agent` loop/evaluator as the *brain* inside a browser -extension, with the JS host owning every side effect. +RFC [#189](https://github.com/chainreactors/CyberHub/issues/189) 方案 A: run +aiscan's `pkg/agent` loop/evaluator as the *brain* inside a browser extension, +with the JS host owning every side effect. **One brain, shared with the CLI.** > **要单一化的是"脑(harness)",不是"手(浏览器操作)"。** The loop makes every > decision; go-rod / playwright / os-exec never enter the wasm. Browser tools are @@ -10,27 +10,28 @@ extension, with the JS host owning every side effect. > on the user's real logged-in tab via `chrome.scripting`. ``` - brain (this wasm) seam (syscall/js, JSON) hand (JS host) - ┌────────────────────┐ ┌──────────────────────────┐ ┌────────────────────┐ - │ agent-core │ LLM │ __aiscanLLM(reqJSON) │ --> │ real provider │ - │ loop / evaluator │────▶│ __aiscanTool(name, args) │ --> │ chrome.scripting │ - │ (agent.wasm) │◀────│ onEvent(eventJSON) │ <-- │ UI / progress │ - └────────────────────┘ └──────────────────────────┘ └────────────────────┘ + brain (this wasm) seam (syscall/js, JSON) hand (JS host) + ┌────────────────────┐ ┌────────────────────────────┐ ┌────────────────────┐ + │ agent-core │ LLM │ __aiscanLLM(reqJSON) │ --> │ real provider │ + │ loop / evaluator │────▶│ __aiscanTool(name,args,ctx) │ --> │ chrome.scripting │ + │ (agent.wasm) │◀────│ onEvent(eventJSON) │ <-- │ UI / progress │ + └────────────────────┘ └────────────────────────────┘ └────────────────────┘ ``` -## GATE result (this spike) +The host is CyberHub Copilot's `src/offscreen/wasm-agent.ts` +(`WasmAgentConversation`); this module's wire protocol matches it exactly, so the +built `agent.wasm` drops straight into `public/wasm/`. -| check | result | -|---|---| -| `pkg/agent` (+ evaluator, provider, commands) compiles `GOOS=js GOARCH=wasm` | ✅ **as-is, no strip needed** — it imports `pkg/commands`, never `pkg/tools` | -| runs end-to-end (LLM ↔ tool ↔ event ↔ cancel) | ✅ `testdata/smoke.mjs` — 21/21 | -| size, stripped (`-s -w`) | **~17 MB** | -| size, stripped + `gzip -9` | **~4.2 MB** | -| baseline: `finger.wasm` already shipping in the extension | 29 MB | +## Status -Standard Go wasm already lands **under** the fingerprint wasm the extension ships -today, so "体积可接受" holds without TinyGo. TinyGo (to shrink further) is a -follow-up, not a blocker — its `encoding/json` reflection is the known snag. +- ✅ `pkg/agent` (+ evaluator, provider, commands) compiles `GOOS=js GOARCH=wasm` + **as-is** — it imports `pkg/commands`, never `pkg/tools`. No strip needed. +- ✅ End-to-end verified two ways: `testdata/smoke.mjs` (23/23) against the built + module, and the extension's own real-host test (`WasmAgentConversation` + + this `agent.wasm` + the real `finish` tool + multi-turn). +- ✅ Size (standard Go, stripped `-s -w`): **~17 MB** / **~4.2 MB** gzipped — + *under* the `finger.wasm` (29 MB) the extension already ships. TinyGo (further + shrink) is a follow-up, not a blocker. ## Build @@ -38,9 +39,9 @@ follow-up, not a blocker — its `encoding/json` reflection is the known snag. make agent-wasm # -> dist/wasm/{agent.wasm, agent.wasm.gz, wasm_exec.js} + size ``` -Needs the matching `wasm_exec.js` (copied by the target from -`$(go env GOROOT)/lib/wasm/wasm_exec.js`) — it must come from the **same Go -version** that built the module. +`wasm_exec.js` must come from the **same Go version** that built the module +(the target copies it from `$(go env GOROOT)/lib/wasm/wasm_exec.js`). Ship it as +`wasm_exec.agent.js` in the extension's `public/wasm/`. ## Smoke test @@ -49,56 +50,56 @@ make agent-wasm node cmd/wasm/testdata/smoke.mjs dist/wasm/agent.wasm dist/wasm/wasm_exec.js ``` -## Wire protocol +## Wire protocol (matches wasm-agent.ts) -The host installs two function globals and passes a per-run event callback: +Host installs two function globals; the event sink is a per-run argument: - `__aiscanLLM(reqJSON) -> Promise` — `reqJSON` is an OpenAI-shaped `ChatCompletionRequest`; resolve a `ChatCompletionResponse` JSON string. The host owns provider choice, keys, caching and fallback. -- `__aiscanTool(name, argsJSON) -> Promise` — `result` is either a - string (result text) or `{ text, is_error, terminate }`. +- `__aiscanTool(name, argsJSON, ctxJSON) -> Promise` — `ctxJSON` is the + run's context blob, threaded verbatim. `result` is a JSON string + `{ content, isError, terminate }` (a plain string or object is also accepted). Exports (installed by `main`, gated on `aiscanAgentReady === true`): -- `aiscanRunAgent(payloadJSON, onEvent?) -> Promise` -- `aiscanCancelAgent(runId) -> bool` — aborts an in-flight run by `run_id`. +- `runAgent(payloadJSON, onEvent) -> Promise` — the name the host calls +- `aiscanRunAgent(...)` — alias of `runAgent` +- `aiscanCancelAgent(runId) -> bool` — aborts an in-flight run by `context.runId` -`onEvent(eventJSON)` receives each `agent.Event` (see `pkg/agent/event_json.go`). +`onEvent(eventJSON)` receives each `agent.Event` verbatim (see +`pkg/agent/event_json.go`); the host's `mapEvent()` translates it. ### payload ```jsonc { - "run_id": "abc", // key for aiscanCancelAgent - "prompt": "task...", - "system_prompt": "...", + "task": "the new user message", + "systemPrompt": "...", "model": "...", + "maxTurns": 25, "messages": [ /* prior transcript to hydrate */ ], "tools": [ { "name": "...", "description": "...", "parameters": { /* JSON schema */ } } ], - "max_turns": 20, - "max_parallel_tools": 4, - "max_tokens": 0, - "temperature": 0.0, - "token_budget": 0, - "eval": { "criteria": "acceptance criteria", "max_rounds": 3 } // omit for plain loop + "context": { "tabId": 12, "url": "https://…", "runId": "r1" }, + // optional: "temperature", "maxParallelTools", "maxTokens", "tokenBudget", + // "eval": { "criteria": "…", "maxRounds": 3 } // enables the Goal loop } ``` ### result ```jsonc -{ "output": "...", "messages": [...], "turns": 2, "stop": "completed", - "usage": { "total_tokens": 41, ... }, "error": "" } +{ "output": "...", "messages": [...], "turns": 2, "stop": "completed" } +// stop ∈ completed | terminated | max_turns | error (+ "usage", "error" when relevant) ``` A mid-flight failure still **resolves** with `error` + partial transcript; only a -malformed payload rejects. +malformed payload rejects. Transcript hydration is via `messages` in/out. -## Scope / not in this spike +## Scope / not in this module -- **Plugin host side** (`agent-manager.js`, tool dispatcher, `buildDomTree.js` - perception, security guardrails) — CyberHubCopilot repo, next step. -- **Session persistence host-out** — `session.go`'s `os`/`filepath` compile under - wasm but do nothing; the host hydrates via `messages` in/out instead. +- **Host side** lives in CyberHubCopilot (`src/offscreen/`, `allTools()`, browser + tools). This module is only the brain. +- **Session persistence** — `session.go`'s `os`/`filepath` compile under wasm but + do nothing; the host hydrates via `messages`. - **TinyGo** size pass. diff --git a/cmd/wasm/bridge.go b/cmd/wasm/bridge.go index 200f9038..29be746e 100644 --- a/cmd/wasm/bridge.go +++ b/cmd/wasm/bridge.go @@ -13,25 +13,37 @@ import ( ) // ---- wire types (host <-> wasm) -------------------------------------------- +// +// Field names match the CyberHub Copilot host (src/offscreen/wasm-agent.ts): +// camelCase, `task` for the new user message, a nested `context` blob passed +// verbatim to the tool bridge, and a `{output, messages, turns, stop}` result. -// runPayload is the JSON argument to aiscanRunAgent. It carries the task, the -// prior transcript to hydrate, and the tool schemas the host can execute. +// runPayload is the JSON argument to runAgent. type runPayload struct { - RunID string `json:"run_id"` - Prompt string `json:"prompt"` - SystemPrompt string `json:"system_prompt"` - Model string `json:"model"` - Messages []agent.ChatMessage `json:"messages"` - Tools []toolSchema `json:"tools"` - MaxTurns int `json:"max_turns"` - MaxParallelTools int `json:"max_parallel_tools"` - MaxTokens int `json:"max_tokens"` - Temperature *float64 `json:"temperature"` - TokenBudget int `json:"token_budget"` - Eval *evalSpec `json:"eval"` + Task string `json:"task"` + SystemPrompt string `json:"systemPrompt"` + Model string `json:"model"` + MaxTurns int `json:"maxTurns"` + Messages []agent.ChatMessage `json:"messages"` + Tools []toolSchema `json:"tools"` + // context is threaded, untouched, to __aiscanTool so the host can target the + // right tab and observe the right abort signal. Kept raw to avoid re-shaping. + Context json.RawMessage `json:"context"` + // Optional knobs (not sent by the current host; supported for completeness). + MaxParallelTools int `json:"maxParallelTools"` + MaxTokens int `json:"maxTokens"` + Temperature *float64 `json:"temperature"` + TokenBudget int `json:"tokenBudget"` + Eval *evalSpec `json:"eval"` } -// toolSchema is a host-executed tool advertised to the LLM. The wasm only holds +// runContext is the subset of `context` the wasm itself needs (the run id, used +// as the cancellation key and session id). +type runContext struct { + RunID string `json:"runId"` +} + +// toolSchema is a host-executed tool advertised to the LLM. The wasm holds only // the schema; execution is delegated to the JS host via __aiscanTool. type toolSchema struct { Name string `json:"name"` @@ -53,19 +65,30 @@ func (t toolSchema) definition() agent.ToolDefinition { // evalSpec enables the evaluator (Goal) loop when Criteria is non-empty. type evalSpec struct { Criteria string `json:"criteria"` - MaxRounds int `json:"max_rounds"` + MaxRounds int `json:"maxRounds"` } -// runResult is the JSON the run Promise resolves with. +// runResult is the JSON the run Promise resolves with. Matches what +// WasmAgentConversation.send() reads: output / messages / turns / stop. type runResult struct { - Output string `json:"output"` - Messages []agent.ChatMessage `json:"messages"` - NewMessages []agent.ChatMessage `json:"new_messages,omitempty"` - Turns int `json:"turns"` - Stop string `json:"stop"` - Usage agent.Usage `json:"usage"` - ContextTokens int `json:"context_tokens,omitempty"` - Error string `json:"error,omitempty"` + Output string `json:"output"` + Messages []agent.ChatMessage `json:"messages"` + Turns int `json:"turns"` + Stop string `json:"stop"` + Usage agent.Usage `json:"usage"` + Error string `json:"error,omitempty"` +} + +// mapStop projects aiscan's richer StopReason set onto the host's vocabulary +// ("completed" | "terminated" | "max_turns" | "error"). A user-cancelled run is +// detected host-side via the abort signal, so its exact stop string is moot. +func mapStop(s agent.StopReason) string { + switch s { + case agent.StopReasonStopped, agent.StopReasonBudget: + return "max_turns" + default: + return string(s) + } } func marshalResult(result *agent.Result, runErr error) (string, error) { @@ -73,11 +96,9 @@ func marshalResult(result *agent.Result, runErr error) (string, error) { if result != nil { out.Output = result.Output out.Messages = result.Messages - out.NewMessages = result.NewMessages out.Turns = result.Turns - out.Stop = string(result.Stop) + out.Stop = mapStop(result.Stop) out.Usage = result.TotalUsage - out.ContextTokens = result.ContextTokens if result.Err != nil { out.Error = result.Err.Error() } @@ -85,6 +106,9 @@ func marshalResult(result *agent.Result, runErr error) (string, error) { if runErr != nil && out.Error == "" { out.Error = runErr.Error() } + if out.Error != "" && (out.Stop == "" || out.Stop == "completed") { + out.Stop = "error" + } data, err := json.Marshal(out) if err != nil { return "", fmt.Errorf("marshal result: %w", err) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 946ebe5b..aa1626f9 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -5,21 +5,23 @@ // (方案 A). The Go loop/evaluator make every decision; the JS host owns every // side effect. Three seams connect them, all crossing the boundary as JSON: // -// brain (this wasm) seam (syscall/js) hand (JS host) -// ---------------- ----------------- -------------- -// agent.Config.Provider -> __aiscanLLM(reqJSON) -> real OpenAI/Anthropic provider -// commands.Registry -> __aiscanTool(name, args) -> chrome.scripting on the real tab -// eventbus.Bus[Event] -> onEvent(eventJSON) -> UI / progress stream +// brain (this wasm) seam (syscall/js) hand (JS host) +// ---------------- ----------------- -------------- +// agent.Config.Provider -> __aiscanLLM(reqJSON) -> real OpenAI/Anthropic provider +// commands.Registry -> __aiscanTool(name,args,ctx) -> chrome.scripting on the real tab +// eventbus.Bus[Event] -> onEvent(eventJSON) -> UI / progress stream // // The wasm never imports go-rod / playwright / os-exec and never touches the // DOM: browser tools are registered as host-dispatched stubs (schema only, -// execution delegated to __aiscanTool). See README.md for the wire protocol. +// execution delegated to __aiscanTool). The wire protocol matches the host in +// CyberHub Copilot's src/offscreen/wasm-agent.ts. See README.md. // // Exported globals: // // aiscanAgentReady : bool // set true once exports are installed -// aiscanRunAgent(payloadJSON, onEvent?) -> Promise -// aiscanCancelAgent(runId) -> bool // aborts an in-flight run by run_id +// runAgent(payloadJSON, onEvent) -> Promise // name the host calls +// aiscanRunAgent(...) // alias of runAgent +// aiscanCancelAgent(runId) -> bool // aborts an in-flight run by context.runId package main import ( @@ -36,15 +38,15 @@ import ( ) // Names of the JS globals the host installs for the LLM and tool seams. The -// event seam is passed per-run as the second argument to aiscanRunAgent. +// event seam is passed per-run as the second argument to runAgent. const ( globalLLMFn = "__aiscanLLM" globalToolFn = "__aiscanTool" globalReadyCB = "__aiscanOnReady" ) -// cancels tracks the CancelFunc for every in-flight run keyed by run_id so the -// host can abort a specific run across the JS/WASM boundary. +// cancels tracks the CancelFunc for every in-flight run keyed by context.runId +// so the host can abort a specific run across the JS/WASM boundary. var ( cancelMu sync.Mutex cancels = map[string]context.CancelFunc{} @@ -83,8 +85,8 @@ func cancelRun(_ js.Value, args []js.Value) any { return true } -// runAgent implements aiscanRunAgent(payloadJSON, onEvent?) -> Promise. -// It returns immediately with a Promise; the loop runs on a goroutine so the JS +// runAgent implements runAgent(payloadJSON, onEvent) -> Promise. It +// returns immediately with a Promise; the loop runs on a goroutine so the JS // event loop stays free to resolve the LLM/tool promises the loop awaits. func runAgent(_ js.Value, args []js.Value) any { payloadJSON := "" @@ -115,9 +117,18 @@ func doRun(payloadJSON string, onEvent js.Value) (string, error) { return "", fmt.Errorf("parse payload: %w", err) } + // The context blob ({tabId, url, runId}) is passed to __aiscanTool verbatim + // so the host targets the right tab and observes the right abort signal. + ctxJSON := "null" + if len(p.Context) > 0 { + ctxJSON = string(p.Context) + } + var rc runContext + _ = json.Unmarshal(p.Context, &rc) + reg := commands.NewRegistry() for _, ts := range p.Tools { - reg.RegisterTool(&jsTool{def: ts.definition()}) + reg.RegisterTool(&jsTool{def: ts.definition(), ctxJSON: ctxJSON}) } bus := eventbus.New[agent.Event]() @@ -137,25 +148,27 @@ func doRun(payloadJSON string, onEvent js.Value) (string, error) { Tools: reg, Model: p.Model, SystemPrompt: p.SystemPrompt, - Messages: p.Messages, - MaxTokens: p.MaxTokens, - Temperature: p.Temperature, MaxTurns: p.MaxTurns, MaxParallelTools: p.MaxParallelTools, + MaxTokens: p.MaxTokens, + Temperature: p.Temperature, TokenBudget: p.TokenBudget, Bus: bus, - SessionID: p.RunID, + SessionID: rc.RunID, // The JS provider is non-streaming; the loop falls back to // ChatCompletion (retry.go) automatically, but be explicit. Stream: false, } ctx, cancel := context.WithCancel(context.Background()) - registerCancel(p.RunID, cancel) - defer unregisterCancel(p.RunID) + registerCancel(rc.RunID, cancel) + defer unregisterCancel(rc.RunID) defer cancel() ag := agent.NewAgent(cfg) + // Hydrate the prior transcript into agent state; Run seeds the loop from + // state (not Config.Messages), so this is how multi-turn continues. + ag.LoadMessages(p.Messages) var ( result *agent.Result @@ -170,20 +183,22 @@ func doRun(payloadJSON string, onEvent js.Value) (string, error) { Model: cfg.Model, }), MaxEvalRounds: p.Eval.MaxRounds, - Goal: p.Prompt, + Goal: p.Task, Criteria: p.Eval.Criteria, Bus: bus, } result, _, runErr = evaluator.RunWithEval(ctx, ag, evalCfg) } else { - result, runErr = ag.Run(ctx, p.Prompt) + result, runErr = ag.Run(ctx, p.Task) } return marshalResult(result, runErr) } func main() { - js.Global().Set("aiscanRunAgent", js.FuncOf(runAgent)) + run := js.FuncOf(runAgent) + js.Global().Set("runAgent", run) // the name the extension host calls + js.Global().Set("aiscanRunAgent", run) // alias js.Global().Set("aiscanCancelAgent", js.FuncOf(cancelRun)) js.Global().Set("aiscanAgentReady", js.ValueOf(true)) if cb := js.Global().Get(globalReadyCB); cb.Type() == js.TypeFunction { diff --git a/cmd/wasm/testdata/smoke.mjs b/cmd/wasm/testdata/smoke.mjs index 5f8434cb..2d0539f3 100644 --- a/cmd/wasm/testdata/smoke.mjs +++ b/cmd/wasm/testdata/smoke.mjs @@ -1,8 +1,10 @@ // End-to-end smoke test for the agent-core wasm module (RFC #189, 方案 A). // -// Instantiates agent.wasm under Node with stubbed host seams (__aiscanLLM, -// __aiscanTool, onEvent) and drives one full tool-using turn plus a cancel, to -// prove the three JS<->WASM seams work — not just that the module compiles. +// Instantiates agent.wasm under Node with stubbed host seams that mirror the +// CyberHub Copilot host contract (src/offscreen/wasm-agent.ts): runAgent + +// __aiscanLLM + __aiscanTool(name,args,ctx) returning {content,isError, +// terminate}. Drives one full tool-using turn plus a cancel, to prove the three +// JS<->WASM seams work — not just that the module compiles. // // node smoke.mjs // (or set AISCAN_WASM / WASM_EXEC) @@ -36,7 +38,7 @@ const timeout = (ms, why) => // Go's wasm_exec.js is a plain script that installs globalThis.Go. vm.runInThisContext(readFileSync(wasmExecPath, 'utf8'), { filename: wasmExecPath }); -// ---- host seams (stubs) ---------------------------------------------------- +// ---- host seams (stubs mirroring wasm-agent.ts) ---------------------------- const llmRequests = []; const toolCalls = []; let llmCall = 0; @@ -48,11 +50,11 @@ globalThis.__aiscanLLM = async (reqJSON) => { if (llmCall === 1) { // Turn 1: instruct the brain to call the echo tool. return JSON.stringify({ - id: 'resp-1', + id: 'wasm', choices: [{ message: { role: 'assistant', - content: null, + content: '', tool_calls: [{ id: 'call_1', type: 'function', @@ -61,22 +63,22 @@ globalThis.__aiscanLLM = async (reqJSON) => { }, finish_reason: 'tool_calls', }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }); } // Turn 2: no tool calls -> the loop completes. const echoed = toolCalls[0]?.result ?? ''; return JSON.stringify({ - id: 'resp-2', + id: 'wasm', choices: [{ message: { role: 'assistant', content: `done: ${echoed}` }, finish_reason: 'stop' }], - usage: { prompt_tokens: 20, completion_tokens: 6, total_tokens: 26 }, }); }; -globalThis.__aiscanTool = async (name, argsJSON) => { +// The extension's __aiscanTool takes (name, argsJSON, ctxJSON) and returns a +// JSON string {content, isError, terminate}. +globalThis.__aiscanTool = async (name, argsJSON, ctxJSON) => { const result = `echoed:${JSON.parse(argsJSON).text}`; - toolCalls.push({ name, args: argsJSON, result }); - return result; // exercise the plain-string result path + toolCalls.push({ name, args: argsJSON, ctx: ctxJSON, result }); + return JSON.stringify({ content: result, isError: false, terminate: false }); }; // ---- instantiate ----------------------------------------------------------- @@ -88,26 +90,27 @@ for (let i = 0; i < 400 && !globalThis.aiscanAgentReady; i++) { await new Promise((r) => setTimeout(r, 5)); } check(globalThis.aiscanAgentReady === true, 'aiscanAgentReady is true after load'); -check(typeof globalThis.aiscanRunAgent === 'function', 'aiscanRunAgent exported'); +check(typeof globalThis.runAgent === 'function', 'runAgent exported (host-facing name)'); check(typeof globalThis.aiscanCancelAgent === 'function', 'aiscanCancelAgent exported'); // ---- phase 1: happy path (LLM -> tool -> LLM -> done) ---------------------- const events = []; const payload = { - run_id: 'smoke-1', - prompt: 'please echo hello', - system_prompt: 'You are a test agent.', + task: 'please echo hello', + systemPrompt: 'You are a test agent.', model: 'test-model', - max_turns: 5, + maxTurns: 5, + messages: [], tools: [{ name: 'echo', description: 'Echo the given text', parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, }], + context: { tabId: 42, url: 'https://example.com', runId: 'r1' }, }; const resultJSON = await Promise.race([ - globalThis.aiscanRunAgent(JSON.stringify(payload), (evJSON) => events.push(JSON.parse(evJSON))), + globalThis.runAgent(JSON.stringify(payload), (evJSON) => events.push(JSON.parse(evJSON))), timeout(10000, 'happy-path run did not settle'), ]); const result = JSON.parse(resultJSON); @@ -115,6 +118,7 @@ const result = JSON.parse(resultJSON); check(llmCall === 2, `LLM called twice (got ${llmCall})`); check(toolCalls.length === 1 && toolCalls[0].name === 'echo', 'echo tool dispatched once via __aiscanTool'); check(JSON.parse(toolCalls[0].args).text === 'hello', 'tool received the arguments from the LLM'); +check(JSON.parse(toolCalls[0].ctx || '{}').runId === 'r1', 'tool received the run context (tabId/url/runId)'); check( (llmRequests[0].tools || []).some((t) => t.function?.name === 'echo'), 'tool schema was advertised to the LLM', @@ -124,24 +128,47 @@ check(result.stop === 'completed', `run stop=completed (got ${result.stop})`); check(result.turns === 2, `run took 2 turns (got ${result.turns})`); check(result.output === 'done: echoed:hello', `final output threaded tool result (got ${JSON.stringify(result.output)})`); check(Array.isArray(result.messages) && result.messages.length >= 4, `transcript has >=4 messages (got ${result.messages?.length})`); -check(result.usage?.total_tokens === 41, `usage summed across turns (got ${result.usage?.total_tokens})`); const evTypes = new Set(events.map((e) => e.type)); for (const t of ['agent_start', 'llm_request', 'tool_execution_start', 'tool_execution_end', 'agent_end']) { check(evTypes.has(t), `event stream emitted ${t}`); } -// ---- phase 2: cancellation over the JS/WASM boundary ----------------------- +// ---- phase 2: multi-turn continuation (transcript hydration) --------------- +llmCall = 0; +toolCalls.length = 0; +globalThis.__aiscanLLM = async (reqJSON) => { + llmRequests.push(JSON.parse(reqJSON)); + return JSON.stringify({ + id: 'wasm', + choices: [{ message: { role: 'assistant', content: 'second turn ok' }, finish_reason: 'stop' }], + }); +}; +const cont = JSON.parse(await Promise.race([ + globalThis.runAgent(JSON.stringify({ + ...payload, + task: 'and now continue', + messages: result.messages, // hydrate prior transcript + context: { tabId: 42, url: 'https://example.com', runId: 'r2' }, + }), () => {}), + timeout(10000, 'continuation run did not settle'), +])); +const lastReq = llmRequests[llmRequests.length - 1]; +const userMsgs = (lastReq.messages || []).filter((m) => m.role === 'user').length; +check(cont.stop === 'completed' && cont.output === 'second turn ok', 'continuation completed'); +check(userMsgs >= 2, `prior transcript hydrated into the 2nd run (user msgs=${userMsgs})`); + +// ---- phase 3: cancellation over the JS/WASM boundary ----------------------- globalThis.__aiscanLLM = () => new Promise(() => {}); // never resolves -const hangPromise = globalThis.aiscanRunAgent( - JSON.stringify({ run_id: 'cancel-1', prompt: 'hang', model: 'm', max_turns: 3, tools: [] }), +const hangPromise = globalThis.runAgent( + JSON.stringify({ task: 'hang', model: 'm', maxTurns: 3, messages: [], tools: [], context: { runId: 'cancel-1' } }), () => {}, ); await new Promise((r) => setTimeout(r, 60)); const cancelled = globalThis.aiscanCancelAgent('cancel-1'); check(cancelled === true, 'aiscanCancelAgent returns true for a live run'); const cancelResult = JSON.parse(await Promise.race([hangPromise, timeout(5000, 'cancel run did not settle')])); -check(cancelResult.stop === 'canceled', `cancelled run stop=canceled (got ${cancelResult.stop})`); +check(cancelResult.stop === 'error' || cancelResult.error, `cancelled run ended (stop=${cancelResult.stop})`); check(globalThis.aiscanCancelAgent('no-such-run') === false, 'cancel of unknown run returns false'); // ---- summary --------------------------------------------------------------- diff --git a/cmd/wasm/tools.go b/cmd/wasm/tools.go index 64248d1f..58c40803 100644 --- a/cmd/wasm/tools.go +++ b/cmd/wasm/tools.go @@ -4,6 +4,8 @@ package main import ( "context" + "encoding/json" + "strings" "syscall/js" "github.com/chainreactors/aiscan/pkg/agent" @@ -11,12 +13,14 @@ import ( ) // jsTool is a host-dispatched tool: the wasm advertises the schema to the LLM -// but delegates execution to the JS global __aiscanTool(name, argsJSON) -> -// Promise. This is the seam that keeps browser drivers (go-rod / +// but delegates execution to the JS global __aiscanTool(name, argsJSON, ctxJSON) +// -> Promise. This is the seam that keeps browser drivers (go-rod / // playwright) out of the wasm — browser tools run in the host on the user's real -// tab, the brain only decides which tool to call. +// tab; the brain only decides which tool to call. ctxJSON is the run's context +// blob ({tabId, url, runId}) threaded verbatim so the host hits the right tab. type jsTool struct { - def agent.ToolDefinition + def agent.ToolDefinition + ctxJSON string } func (t *jsTool) Name() string { return t.def.Function.Name } @@ -28,38 +32,90 @@ func (t *jsTool) Execute(ctx context.Context, arguments string) (commands.ToolRe if fn.Type() != js.TypeFunction { return commands.ErrorResult("host tool bridge " + globalToolFn + " is not defined"), nil } - val, err := awaitValue(ctx, fn.Invoke(t.Name(), arguments)) + val, err := awaitValue(ctx, fn.Invoke(t.Name(), arguments, t.ctxJSON)) if err != nil { - // A ctx cancel or host rejection: surface as a tool error so the loop - // can heal the dangling tool_call rather than aborting the whole run. + // A ctx cancel or host rejection: surface to the loop, which cancels + // cleanly (canceled ctx) or heals the dangling tool_call. return commands.ToolResult{}, err } return toolResultFromJS(val), nil } -// toolResultFromJS accepts either a plain string (the result text) or an object -// { text, is_error, terminate } so the host can signal errors and let a tool end -// the run (e.g. a finish/submit action). +// toolEnvelope is the host's __aiscanTool reply shape. Pointers distinguish +// "field present" from "zero value" so a bare JSON blob isn't mistaken for it. +type toolEnvelope struct { + Content *string `json:"content"` + Text *string `json:"text"` + IsError *bool `json:"isError"` + IsErrorSnake *bool `json:"is_error"` + Terminate *bool `json:"terminate"` +} + +// toolResultFromJS parses the host reply. The extension returns a JSON string +// {content, isError, terminate}; a simpler host may return a plain string or an +// object — all three are accepted. func toolResultFromJS(v js.Value) commands.ToolResult { switch v.Type() { case js.TypeString: - return commands.TextResult(v.String()) + return parseToolEnvelope(v.String()) case js.TypeObject: - text := "" - if t := v.Get("text"); t.Type() == js.TypeString { - text = t.String() - } - res := commands.TextResult(text) - if e := v.Get("is_error"); e.Type() == js.TypeBoolean { - res.IsError = e.Bool() - } - if tm := v.Get("terminate"); tm.Type() == js.TypeBoolean { - res.Terminate = tm.Bool() - } - return res + return toolResultFromObject(v) case js.TypeUndefined, js.TypeNull: return commands.TextResult("") default: return commands.TextResult(v.String()) } } + +func parseToolEnvelope(s string) commands.ToolResult { + if strings.HasPrefix(strings.TrimSpace(s), "{") { + var env toolEnvelope + if json.Unmarshal([]byte(s), &env) == nil && + (env.Content != nil || env.Text != nil || env.IsError != nil || env.IsErrorSnake != nil || env.Terminate != nil) { + return env.result() + } + } + // Not the envelope — treat the whole string as the result text. + return commands.TextResult(s) +} + +func (env toolEnvelope) result() commands.ToolResult { + content := "" + switch { + case env.Content != nil: + content = *env.Content + case env.Text != nil: + content = *env.Text + } + res := commands.TextResult(content) + switch { + case env.IsError != nil: + res.IsError = *env.IsError + case env.IsErrorSnake != nil: + res.IsError = *env.IsErrorSnake + } + if env.Terminate != nil { + res.Terminate = *env.Terminate + } + return res +} + +func toolResultFromObject(v js.Value) commands.ToolResult { + get := func(k string) js.Value { return v.Get(k) } + content := "" + if c := get("content"); c.Type() == js.TypeString { + content = c.String() + } else if t := get("text"); t.Type() == js.TypeString { + content = t.String() + } + res := commands.TextResult(content) + if e := get("isError"); e.Type() == js.TypeBoolean { + res.IsError = e.Bool() + } else if e := get("is_error"); e.Type() == js.TypeBoolean { + res.IsError = e.Bool() + } + if tm := get("terminate"); tm.Type() == js.TypeBoolean { + res.Terminate = tm.Bool() + } + return res +} From 63fdb82d756abf448848f1ac2f7d43546e68c883 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Sat, 11 Jul 2026 03:42:26 -0700 Subject: [PATCH 004/348] =?UTF-8?q?feat(wasm):=20=5F=5FaiscanLLM=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20per-run=20=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM 桥接现在把每次运行的 ctxJSON({tabId,url,runId}) 一并传给宿主 __aiscanLLM(reqJSON, ctxJSON),让宿主能把 LLM fetch 的取消绑定到对应 run;jsProvider 持有 ctxJSON,README 与 smoke 测试(24/24)同步。 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/wasm/README.md | 10 ++++++---- cmd/wasm/main.go | 4 ++-- cmd/wasm/provider.go | 12 ++++++++---- cmd/wasm/testdata/smoke.mjs | 8 ++++++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/cmd/wasm/README.md b/cmd/wasm/README.md index e113f54d..d0f84ccf 100644 --- a/cmd/wasm/README.md +++ b/cmd/wasm/README.md @@ -26,7 +26,7 @@ built `agent.wasm` drops straight into `public/wasm/`. - ✅ `pkg/agent` (+ evaluator, provider, commands) compiles `GOOS=js GOARCH=wasm` **as-is** — it imports `pkg/commands`, never `pkg/tools`. No strip needed. -- ✅ End-to-end verified two ways: `testdata/smoke.mjs` (23/23) against the built +- ✅ End-to-end verified two ways: `testdata/smoke.mjs` (24/24) against the built module, and the extension's own real-host test (`WasmAgentConversation` + this `agent.wasm` + the real `finish` tool + multi-turn). - ✅ Size (standard Go, stripped `-s -w`): **~17 MB** / **~4.2 MB** gzipped — @@ -54,9 +54,11 @@ node cmd/wasm/testdata/smoke.mjs dist/wasm/agent.wasm dist/wasm/wasm_exec.js Host installs two function globals; the event sink is a per-run argument: -- `__aiscanLLM(reqJSON) -> Promise` — `reqJSON` is an OpenAI-shaped - `ChatCompletionRequest`; resolve a `ChatCompletionResponse` JSON string. The - host owns provider choice, keys, caching and fallback. +- `__aiscanLLM(reqJSON, ctxJSON) -> Promise` — `reqJSON` is an + OpenAI-shaped `ChatCompletionRequest`; `ctxJSON` is the run context + `{tabId,url,runId}` so the host can bind cancellation. Resolve a + `ChatCompletionResponse` JSON string. The host owns provider choice, keys, + caching and fallback. - `__aiscanTool(name, argsJSON, ctxJSON) -> Promise` — `ctxJSON` is the run's context blob, threaded verbatim. `result` is a JSON string `{ content, isError, terminate }` (a plain string or object is also accepted). diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index aa1626f9..ccbc94ac 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -7,7 +7,7 @@ // // brain (this wasm) seam (syscall/js) hand (JS host) // ---------------- ----------------- -------------- -// agent.Config.Provider -> __aiscanLLM(reqJSON) -> real OpenAI/Anthropic provider +// agent.Config.Provider -> __aiscanLLM(reqJSON, ctxJSON) -> real OpenAI/Anthropic provider // commands.Registry -> __aiscanTool(name,args,ctx) -> chrome.scripting on the real tab // eventbus.Bus[Event] -> onEvent(eventJSON) -> UI / progress stream // @@ -144,7 +144,7 @@ func doRun(payloadJSON string, onEvent js.Value) (string, error) { } cfg := agent.Config{ - Provider: &jsProvider{}, + Provider: &jsProvider{ctxJSON: ctxJSON}, Tools: reg, Model: p.Model, SystemPrompt: p.SystemPrompt, diff --git a/cmd/wasm/provider.go b/cmd/wasm/provider.go index 0bfaa659..6f07c102 100644 --- a/cmd/wasm/provider.go +++ b/cmd/wasm/provider.go @@ -12,9 +12,13 @@ import ( ) // jsProvider implements agent.Provider by bridging every ChatCompletion to the -// JS global __aiscanLLM(reqJSON) -> Promise. The host owns provider -// selection, keys, caching and fallback; the brain just asks for a completion. -type jsProvider struct{} +// JS global __aiscanLLM(reqJSON, ctxJSON) -> Promise. The host owns +// provider selection, keys, caching and fallback; the brain just asks for a +// completion. ctxJSON is the same per-run context used by tools, so the host can +// bind LLM fetch cancellation to the right run. +type jsProvider struct { + ctxJSON string +} func (p *jsProvider) Name() string { return "wasm-host" } @@ -29,7 +33,7 @@ func (p *jsProvider) ChatCompletion(ctx context.Context, req *agent.ChatCompleti return nil, fmt.Errorf("host LLM bridge %q is not defined", globalLLMFn) } - val, err := awaitValue(ctx, fn.Invoke(string(reqJSON))) + val, err := awaitValue(ctx, fn.Invoke(string(reqJSON), p.ctxJSON)) if err != nil { return nil, err } diff --git a/cmd/wasm/testdata/smoke.mjs b/cmd/wasm/testdata/smoke.mjs index 2d0539f3..8f5e02e4 100644 --- a/cmd/wasm/testdata/smoke.mjs +++ b/cmd/wasm/testdata/smoke.mjs @@ -40,12 +40,14 @@ vm.runInThisContext(readFileSync(wasmExecPath, 'utf8'), { filename: wasmExecPath // ---- host seams (stubs mirroring wasm-agent.ts) ---------------------------- const llmRequests = []; +const llmContexts = []; const toolCalls = []; let llmCall = 0; -globalThis.__aiscanLLM = async (reqJSON) => { +globalThis.__aiscanLLM = async (reqJSON, ctxJSON) => { const req = JSON.parse(reqJSON); llmRequests.push(req); + llmContexts.push(JSON.parse(ctxJSON || '{}')); llmCall++; if (llmCall === 1) { // Turn 1: instruct the brain to call the echo tool. @@ -123,6 +125,7 @@ check( (llmRequests[0].tools || []).some((t) => t.function?.name === 'echo'), 'tool schema was advertised to the LLM', ); +check(llmContexts[0]?.runId === 'r1', 'LLM received the run context (runId)'); check(llmRequests[0].messages?.[0]?.role === 'system', 'system prompt reached the provider'); check(result.stop === 'completed', `run stop=completed (got ${result.stop})`); check(result.turns === 2, `run took 2 turns (got ${result.turns})`); @@ -137,8 +140,9 @@ for (const t of ['agent_start', 'llm_request', 'tool_execution_start', 'tool_exe // ---- phase 2: multi-turn continuation (transcript hydration) --------------- llmCall = 0; toolCalls.length = 0; -globalThis.__aiscanLLM = async (reqJSON) => { +globalThis.__aiscanLLM = async (reqJSON, ctxJSON) => { llmRequests.push(JSON.parse(reqJSON)); + llmContexts.push(JSON.parse(ctxJSON || '{}')); return JSON.stringify({ id: 'wasm', choices: [{ message: { role: 'assistant', content: 'second turn ok' }, finish_reason: 'stop' }], From 2eb32d7d7b1a0bab70c5bf8247a55afc7c119ab1 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Sat, 11 Jul 2026 03:42:26 -0700 Subject: [PATCH 005/348] =?UTF-8?q?feat(web):=20=E9=9D=99=E6=80=81?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E6=8C=89=E6=8C=87=E7=BA=B9=E6=B0=B8=E4=B9=85?= =?UTF-8?q?=E7=BC=93=E5=AD=98,index.html=20=E4=B8=8D=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite 给每个资源打指纹(index-.js),字节不变故 assets/ 下 immutable 永久缓存;index.html 是唯一未指纹化、且携带每次启动 access key 的文档,改为 no-cache,避免重启后仍加载旧 bundle 或失效的 key。 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/aiscan/web_full.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 09e2549b..a6df8dc8 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -128,13 +128,23 @@ func newSPAFileServer(fsys fs.FS, accessKey string) http.HandlerFunc { if name != "" { if f, err := fsys.Open(name); err == nil { f.Close() + // Vite fingerprints every asset (index-.js), so a given + // filename's bytes never change — cache it forever. A rebuild + // mints new filenames, so this never serves stale content. + if strings.HasPrefix(name, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } fileServer.ServeHTTP(w, r) return } } - // Serve injected index.html for SPA routes + // Serve injected index.html for SPA routes. Never cache it: it's the one + // unfingerprinted document, it carries the per-start access key, and it + // points at the current asset hashes — a cached shell would keep loading a + // stale bundle (or a dead access key after a restart) until a hard refresh. if len(indexBytes) > 0 { w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(http.StatusOK) _, _ = w.Write(indexBytes) return From 66c14f1ca9141970240abe8d3282f73d10c09c93 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Sat, 11 Jul 2026 03:42:26 -0700 Subject: [PATCH 006/348] =?UTF-8?q?fix(web):=20=E6=9C=AC=E5=9C=B0=20Agent?= =?UTF-8?q?=20=E6=90=BA=E5=B8=A6=20access=20key=20=E8=AE=A4=E8=AF=81=20/ap?= =?UTF-8?q?i/agent/ws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hub 的 /api/* 受 access key 保护,此前本地启动的 agent 拨 WS 未带 key 导致 401、卡在"连接中"死循环。现 --web-url 以 userinfo 形式内嵌 token(http://@host),webagent 拨号时把 token 提出来用 Authorization: Bearer 带上、拨无 userinfo 的地址;无 key 时 header 为空、行为不变(鉴权关闭/测试)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/web/localagent.go | 32 +++++++++++++++++++++++++------- pkg/webagent/agent.go | 28 ++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/pkg/web/localagent.go b/pkg/web/localagent.go index ef7049ed..3038885c 100644 --- a/pkg/web/localagent.go +++ b/pkg/web/localagent.go @@ -33,9 +33,10 @@ type localProc struct { // like any node. The hub holds the only handle to these processes, so StopAll // kills them on shutdown rather than leaving orphans. type LocalAgents struct { - webURL string // hub loopback address children dial (derived from web --addr) - ioaURL string // hub IOA endpoint carrying the embedded access token - pool *AgentPool // live pool, for registration/busy cross-reference + webURL string // hub loopback address children dial (derived from web --addr) + webAuthURL string // same base with the access token as userinfo, for /api/agent/ws auth + ioaURL string // hub IOA endpoint carrying the embedded access token + pool *AgentPool // live pool, for registration/busy cross-reference mu sync.Mutex procs []*localProc @@ -47,12 +48,29 @@ type LocalAgents struct { // URL. Children are launched from the current aiscan executable. func NewLocalAgents(hubURL, ioaToken string, pool *AgentPool) *LocalAgents { return &LocalAgents{ - webURL: hubURL, - ioaURL: nodeIOAURL(hubURL, ioaToken), - pool: pool, + webURL: hubURL, + webAuthURL: webURLWithToken(hubURL, ioaToken), + ioaURL: nodeIOAURL(hubURL, ioaToken), + pool: pool, } } +// webURLWithToken embeds the access token as userinfo on the hub's loopback web +// URL (http://@host), so a launched agent can authenticate its +// /api/agent/ws pool connection — the hub gates /api/* behind that key. An empty +// token or unparseable hubURL yields hubURL unchanged. +func webURLWithToken(hubURL, token string) string { + if hubURL == "" || token == "" { + return hubURL + } + u, err := url.Parse(strings.TrimRight(hubURL, "/")) + if err != nil { + return hubURL + } + u.User = url.User(token) + return u.String() +} + // nodeIOAURL embeds the access token as userinfo and points at the /ioa path, // yielding http://@host:port/ioa. An empty or unparseable hubURL yields "". func nodeIOAURL(hubURL, token string) string { @@ -91,7 +109,7 @@ func (l *LocalAgents) Launch(ctx context.Context) (*LocalAgentView, error) { l.mu.Unlock() cmd := exec.Command(bin, "agent", - "--web-url", l.webURL, + "--web-url", l.webAuthURL, "--server-url", l.ioaURL, "--space", "default", "--node-name", name, diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index 9b3e787d..a102692e 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net/http" "net/url" "os" "os/user" @@ -124,8 +125,18 @@ func runConnectionOnce(ctx context.Context, serverURL, name string, reg *command if reg == nil { return fmt.Errorf("command registry is nil") } - wsURL := httpToWS(serverURL) + "/api/agent/ws" - conn, wsResp, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) + // The hub gates /api/* behind an access key (see pkg/web/auth.go). The key + // rides in the serverURL userinfo (http://@host, set by the local-agent + // launcher). gorilla/websocket ignores URL userinfo, so lift the key out and + // present it as a Bearer token, dialing a userinfo-free URL. With no key the + // header stays nil and behaviour is unchanged (auth-disabled hub / tests). + dialURL, accessKey := splitAccessKey(serverURL) + wsURL := httpToWS(dialURL) + "/api/agent/ws" + var reqHeader http.Header + if accessKey != "" { + reqHeader = http.Header{"Authorization": {"Bearer " + accessKey}} + } + conn, wsResp, err := websocket.DefaultDialer.DialContext(ctx, wsURL, reqHeader) if wsResp != nil && wsResp.Body != nil { wsResp.Body.Close() } @@ -1133,6 +1144,19 @@ func remoteIOAConfig(option *cfg.Option) *cfg.IOAConfig { } } +// splitAccessKey lifts the access token out of a URL's userinfo +// (http://@host…), returning a userinfo-free URL plus the token. A URL +// without userinfo (or an unparseable one) comes back unchanged with an empty token. +func splitAccessKey(rawURL string) (dialURL, token string) { + u, err := url.Parse(rawURL) + if err != nil || u.User == nil { + return rawURL, "" + } + token = u.User.Username() + u.User = nil + return u.String(), token +} + func httpToWS(rawURL string) string { u, err := url.Parse(strings.TrimRight(rawURL, "/")) if err != nil { From 9b4aac8e5c0b243fc6de179097abff43450f78f5 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Sat, 11 Jul 2026 03:42:26 -0700 Subject: [PATCH 007/348] =?UTF-8?q?fix(scan):=20=E4=BF=9D=E7=95=99=20http/?= =?UTF-8?q?https=20=E5=90=8C=E6=BA=90=E4=B8=BA=E7=8B=AC=E7=AB=8B=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=B9=B6=E9=87=87=E9=9B=86=20content=5Ftype/redirect?= =?UTF-8?q?=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除按 host(无 scheme)聚合的 canonical key——它会把同一主机的 http 与 https 源、以及裸端口误并成一个资产;现各自独立。addWebProbe 额外记录 content_type 与 redirect_url,供前端呈现内容类型/重定向。新增 aggregate_test 覆盖。 Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tools/scan/aggregate.go | 27 ++++++++++--------- pkg/tools/scan/aggregate_test.go | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 pkg/tools/scan/aggregate_test.go diff --git a/pkg/tools/scan/aggregate.go b/pkg/tools/scan/aggregate.go index 28a8f33a..7c1a3e30 100644 --- a/pkg/tools/scan/aggregate.go +++ b/pkg/tools/scan/aggregate.go @@ -9,8 +9,8 @@ import ( "strings" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/utils/parsers" sdktypes "github.com/chainreactors/sdk/pkg/types" + "github.com/chainreactors/utils/parsers" ) var firstURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) @@ -60,6 +60,18 @@ func AggregateStructuredResult(result *output.Result) []output.Asset { for _, err := range result.Errors { builder.addError(err) } + // Older stored results may already contain AI notes/responses. Rebuilding the + // service/path buckets should not discard those supplemental items. + for _, asset := range result.Assets { + for _, item := range asset.Items { + switch item.Kind { + case output.AssetItemService, output.AssetItemPath, output.AssetItemFingerprint, output.AssetItemLoot, output.AssetItemError: + continue + } + target := output.FirstNonEmpty(item.Target, asset.Target, "Scan") + builder.addItem(target, targetKeys(asset.Key, asset.Target, item.Target), itemIdentity(item), item) + } + } return builder.assets() } @@ -121,6 +133,8 @@ func (b *assetBuilder) addWebProbe(probe *sdktypes.SprayResult) { "status", probe.Status, "length", probe.BodyLength, "title", probe.Title, + "content_type", probe.ContentType, + "redirect_url", probe.RedirectURL, "fingers", fingerNames, "validated", isSprayValidated(sourceName), ) @@ -358,9 +372,6 @@ func addTargetKeys(keys map[string]struct{}, value string) { if origin := urlOrigin(withoutHost); origin != "" { addCanonicalKey(keys, origin) } - if host := urlHost(withoutHost); host != "" { - addCanonicalKey(keys, host) - } if normalized := normalizedURL(withoutHost); normalized != "" { addCanonicalKey(keys, normalized) } @@ -405,14 +416,6 @@ func urlOrigin(value string) string { return strings.ToLower(parsed.Scheme + "://" + stripDefaultPort(parsed)) } -func urlHost(value string) string { - parsed, err := url.Parse(strings.TrimSpace(value)) - if err != nil || parsed.Host == "" { - return "" - } - return strings.ToLower(stripDefaultPort(parsed)) -} - func stripDefaultPort(u *url.URL) string { host := u.Hostname() port := u.Port() diff --git a/pkg/tools/scan/aggregate_test.go b/pkg/tools/scan/aggregate_test.go new file mode 100644 index 00000000..8bc86ee2 --- /dev/null +++ b/pkg/tools/scan/aggregate_test.go @@ -0,0 +1,45 @@ +package scan + +import ( + "testing" + + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/utils/parsers" +) + +func TestAggregateStructuredResultKeepsHTTPOriginsSeparate(t *testing.T) { + result := &output.Result{ + Services: []*parsers.GOGOResult{ + {Ip: "111.63.65.103", Port: "80", Protocol: "http"}, + {Ip: "111.63.65.103", Port: "443", Protocol: "https"}, + {Ip: "111.63.65.103", Port: "icmp", Protocol: "icmp"}, + }, + WebProbes: []*parsers.SprayResult{ + {UrlString: "http://111.63.65.103/admin", Status: 200, Source: parsers.CheckSource}, + {UrlString: "https://111.63.65.103/login", Status: 301, Source: parsers.CheckSource}, + }, + } + + assets := AggregateStructuredResult(result) + if len(assets) != 3 { + t.Fatalf("got %d assets, want separate http, https, and icmp services: %#v", len(assets), assets) + } + for _, asset := range assets { + services, paths := 0, 0 + for _, item := range asset.Items { + switch item.Kind { + case output.AssetItemService: + services++ + case output.AssetItemPath: + paths++ + } + } + wantPaths := 1 + if asset.Target == "111.63.65.103:icmp" { + wantPaths = 0 + } + if services != 1 || paths != wantPaths { + t.Fatalf("asset %q has %d services and %d paths, want 1 service and %d paths", asset.Target, services, paths, wantPaths) + } + } +} From 002bea6f79bbf4677992b93cf911278a0fc2e4fc Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Sat, 11 Jul 2026 03:46:47 -0700 Subject: [PATCH 008/348] =?UTF-8?q?feat(web):=20=E4=BE=A6=E5=AF=9F?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E9=87=8D=E5=86=99=E5=B9=B6=E6=8C=89=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=B8=B2=E6=9F=93,scan=5Fcomplete=20=E8=90=BD?= =?UTF-8?q?=E5=BA=93=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 报告改为运营者口吻:一段像人写的概述 + 资产明细,不再是指标表格,也不泄漏内部扫描器名(gogo_portscan/check);裸存活主机(icmp/裸端口)折叠进"其他存活主机"列表。GetReport 增加 lang 参数,从结构化结果按请求语言(zh/en)重渲染,扫描期只冻结一份兜底。 另 scan_complete 落库一条只含 scan_id 的轻量 system marker(重 Result 仍经 session_scans 反查),修复刷新/切会话/SSE 重连从消息重建时间线时内联扫描卡消失;空 scan_id 不落库。 Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/web/handler.go | 2 +- pkg/web/service.go | 324 ++++++++++++++++++++++++++++++++-------- pkg/web/service_test.go | 157 ++++++++++++++++++- pkg/web/sse_test.go | 39 +++++ 4 files changed, 459 insertions(+), 63 deletions(-) diff --git a/pkg/web/handler.go b/pkg/web/handler.go index f68c1473..03ccd776 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -239,7 +239,7 @@ func (h *handlerImpl) scanEvents(w http.ResponseWriter, r *http.Request) { } func (h *handlerImpl) scanReport(w http.ResponseWriter, r *http.Request) { - report, err := h.service.GetReport(r.Context(), r.PathValue("id")) + report, err := h.service.GetReport(r.Context(), r.PathValue("id"), r.URL.Query().Get("lang")) if err != nil { writeError(w, http.StatusNotFound, "scan not found") return diff --git a/pkg/web/service.go b/pkg/web/service.go index a3cad0c0..0119805e 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -18,6 +18,7 @@ import ( "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/runner" + scantool "github.com/chainreactors/aiscan/pkg/tools/scan" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -213,11 +214,29 @@ func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, s } func (s *Service) GetScan(ctx context.Context, id string) (*ScanJob, error) { - return s.store.Get(ctx, id) + job, err := s.store.Get(ctx, id) + if err != nil { + return nil, err + } + refreshStructuredAssets(job) + return job, nil } func (s *Service) ListScans(ctx context.Context) ([]*ScanJob, error) { - return s.store.List(ctx, 100) + jobs, err := s.store.List(ctx, 100) + if err != nil { + return nil, err + } + for _, job := range jobs { + refreshStructuredAssets(job) + } + return jobs, nil +} + +func refreshStructuredAssets(job *ScanJob) { + if job != nil && job.Result != nil && (len(job.Result.Services) > 0 || len(job.Result.WebProbes) > 0) { + job.Result.Assets = scantool.AggregateStructuredResult(job.Result) + } } func (s *Service) CancelScan(id string) error { @@ -240,11 +259,18 @@ func (s *Service) CancelScan(id string) error { return nil } -func (s *Service) GetReport(ctx context.Context, id string) (string, error) { - job, err := s.store.Get(ctx, id) +// GetReport re-renders the report in the requested language from the stored +// structured result, so a zh user gets a zh report even though the scan ran +// once. It falls back to the report frozen at scan time when the structured +// result is no longer around. +func (s *Service) GetReport(ctx context.Context, id, lang string) (string, error) { + job, err := s.GetScan(ctx, id) if err != nil { return "", err } + if job.Result != nil { + return buildMarkdownReport(job.Target, job.Mode, job.Result, lang), nil + } return job.Report, nil } @@ -358,7 +384,7 @@ func (s *Service) persistResultRecords(scanID, agentID string, result *output.Re func (s *Service) completeJob(ctx context.Context, job *ScanJob, agentID string, result *output.Result) { job.Status = StatusCompleted - job.Report = buildMarkdownReport(job.Target, job.Mode, result) + job.Report = buildMarkdownReport(job.Target, job.Mode, result, defaultReportLang) job.Result = result job.UpdatedAt = time.Now() _ = s.store.Update(ctx, job) @@ -499,62 +525,237 @@ func (w *sseStreamWriter) Write(p []byte) (int, error) { return len(p), nil } -func buildMarkdownReport(target, mode string, result *output.Result) string { +// defaultReportLang is the language the report is frozen in at scan time; the +// stored copy is only a fallback — GetReport re-renders per request language. +const defaultReportLang = "zh" + +// reportLang narrows a UI locale down to the two languages the report speaks. +func reportLang(lang string) string { + if strings.HasPrefix(strings.ToLower(lang), "zh") { + return "zh" + } + return "en" +} + +// tr picks the zh or en variant for the (already normalised) report language. +func tr(lang, zh, en string) string { + if lang == "zh" { + return zh + } + return en +} + +func reportModeName(lang, mode string) string { + if strings.EqualFold(mode, "full") { + return tr(lang, "全面侦察", "Full recon") + } + return tr(lang, "快速侦察", "Quick recon") +} + +// buildMarkdownReport renders a scan result as an operator-facing recon report. +// It reads like something a human wrote — a prose overview instead of a raw +// metric dump, no internal scanner names (gogo_portscan / check) leaking into +// the prose, and bare live hosts (an icmp echo, say) folded into a trailing +// list rather than each claiming a full section. +func buildMarkdownReport(target, mode string, result *output.Result, lang string) string { + lang = reportLang(lang) var sb strings.Builder - sb.WriteString("# Penetration Test Report\n\n") - sb.WriteString(fmt.Sprintf("**Target:** `%s` \n", target)) - sb.WriteString(fmt.Sprintf("**Mode:** %s \n", mode)) - sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", time.Now().Format("2006-01-02 15:04:05"))) + + heading := output.FirstNonEmpty(target, tr(lang, "目标", "target")) + fmt.Fprintf(&sb, "# %s%s\n\n", tr(lang, "侦察报告 · ", "Recon report · "), heading) + fmt.Fprintf(&sb, "%s `%s` · %s · %s\n\n", + tr(lang, "目标", "Target"), target, + reportModeName(lang, mode), + time.Now().Format("2006-01-02 15:04:05")) sb.WriteString("---\n\n") if result == nil { - sb.WriteString("No structured result was returned.\n") + sb.WriteString(tr(lang, "本次扫描未返回结构化结果。\n", "No structured result was returned.\n")) return sb.String() } - sb.WriteString("## Summary\n\n") - sb.WriteString("| Metric | Value |\n|---|---:|\n") - sb.WriteString(fmt.Sprintf("| Targets | %d |\n", result.Summary.Targets)) - sb.WriteString(fmt.Sprintf("| Services | %d |\n", result.Summary.Services)) - sb.WriteString(fmt.Sprintf("| Web | %d |\n", result.Summary.Webs)) - sb.WriteString(fmt.Sprintf("| Probes | %d |\n", result.Summary.Probes)) - sb.WriteString(fmt.Sprintf("| Fingerprints | %d |\n", resultFingerprintCount(result))) - sb.WriteString(fmt.Sprintf("| Loots | %d |\n", result.Summary.Loots)) - sb.WriteString(fmt.Sprintf("| Errors | %d |\n", result.Summary.Errors)) - if result.Summary.Duration != "" { - sb.WriteString(fmt.Sprintf("| Duration | %s |\n", result.Summary.Duration)) + sb.WriteString("## " + tr(lang, "概述", "Overview") + "\n\n") + sb.WriteString(reportOverview(lang, result)) + sb.WriteString("\n\n") + + rich, bare := splitReportAssets(result.Assets) + if len(rich) > 0 { + sb.WriteString("## " + tr(lang, "资产明细", "Assets") + "\n\n") + for _, asset := range rich { + writeAssetReport(&sb, lang, asset) + } + } + if len(bare) > 0 { + sb.WriteString("## " + tr(lang, "其他存活主机", "Other live hosts") + "\n\n") + for _, asset := range bare { + writeBareAsset(&sb, asset) + } + sb.WriteString("\n") } - sb.WriteString("\n") - if len(result.Assets) == 0 { - return sb.String() + return sb.String() +} + +// reportOverview is the executive summary — one flowing paragraph that names +// only the numbers that are actually present, so a clean scan reads like a +// sentence rather than a table full of zeros. +func reportOverview(lang string, result *output.Result) string { + s := result.Summary + hosts := reportHostCount(result.Assets) + fingers := resultFingerprintCount(result) + var b strings.Builder + + if lang == "zh" { + fmt.Fprintf(&b, "本次侦察共识别 %d 台主机、%d 个开放服务", hosts, s.Services) + if s.Webs > 0 { + fmt.Fprintf(&b, "(含 %d 个 Web 站点)", s.Webs) + } + b.WriteString("。") + if s.Probes > 0 { + fmt.Fprintf(&b, "累计探测 %d 条路径", s.Probes) + if fingers > 0 { + fmt.Fprintf(&b, "、命中 %d 项 Web 指纹", fingers) + } + b.WriteString("。") + } else if fingers > 0 { + fmt.Fprintf(&b, "命中 %d 项 Web 指纹。", fingers) + } + if s.Loots > 0 { + fmt.Fprintf(&b, "**发现 %d 项需优先复核的安全发现(凭证 / 弱口令 / 漏洞)。**", s.Loots) + } + if s.Errors > 0 { + fmt.Fprintf(&b, "另有 %d 处探测报错。", s.Errors) + } + if s.Duration != "" { + fmt.Fprintf(&b, "全程耗时 %s。", s.Duration) + } + return b.String() } - sb.WriteString("## Assets\n\n") - for _, asset := range result.Assets { - title := output.FirstNonEmpty(asset.Title, asset.Target, asset.Key, "Asset") - sb.WriteString(fmt.Sprintf("### %s\n\n", title)) - if asset.Target != "" && asset.Target != title { - sb.WriteString(fmt.Sprintf("- **Target:** %s\n", markdownCode(asset.Target))) + fmt.Fprintf(&b, "The pass identified %s across %s", plural(hosts, "host", "hosts"), plural(s.Services, "open service", "open services")) + if s.Webs > 0 { + fmt.Fprintf(&b, " (%s)", plural(s.Webs, "web site", "web sites")) + } + b.WriteString(". ") + if s.Probes > 0 { + fmt.Fprintf(&b, "It probed %s", plural(s.Probes, "path", "paths")) + if fingers > 0 { + fmt.Fprintf(&b, " and matched %s", plural(fingers, "fingerprint", "fingerprints")) + } + b.WriteString(". ") + } else if fingers > 0 { + fmt.Fprintf(&b, "It matched %s. ", plural(fingers, "fingerprint", "fingerprints")) + } + if s.Loots > 0 { + fmt.Fprintf(&b, "**%s surfaced (credentials / weak passwords / vulnerabilities) — review these first.** ", plural(s.Loots, "security finding", "security findings")) + } + if s.Errors > 0 { + fmt.Fprintf(&b, "%s occurred during probing. ", plural(s.Errors, "error", "errors")) + } + if s.Duration != "" { + fmt.Fprintf(&b, "The scan took %s.", s.Duration) + } + return strings.TrimSpace(b.String()) +} + +func plural(n int, one, many string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, one) + } + return fmt.Sprintf("%d %s", n, many) +} + +// reportHostCount collapses assets down to distinct hosts, so an IP that has +// both an icmp echo and a web service counts once, not twice. +func reportHostCount(assets []output.Asset) int { + seen := make(map[string]struct{}) + for _, a := range assets { + if h := assetHost(a); h != "" { + seen[h] = struct{}{} } - if asset.Status != "" { - sb.WriteString(fmt.Sprintf("- **State:** %s\n", markdownCode(asset.Status))) + } + if len(seen) == 0 { + return len(assets) + } + return len(seen) +} + +func assetHost(a output.Asset) string { + v := output.FirstNonEmpty(a.Target, a.Key, a.Title) + if i := strings.Index(v, "://"); i >= 0 { + v = v[i+3:] + } + if i := strings.IndexAny(v, "/?#"); i >= 0 { + v = v[:i] + } + if strings.Count(v, ":") == 1 { // host:port — drop the port, leave IPv6 alone + v = v[:strings.LastIndex(v, ":")] + } + return v +} + +func splitReportAssets(assets []output.Asset) (rich, bare []output.Asset) { + for _, a := range assets { + if assetIsBare(a) { + bare = append(bare, a) + } else { + rich = append(rich, a) } - writeMarkdownList(&sb, "Services", assetServiceFacts(asset.Items)) - writeMarkdownList(&sb, "HTTP", assetHTTPStatuses(asset.Items)) - writeMarkdownList(&sb, "Fingers", assetFingers(asset.Items)) - writeMarkdownList(&sb, "Sources", assetSources(asset.Items)) - if paths := assetPathCount(asset.Items); paths > 0 { - sb.WriteString(fmt.Sprintf("- **Paths:** %d\n", paths)) + } + return rich, bare +} + +// assetIsBare is true for a live host that only answered with non-web services +// (an icmp echo, a bare tcp port) — nothing worth its own section. +func assetIsBare(a output.Asset) bool { + hasService := false + for _, item := range a.Items { + if item.Kind != output.AssetItemService { + return false + } + hasService = true + svc := strings.ToLower(output.AssetDataString(item.Data, "service") + " " + output.AssetDataString(item.Data, "protocol")) + if strings.Contains(svc, "http") { + return false } - writeAssetLootMarkdown(&sb, asset.Items) - sb.WriteString("\n") } + return hasService +} - return sb.String() +func writeAssetReport(sb *strings.Builder, lang string, asset output.Asset) { + title := output.FirstNonEmpty(asset.Title, asset.Target, asset.Key, tr(lang, "资产", "Asset")) + if asset.Target != "" && asset.Target != title { + fmt.Fprintf(sb, "### %s — `%s`\n\n", title, asset.Target) + } else { + fmt.Fprintf(sb, "### %s\n\n", title) + } + + writeReportFact(sb, lang, tr(lang, "开放服务", "Services"), assetServiceFacts(asset.Items)) + writeReportFact(sb, lang, tr(lang, "HTTP 响应", "HTTP"), assetHTTPStatuses(asset.Items)) + writeReportFact(sb, lang, tr(lang, "Web 指纹", "Fingerprints"), assetFingers(asset.Items)) + if paths := assetPathCount(asset.Items); paths > 0 { + fmt.Fprintf(sb, "- %s%s%s\n", tr(lang, "已探测路径", "Paths"), labelSep(lang), tr(lang, fmt.Sprintf("%d 条", paths), fmt.Sprintf("%d", paths))) + } + if asset.Status != "" { + fmt.Fprintf(sb, "- %s%s%s\n", tr(lang, "状态", "State"), labelSep(lang), markdownCode(asset.Status)) + } + sb.WriteString("\n") + + writeAssetLootMarkdown(sb, lang, asset.Items) +} + +func writeBareAsset(sb *strings.Builder, asset output.Asset) { + host := output.FirstNonEmpty(asset.Target, asset.Title, asset.Key) + if services := assetServiceFacts(asset.Items); len(services) > 0 { + fmt.Fprintf(sb, "- `%s` · %s\n", host, strings.Join(services, ", ")) + } else { + fmt.Fprintf(sb, "- `%s`\n", host) + } } -func writeMarkdownList(sb *strings.Builder, label string, values []string) { +func labelSep(lang string) string { return tr(lang, ":", ": ") } + +func writeReportFact(sb *strings.Builder, lang, label string, values []string) { if len(values) == 0 { return } @@ -562,10 +763,10 @@ func writeMarkdownList(sb *strings.Builder, label string, values []string) { for _, value := range values { coded = append(coded, markdownCode(value)) } - sb.WriteString(fmt.Sprintf("- **%s:** %s\n", label, strings.Join(coded, ", "))) + fmt.Fprintf(sb, "- %s%s%s\n", label, labelSep(lang), strings.Join(coded, tr(lang, "、", ", "))) } -func writeAssetLootMarkdown(sb *strings.Builder, items []output.AssetItem) { +func writeAssetLootMarkdown(sb *strings.Builder, lang string, items []output.AssetItem) { wrote := false for _, item := range items { switch item.Kind { @@ -575,19 +776,14 @@ func writeAssetLootMarkdown(sb *strings.Builder, items []output.AssetItem) { if summary == "" && detail == "" { continue } - prefix := output.FirstNonEmpty(item.Source, item.Kind) - if item.Status != "" { - prefix += ":" + item.Status - } if !wrote { - sb.WriteString("\n#### Analysis\n\n") + sb.WriteString("#### " + tr(lang, "分析研判", "Analysis") + "\n\n") wrote = true } if summary == "" { summary = firstMarkdownLine(detail) } - sb.WriteString(fmt.Sprintf("##### %s\n\n", markdownHeading(summary))) - sb.WriteString(fmt.Sprintf("**Source:** %s\n\n", markdownCode(prefix))) + fmt.Fprintf(sb, "##### %s\n\n", markdownHeading(summary)) if detail != "" && !sameMarkdownText(summary, detail) { writeMarkdownBlock(sb, detail) } else if detail == "" && summary != "" { @@ -660,14 +856,6 @@ func assetFingers(items []output.AssetItem) []string { return output.CompactStrings(values...) } -func assetSources(items []output.AssetItem) []string { - var values []string - for _, item := range items { - values = append(values, item.Source) - } - return output.CompactStrings(values...) -} - func assetPathCount(items []output.AssetItem) int { count := 0 for _, item := range items { @@ -711,7 +899,6 @@ func generateID() string { return hex.EncodeToString(b) } - func lastOutputLine(s string) string { lines := strings.Split(s, "\n") for i := len(lines) - 1; i >= 0; i-- { @@ -994,6 +1181,21 @@ func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) { metadata["eval_pass"] = event.EvalPass metadata["eval_reason"] = event.EvalReason + case ChatEventScanComplete: + // Persist a lightweight marker so the inline scan card survives a reload / + // session switch. The heavy Result payload is NOT stored here — it stays + // reloadable via the session_scans link (getScan), and the client fills the + // card from its scanResults map keyed by this scan_id. Without this marker + // the scan is invisible to any timeline rebuilt from messages (a page + // reload, an SSE reconnect, or a session switch that revalidates against + // the store), even though the result itself is still fetchable. + if event.ScanID == "" { + return + } + msg.Role = "system" + msg.Content = "scan complete" + metadata["scan_id"] = event.ScanID + default: return } diff --git a/pkg/web/service_test.go b/pkg/web/service_test.go index 393092ed..26087f4f 100644 --- a/pkg/web/service_test.go +++ b/pkg/web/service_test.go @@ -1,11 +1,15 @@ package web import ( + "context" + "path/filepath" "reflect" "strings" "testing" + "time" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/utils/parsers" ) func TestScanRequestAnalysisOptions(t *testing.T) { @@ -43,6 +47,97 @@ func TestServiceStatusReportsLLMAvailability(t *testing.T) { } } +func TestGetScanRebuildsLegacyMergedAssets(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) + if err != nil { + t.Fatalf("NewSQLiteStore() error = %v", err) + } + defer store.Close() + + now := time.Now() + job := &ScanJob{ + ID: "legacy-merged-services", + Target: "111.63.65.103", + Mode: "quick", + Status: StatusCompleted, + CreatedAt: now, + UpdatedAt: now, + Result: &output.Result{ + Services: []*parsers.GOGOResult{ + {Ip: "111.63.65.103", Port: "80", Protocol: "http"}, + {Ip: "111.63.65.103", Port: "443", Protocol: "https"}, + {Ip: "111.63.65.103", Port: "icmp", Protocol: "icmp"}, + }, + WebProbes: []*parsers.SprayResult{ + {UrlString: "http://111.63.65.103/", Status: 200, Source: parsers.CheckSource}, + {UrlString: "https://111.63.65.103/", Status: 301, Source: parsers.CheckSource}, + }, + Assets: []output.Asset{{ + Target: "https://111.63.65.103", + Items: []output.AssetItem{{ + Kind: output.AssetItemResponse, Target: "https://111.63.65.103", Summary: "saved analysis", + }}, + }}, + }, + } + if err := store.Create(context.Background(), job); err != nil { + t.Fatalf("Create() error = %v", err) + } + + got, err := NewService(ServiceConfig{Store: store}).GetScan(context.Background(), job.ID) + if err != nil { + t.Fatalf("GetScan() error = %v", err) + } + if len(got.Result.Assets) != 3 { + t.Fatalf("assets = %d, want 3 separated services: %#v", len(got.Result.Assets), got.Result.Assets) + } + foundAnalysis := false + for _, asset := range got.Result.Assets { + for _, item := range asset.Items { + foundAnalysis = foundAnalysis || item.Kind == output.AssetItemResponse && item.Summary == "saved analysis" + } + } + if !foundAnalysis { + t.Fatal("supplemental analysis item was dropped during legacy asset rebuild") + } +} + +func TestGetScanRebuildsLegacyWebOnlyAssets(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) + if err != nil { + t.Fatalf("NewSQLiteStore() error = %v", err) + } + defer store.Close() + + now := time.Now() + job := &ScanJob{ + ID: "legacy-web-only", + Target: "111.63.65.103", + Mode: "quick", + Status: StatusCompleted, + CreatedAt: now, + UpdatedAt: now, + Result: &output.Result{ + WebProbes: []*parsers.SprayResult{ + {UrlString: "http://111.63.65.103/", Status: 200, Source: parsers.CheckSource}, + {UrlString: "https://111.63.65.103/", Status: 301, Source: parsers.CheckSource}, + }, + Assets: []output.Asset{{Target: "http://111.63.65.103"}}, + }, + } + if err := store.Create(context.Background(), job); err != nil { + t.Fatalf("Create() error = %v", err) + } + + got, err := NewService(ServiceConfig{Store: store}).GetScan(context.Background(), job.ID) + if err != nil { + t.Fatalf("GetScan() error = %v", err) + } + if len(got.Result.Assets) != 2 { + t.Fatalf("assets = %d, want separate http and https web origins: %#v", len(got.Result.Assets), got.Result.Assets) + } +} + func TestBuildMarkdownReportKeepsAssetDetailAsMarkdown(t *testing.T) { report := buildMarkdownReport("http://127.0.0.1:8092", "quick", &output.Result{ Summary: output.Summary{Targets: 1}, @@ -60,7 +155,7 @@ func TestBuildMarkdownReportKeepsAssetDetailAsMarkdown(t *testing.T) { }, }, }, - }) + }, "en") for _, want := range []string{"## Evidence Analysis", "| Asset | Details |"} { if !strings.Contains(report, want) { @@ -68,3 +163,63 @@ func TestBuildMarkdownReportKeepsAssetDetailAsMarkdown(t *testing.T) { } } } + +func TestBuildMarkdownReportLocalizedAndDeNoised(t *testing.T) { + result := &output.Result{ + Summary: output.Summary{Targets: 1, Services: 3, Webs: 2, Probes: 2, Duration: "22.266s"}, + Assets: []output.Asset{ + { + Target: "http://111.63.65.103:80", + Title: "BWS/1.1", + Items: []output.AssetItem{ + {Kind: output.AssetItemService, Source: "gogo_portscan", Data: map[string]any{"service": "http", "port": "80"}}, + {Kind: output.AssetItemPath, Status: "301"}, + {Kind: output.AssetItemPath, Status: "200"}, + }, + }, + { + // Bare live host — only an icmp echo. Must fold into the trailing + // list, not claim its own ### section, and must not inflate the host count. + Target: "111.63.65.103:icmp", + Key: "111.63.65.103:icmp", + Items: []output.AssetItem{ + {Kind: output.AssetItemService, Source: "gogo_portscan", Data: map[string]any{"service": "icmp"}}, + }, + }, + }, + } + + zh := buildMarkdownReport("baidu.com", "quick", result, "zh") + for _, want := range []string{"# 侦察报告", "## 概述", "快速侦察", "1 台主机", "其他存活主机"} { + if !strings.Contains(zh, want) { + t.Fatalf("zh report missing %q:\n%s", want, zh) + } + } + // The "去 AI 味" contract: no internal scanner names leak, no generic English boilerplate title. + if strings.Contains(zh, "gogo_portscan") { + t.Errorf("zh report leaks scanner source name:\n%s", zh) + } + if strings.Contains(zh, "战利品") { + t.Errorf("zh report leaks internal loot terminology:\n%s", zh) + } + if strings.Contains(zh, "Penetration Test Report") || strings.Contains(zh, "| Metric | Value |") { + t.Errorf("zh report still uses the old boilerplate:\n%s", zh) + } + // icmp is folded, so it must not appear as its own heading. + if strings.Contains(zh, "### 111.63.65.103:icmp") { + t.Errorf("bare icmp host got its own section:\n%s", zh) + } + + en := buildMarkdownReport("baidu.com", "quick", result, "en") + for _, want := range []string{"## Overview", "Quick recon", "1 host", "Other live hosts"} { + if !strings.Contains(en, want) { + t.Fatalf("en report missing %q:\n%s", want, en) + } + } + if strings.Contains(en, "gogo_portscan") { + t.Errorf("en report leaks scanner source name:\n%s", en) + } + if strings.Contains(strings.ToLower(en), "loot") { + t.Errorf("en report leaks internal loot terminology:\n%s", en) + } +} diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index 04aecd4c..66149d97 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -187,6 +187,45 @@ func TestEvalEventPersistsVerdictMetadata(t *testing.T) { } } +func TestScanCompletePersistsMarkerMetadata(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + svc := NewService(ServiceConfig{Store: store}) + + // A completed scan must leave a durable marker so its inline card survives a + // timeline rebuild (reload / session switch). The heavy Result is intentionally + // not stored — only the scan_id, which the client re-hydrates via scan_ids. + svc.BroadcastChatEvent("sess-scan", ChatEvent{ + Type: ChatEventScanComplete, + ScanID: "scan-123", + }) + + msgs, err := store.ListMessages(context.Background(), "sess-scan", 100) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 1 { + t.Fatalf("persisted messages = %d, want 1", len(msgs)) + } + var metadata map[string]any + if err := json.Unmarshal(msgs[0].Metadata, &metadata); err != nil { + t.Fatalf("metadata json: %v", err) + } + if metadata["event_type"] != ChatEventScanComplete || metadata["scan_id"] != "scan-123" { + t.Fatalf("scan marker metadata = %#v", metadata) + } + + // A marker with no scan id is meaningless — it must not create a phantom row. + svc.BroadcastChatEvent("sess-scan-empty", ChatEvent{Type: ChatEventScanComplete}) + empty, _ := store.ListMessages(context.Background(), "sess-scan-empty", 100) + if len(empty) != 0 { + t.Fatalf("empty-scanID persisted messages = %d, want 0", len(empty)) + } +} + func drainEventTypes(ch <-chan HubEvent) []string { var out []string for len(ch) > 0 { From 03711e3f09e539d2f976fcb95032ca70f5ceb509 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Sat, 11 Jul 2026 03:46:47 -0700 Subject: [PATCH 009/348] =?UTF-8?q?feat(web):=20=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=86=85=E8=81=94=E8=87=AA=E8=B6=B3,?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=AF=A6=E6=83=85=E6=8A=BD=E5=B1=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScanSummaryCard 重写为内嵌 资产/发现/报告 三 Tab(报告按语言拉取),对话内即可完整查看扫描结果,删除右侧 DetailPanel 抽屉及 detailScanID/showScanDetail 全套接线;时间线渲染器在只有持久化 marker(无内联 Result)时回退到 scanResults map。 scan-result 数据模型去掉 assets/loots/sources 冗余、新增 contentToken/redirectTarget、修非数字端口排序;AssetResultView 呈现内容类型/重定向;文案去"战利品"(→发现项)。App/ChatPanel 一并带入手机端会话外壳(问候能力卡 + 顶栏抽屉)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- web/frontend/src/App.tsx | 100 +++---- .../src/components/AssetResultView.tsx | 276 +++++++++++------- web/frontend/src/components/ChatPanel.tsx | 111 +++++-- web/frontend/src/components/DetailPanel.tsx | 106 ------- .../src/components/chat/ScanSummaryCard.tsx | 162 ++++++---- web/frontend/src/hooks/useChatSession.ts | 34 ++- web/frontend/src/i18n/locales/en/agent.ts | 2 +- web/frontend/src/i18n/locales/en/app.ts | 2 + web/frontend/src/i18n/locales/en/chat.ts | 18 +- web/frontend/src/i18n/locales/en/findings.ts | 11 +- web/frontend/src/i18n/locales/en/scan.ts | 6 - web/frontend/src/i18n/locales/zh/agent.ts | 2 +- web/frontend/src/i18n/locales/zh/app.ts | 2 + web/frontend/src/i18n/locales/zh/chat.ts | 18 +- web/frontend/src/i18n/locales/zh/findings.ts | 9 +- web/frontend/src/i18n/locales/zh/scan.ts | 6 - web/frontend/src/lib/chat-extensions.tsx | 15 +- web/frontend/src/lib/scan-result.ts | 46 +-- 18 files changed, 512 insertions(+), 414 deletions(-) delete mode 100644 web/frontend/src/components/DetailPanel.tsx diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index ec738527..800e6f94 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -1,10 +1,9 @@ import { useState, useEffect, useCallback, lazy, Suspense, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { Monitor, Settings } from 'lucide-react' +import { Menu, Monitor, Settings } from 'lucide-react' import LanguageToggle from './components/LanguageToggle' import SessionList from './components/SessionList' import ChatPanel from './components/ChatPanel' -import DetailPanel from './components/DetailPanel' import ConfigPanel from './components/ConfigPanel' import AgentPanel from './components/AgentPanel' import LLMHealth from './components/LLMHealth' @@ -58,7 +57,6 @@ export default function App() { const [agentPanelOpen, setAgentPanelOpen] = useState(false) const [agentPanelFocusID, setAgentPanelFocusID] = useState(null) const [sidebarOpen, setSidebarOpen] = useState(getInitialSidebarOpen) - const [detailOpen, setDetailOpen] = useState(true) // Bumped after a settings save so the header LLM health dot re-probes. const [healthNonce, setHealthNonce] = useState(0) // Track the terminal target by the node's STABLE key, not its transient agent @@ -66,14 +64,6 @@ export default function App() { // the terminal (and never restore it) when a node bounces to reload config. const [terminalNodeKey, setTerminalNodeKey] = useState(null) - // Stable so ChatPanel's memoized TimelineEntry rows aren't re-rendered on every - // streamed token. chat.showScanDetail is itself a stable useCallback. Scan - // detail here is an agent-run scan surfaced inside the conversation. - const handleShowScanDetail = useCallback((scanID: string) => { - chat.showScanDetail(scanID) - setDetailOpen(true) - }, [chat.showScanDetail]) - const refreshStatus = useCallback(async () => { try { setServerStatus(await getStatus()) @@ -93,8 +83,6 @@ export default function App() { window.localStorage.setItem(sidebarStorageKey, String(sidebarOpen)) }, [sidebarOpen]) - const detailResult = chat.detailScanID ? chat.scanResults.get(chat.detailScanID) ?? null : null - const showDetail = detailOpen && !!chat.detailScanID && !!detailResult const terminalAgent = terminalNodeKey ? chat.agents.find((a) => agentNodeKey(a) === terminalNodeKey) ?? null : null const model = serverStatus?.llm_model || chat.agents.find((a) => a.identity?.model)?.identity?.model || 'cortex' @@ -104,20 +92,32 @@ export default function App() { // can't be dispatched until it reconnects — surface that in the chat panel. const activeAgentOffline = !!activeSession && !isSessionAgentOnline(activeSession, chat.agents) + // On phones the sidebar is an overlay drawer (see SessionList); entering a + // conversation or terminal should dismiss it so the content isn't left covered. + // No-op at md+ where the sidebar is a docked rail that shares the row. + function closeSidebarOnMobile() { + if (typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches) { + setSidebarOpen(false) + } + } + function handleOpenTerminal(agentID: string) { const a = chat.agents.find((x) => x.id === agentID) setTerminalNodeKey(a ? agentNodeKey(a) : agentID) chat.selectAgent(agentID) + closeSidebarOnMobile() } function handleSelectSession(id: string) { setTerminalNodeKey(null) chat.selectSession(id) + closeSidebarOnMobile() } function handleCreateSession(agentID: string) { setTerminalNodeKey(null) chat.createSession(agentID) + closeSidebarOnMobile() } // Deleting a session also tears down its live subscription, so confirm first — @@ -142,12 +142,23 @@ export default function App() { return ( -
-
-
+
+
+
+ {/* Phone-only drawer opener — the collapsed sidebar is hidden below md, + so the session history opens from here (Doubao-style). */} + - AIScan - {model} + AIScan + {model} setConfigOpen(true)} reloadSignal={healthNonce} />
@@ -186,43 +197,22 @@ export default function App() {
) : ( - <> - - - {/* Agent-run scans surface a result card in the transcript; clicking - it opens this detail drawer. */} -
- {showDetail && ( - setDetailOpen(false)} - /> - )} -
- + )}
diff --git a/web/frontend/src/components/AssetResultView.tsx b/web/frontend/src/components/AssetResultView.tsx index ab131e58..dfb56c78 100644 --- a/web/frontend/src/components/AssetResultView.tsx +++ b/web/frontend/src/components/AssetResultView.tsx @@ -1,15 +1,16 @@ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { AlertCircle, Brain, CheckCircle2, ChevronRight, Crosshair, File, Fingerprint, Folder, FolderOpen, Globe, Link2, Network, Radar, Server } from 'lucide-react' +import { AlertCircle, Brain, CheckCircle2, ChevronRight, CornerDownRight, Crosshair, File, FileCode2, Fingerprint, Folder, FolderOpen, Globe, Link2, Network, Radar, Server } from 'lucide-react' import type { AssetItem, ScanResult } from '../api' import { assetItemContent, buildResultModel, buildSitemapTree, collectSitemapFolderIDs, + contentToken, defaultOpenSitemapNodes, endpointFileName, - formatCount, + findingTargetURL, itemFactValues, itemFacts, itemKindTone, @@ -18,6 +19,7 @@ import { isAnalysisItem, pathIdentity, pathSearch, + redirectTarget, sameTarget, serviceAIStatus, statusCodeTone, @@ -31,12 +33,12 @@ import { import { cn } from '@aspect/theme' import { MarkdownContent } from '@/markdown' import { badgeToneClass } from '../lib/tones' -import { Badge as UIBadge, Button, Card, CardContent, CardHeader, Chip, EmptyState, StatTile, Tooltip, TooltipContent, TooltipTrigger } from '@aspect/ui' +import { Badge as UIBadge, Button, EmptyState, Tooltip, TooltipContent, TooltipTrigger } from '@aspect/ui' import { AiPanel } from '@/components/AiPanel' -import FindingsSummary from './FindingsSummary' interface AssetResultViewProps { result: ScanResult + anchorPrefix?: string } type AssetPanel = { @@ -47,31 +49,15 @@ type AssetPanel = { render: () => ReactNode } -export default function AssetResultView({ result }: AssetResultViewProps) { +export default function AssetResultView({ result, anchorPrefix = '' }: AssetResultViewProps) { const { t } = useTranslation('findings') const model = useMemo(() => buildResultModel(result), [result]) return ( -
- -
- - - - - - - - - -
-
- - - +
{model.hosts.length > 0 ? ( - + ) : ( )} @@ -80,21 +66,20 @@ export default function AssetResultView({ result }: AssetResultViewProps) { ) } -function HostList({ hosts }: { hosts: HostGroup[] }) { +function HostList({ hosts, anchorPrefix }: { hosts: HostGroup[]; anchorPrefix: string }) { return (
{hosts.map((host) => ( - + ))}
) } -function HostPanel({ host }: { host: HostGroup }) { +function HostPanel({ host, anchorPrefix }: { host: HostGroup; anchorPrefix: string }) { const { t } = useTranslation('findings') const [open, setOpen] = useState(true) - const webCount = host.services.filter((service) => service.web).length - const anchor = assetAnchor('host', host.id) + const anchor = assetAnchor(anchorPrefix, 'host', host.id) return (
{host.host} - {formatCount(host.services.length, 'service')} - {webCount > 0 && {t('webCount', { count: webCount })}}
- +
) } -function ServiceList({ services }: { services: ServiceNode[] }) { +function ServiceList({ services, anchorPrefix }: { services: ServiceNode[]; anchorPrefix: string }) { return (
{services.map((service) => ( - + ))}
) } -function ServiceRow({ service }: { service: ServiceNode }) { +function ServiceRow({ service, anchorPrefix }: { service: ServiceNode; anchorPrefix: string }) { const { t } = useTranslation('findings') - const panels = useMemo(() => servicePanels(service), [service]) - const [open, setOpen] = useState(false) + const panels = useMemo(() => servicePanels(service, anchorPrefix), [service, anchorPrefix]) + const [open, setOpen] = useState(true) const [activePanelID, setActivePanelID] = useState(() => defaultPanelID(panels)) const activePanel = panels.find((panel) => panel.id === activePanelID) || panels[0] - const anchor = assetAnchor('service', service.id) + const showPanelTabs = panels.length > 1 + const anchor = assetAnchor(anchorPrefix, 'service', service.id) useEffect(() => { if (!panels.some((panel) => panel.id === activePanelID)) { @@ -157,7 +141,7 @@ function ServiceRow({ service }: { service: ServiceNode }) { if (panels.length === 0) { return (
- +
) } @@ -170,8 +154,11 @@ function ServiceRow({ service }: { service: ServiceNode }) { onToggle={(event) => setOpen(event.currentTarget.open)} > - -
+ +
+ + {showPanelTabs && ( +
{panels.map((panel) => ( ))}
- + )} {activePanel && ( -
+
+ {!showPanelTabs && ( +
+ {t(activePanel.labelKey)} + {typeof activePanel.count === 'number' && activePanel.count > 0 && ( + {activePanel.count} + )} +
+ )} {activePanel.render()}
)} @@ -193,29 +188,40 @@ function ServiceRow({ service }: { service: ServiceNode }) { ) } -function ServiceLine({ service, expandable = false }: { service: ServiceNode; expandable?: boolean }) { +function ServiceLine({ + service, + anchorPrefix, + expandable = false, +}: { + service: ServiceNode + anchorPrefix: string + expandable?: boolean +}) { const { t } = useTranslation('findings') const displayTarget = service.web ? service.asset.target : service.target const aiStatus = serviceAIStatus(service) + const port = service.port && service.port.toLowerCase() !== (service.service || service.protocol).toLowerCase() + ? service.port + : '—' return ( -
+
{expandable ? ( ) : ( )} - - {service.port || '-'} + + {port}
{service.service || service.protocol || 'service'} - + {service.protocol && service.protocol !== service.service && {service.protocol}} - {service.web && {service.pathCount > 0 ? t('webCount', { count: service.pathCount }) : t('web')}} + {service.web && service.pathCount === 0 && {t('web')}} {aiStatus === 'verified' && ( {t('aiVerified')} )} @@ -232,20 +238,16 @@ function ServiceLine({ service, expandable = false }: { service: ServiceNode; ex
{displayTarget && {displayTarget}} {service.summary && {service.summary}} - {service.statuses.slice(0, 5).map((status) => ( + {service.pathCount === 0 && service.statuses.slice(0, 5).map((status) => ( {status} ))} {service.states.slice(0, 3).map((state) => ( {state} ))} - {service.analysisItems.length > 0 && ( - {t('analysisCount', { count: service.analysisItems.length })} - )}
-
) } @@ -260,7 +262,7 @@ function ServiceIcon({ service }: { service: ServiceNode }) { return } -function servicePanels(service: ServiceNode): AssetPanel[] { +function servicePanels(service: ServiceNode, anchorPrefix: string): AssetPanel[] { const panels: AssetPanel[] = [] if (service.paths.length > 0) { panels.push({ @@ -276,7 +278,7 @@ function servicePanels(service: ServiceNode): AssetPanel[] { id: 'analysis', labelKey: 'analysis', count: service.analysisItems.length, - render: () => , + render: () => , }) } return panels @@ -288,7 +290,7 @@ function defaultPanelID(panels: AssetPanel[]) { function ItemFactLine({ item, search, className }: { item: AssetItem; search?: string; className?: string }) { const facts = itemFacts(item) - if (facts.statuses.length === 0 && facts.states.length === 0 && facts.fingers.length === 0 && facts.sources.length === 0 && !search) { + if (facts.statuses.length === 0 && facts.states.length === 0 && facts.fingers.length === 0 && !search) { return null } return ( @@ -300,28 +302,27 @@ function ItemFactLine({ item, search, className }: { item: AssetItem; search?: s {state} ))} - {search && {search}}
) } -function AssetItemsBlock({ asset, items }: { asset: ViewAsset; items: AssetItem[] }) { +function AssetItemsBlock({ asset, items, anchorPrefix }: { asset: ViewAsset; items: AssetItem[]; anchorPrefix: string }) { return (
{items.map((item, idx) => ( - + ))}
) } -function AssetItemRow({ item, asset }: { item: AssetItem; asset: ViewAsset }) { +function AssetItemRow({ item, asset, anchorPrefix }: { item: AssetItem; asset: ViewAsset; anchorPrefix: string }) { const { t } = useTranslation('findings') const markdown = isAnalysisItem(item) const title = markdown ? firstText(item.summary, item.title) : itemTitle(item) const detail = itemContent(item) - const anchor = assetAnchor('item', itemAnchorValue(item, asset)) + const anchor = assetAnchor(anchorPrefix, 'item', itemAnchorValue(item, asset)) const showTarget = item.target && !sameTarget(item.target, asset.target) const headerBadges = [ { id: `kind:${item.kind}`, label: item.kind, tone: itemKindTone(item.kind) }, @@ -422,8 +423,8 @@ function AnchorLink({ id, label }: { id: string; label: string }) { ) } -function assetAnchor(prefix: string, value: string) { - return `asset-${prefix}-${anchorSlug(value)}` +function assetAnchor(namespace: string, prefix: string, value: string) { + return ['asset', namespace && anchorSlug(namespace), prefix, anchorSlug(value)].filter(Boolean).join('-') } function itemAnchorValue(item: AssetItem, asset: ViewAsset) { @@ -494,9 +495,9 @@ function SitemapBlock({ items }: { items: AssetItem[] }) { } return ( -
+
{folderIDs.length > 0 && ( -
+
setOpenIDs(new Set(folderIDs))}> @@ -505,7 +506,7 @@ function SitemapBlock({ items }: { items: AssetItem[] }) {
)} -
+
{tree.map((node) => ( 0 const isOpen = openIDs.has(node.id) - const paddingLeft = `${0.6 + depth * 1.15}rem` + const paddingLeft = `${0.6 + Math.min(depth, 4) * 1.15}rem` const count = node.children.length + node.items.length if (isFolder) { return ( -
+
{isOpen && ( -
+
{node.items.map((item, idx) => ( ))} @@ -587,47 +589,91 @@ function SitemapTreeNode({ } function EndpointFile({ item, depth }: { item: AssetItem; depth: number }) { - const paddingLeft = `${0.6 + depth * 1.15}rem` - const filename = endpointFileName(item) + const { t } = useTranslation('findings') + const paddingLeft = `${0.6 + Math.min(depth, 4) * 1.15}rem` + // endpointFileName folds the query onto the basename; strip it so the search + // string can render dimmed on its own, matching the deck's sitemap tree. + const filename = endpointFileName(item).split('?')[0] || '/' const search = pathSearch(item) + const status = pathStatus(item) + const token = contentToken(item) + const redirect = redirectTarget(item) + const url = findingTargetURL(item.target) || findingTargetURL(endpointDataURL(item)) + const isJS = token === 'JS' - return ( -
-
+ const body = ( + <> + {isJS ? ( + + ) : ( - {filename} - {item.title && {item.title}} -
- -
+ )} + + {filename} + {search && {search}} + + {item.title && {item.title}} + {redirect && ( + + + {t('redirectsTo', { url: redirect })} + + )} + {token && ( + + {token} + + )} + {status && ( + + {status} + + )} + ) -} -function SourceChips({ sources, className }: { sources: string[]; className?: string }) { - const { t } = useTranslation('findings') - if (sources.length === 0) { - return null + const rowClass = 'flex items-center gap-2 py-1.5 pr-3 text-xs hover:bg-secondary/30' + if (url) { + return ( + + {body} + + ) } - - const visible = sources.slice(0, 5) - const hidden = sources.length - visible.length - return ( - - - - - {visible.map((source) => ( - {source} - ))} - {hidden > 0 && +{hidden}} - - - {t('sources')} - +
+ {body} +
) } +function pathStatus(item: AssetItem): string { + if (item.status) return item.status + const raw = item.data?.status + if (typeof raw === 'string') return raw + if (typeof raw === 'number' && raw > 0) return String(raw) + return '' +} + +function endpointDataURL(item: AssetItem): string | undefined { + const raw = item.data?.url + return typeof raw === 'string' ? raw : undefined +} + function FingerChips({ fingers }: { fingers: string[] }) { const { t } = useTranslation('findings') if (fingers.length === 0) { @@ -667,11 +713,11 @@ function IconButton({ @@ -693,19 +739,29 @@ function TabChip({ onClick: (event: MouseEvent) => void }) { return ( - + ) } function Section({ title, children }: { title: string; children: ReactNode }) { return ( - - {title} - {children} - +
+

{title}

+
{children}
+
) } diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx index 696a21bf..79aa7495 100644 --- a/web/frontend/src/components/ChatPanel.tsx +++ b/web/frontend/src/components/ChatPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import i18n from '../i18n' import { @@ -6,8 +6,12 @@ import { CheckCircle2, FileText, GitBranch, + Layers, Loader2, MessageSquare, + Network, + Radar, + RefreshCw, Sparkles, Target, User, @@ -90,8 +94,6 @@ interface Props { onSend: (content: string, opts?: { persist?: boolean; evalCriteria?: string; evalMaxRounds?: number }) => void onPause: () => void onClearError: () => void - onShowScanDetail: (scanID: string) => void - detailOpen: boolean } export default function ChatPanel({ @@ -109,8 +111,6 @@ export default function ChatPanel({ onSend, onPause, onClearError, - onShowScanDetail, - detailOpen, }: Props) { const { t, i18n } = useTranslation('chat') const scrollRef = useRef(null) @@ -121,11 +121,10 @@ export default function ChatPanel({ const hasAssistantResponse = timeline.some((item) => item.kind === 'assistant_response') // The right rail carries IOA thread notes, which only a fraction of turns emit. // Reserve its 6rem column only when the transcript actually has one — otherwise - // the empty rail just steals horizontal space from the conversation. (When the - // scan-detail drawer is open the rail is already suppressed.) + // the empty rail just steals horizontal space from the conversation. const hasThreadNotes = useMemo( - () => !detailOpen && timeline.some((item) => describeIOAThreadItem(item, t) !== null), - [timeline, detailOpen, t], + () => timeline.some((item) => describeIOAThreadItem(item, t) !== null), + [timeline, t], ) const inputFormClass = cn(contentOffsetClass, hasThreadNotes && threadOffsetClass) const [persist, setPersist] = useState(false) @@ -143,6 +142,20 @@ export default function ChatPanel({ const [liveStatus, setLiveStatus] = useState('') const wasActiveRef = useRef(false) + // Composer seed — the mobile greeting's capability cards push a starter prompt + // into the composer through ChatInput's injectText (nonce-guarded append). Own + // the nonce here so each card tap reliably re-injects; still fold in an external + // injectText if one ever arrives (the asset-pool source is gone, so it's inert). + const [composerSeed, setComposerSeed] = useState<{ text: string; nonce: number }>( + () => injectText ?? { text: '', nonce: 0 }, + ) + useEffect(() => { + if (injectText && injectText.nonce > 0) setComposerSeed(injectText) + }, [injectText]) + const seedComposer = useCallback((text: string) => { + setComposerSeed((s) => ({ text, nonce: s.nonce + 1 })) + }, []) + function sendOpts() { if (!persist) return undefined const criteria = evalCriteria.trim() @@ -309,7 +322,7 @@ export default function ChatPanel({
{!hasActiveSession && timeline.length === 0 && ( @@ -323,13 +336,21 @@ export default function ChatPanel({ )} {hasActiveSession && timeline.length === 0 && !isThinking && (
- {t('readyHintBefore')}/scan <target>{t('readyHintAfter')} - } - /> + {/* Desktop keeps the idle "instrument" empty state; phones get the + Doubao-style greeting + capability cards (mobile-only, so the + deck's identity is untouched at md+). */} +
+ {t('readyHintBefore')}/scan <target>{t('readyHintAfter')} + } + /> +
+
+ +
)} @@ -339,7 +360,6 @@ export default function ChatPanel({ item={item} scanResults={scanResults} hasThreadNotes={hasThreadNotes} - onShowScanDetail={onShowScanDetail} /> ))} @@ -361,7 +381,7 @@ export default function ChatPanel({
{hasActiveSession && ( -
+
{agentOffline && (
@@ -420,7 +440,7 @@ export default function ChatPanel({ variant="ghost" active={persist} onClick={() => setPersist((v) => !v)} - className={cn('h-10 shrink-0 gap-1.5 rounded-full px-3.5 text-xs', !persist && 'text-muted-foreground')} + className={cn('h-9 shrink-0 gap-1.5 rounded-full px-3 text-xs md:h-10 md:px-3.5', !persist && 'text-muted-foreground')} > {t('persistMode')} @@ -434,7 +454,7 @@ export default function ChatPanel({ busy={isBusy} commands={chatCommands} mentionables={mentionables} - injectText={injectText} + injectText={composerSeed} placeholder={t('typeMessageWithCommands')} enableAttachments={!!activeSessionID} /> @@ -452,20 +472,18 @@ export default function ChatPanel({ // changes. Without memo, timeline.map re-renders EVERY settled entry each token // — and each MessageBubble re-parses its markdown (remark) from scratch, so a // 40-message transcript re-parses 40 docs per token. A shallow prop compare lets -// unchanged entries bail out; it holds only because `onShowScanDetail` is now a -// stable useCallback in App and `scanResults`/`detailOpen` don't change mid-stream. +// unchanged entries bail out because settled item references and the scan-results +// map stay stable while an unrelated response streams. const TimelineEntry = memo(function TimelineEntry({ item, scanResults, hasThreadNotes, - onShowScanDetail, }: { item: TimelineItem scanResults: Map hasThreadNotes: boolean - onShowScanDetail: (scanID: string) => void }) { - const content = timelineContent(item, scanResults, onShowScanDetail) + const content = timelineContent(item, scanResults) if (!content) return null return ( @@ -506,7 +524,6 @@ function TimelineRow({ function timelineContent( item: TimelineItem, scanResults: Map, - onShowScanDetail: (scanID: string) => void, ): ReactNode { switch (item.kind) { case 'message': @@ -547,12 +564,13 @@ function timelineContent( case 'scan_started': case 'scan_progress': case 'scan_complete': { + if (item.kind !== 'scan_complete' && item.scanID && scanResults.has(item.scanID)) return null const ext = toExtensionItem(item) as ExtensionTimelineItem | null if (!ext) return null const config = resolveTimelineRenderer(ext.extensionType) if (!config) return null const Renderer = config.renderer - return + return } case 'thinking': @@ -712,6 +730,43 @@ function EmptyState({ eyebrow, title, subtitle }: { eyebrow: string; title: stri return } +// Phone-only greeting for a fresh, empty session: an AIScan hello + a 2×2 grid of +// capability cards, each seeding the composer with a starter prompt (Doubao's +// home pattern). Kept in AIScan's own skin — blue accent, warm reserved for +// severity, no mascot. The scan card seeds the real "/scan " command; the others +// seed editable natural-language templates the operator completes. +function MobileChatGreeting({ onSeed }: { onSeed: (text: string) => void }) { + const { t } = useTranslation('chat') + const cards: { key: string; Icon: typeof Radar; seed?: string; seedKey?: string; titleKey: string; subKey: string }[] = [ + { key: 'scan', Icon: Radar, seed: '/scan ', titleKey: 'cardScanTitle', subKey: 'cardScanSub' }, + { key: 'verify', Icon: RefreshCw, seedKey: 'cardVerifySeed', titleKey: 'cardVerifyTitle', subKey: 'cardVerifySub' }, + { key: 'assets', Icon: Layers, seedKey: 'cardAssetsSeed', titleKey: 'cardAssetsTitle', subKey: 'cardAssetsSub' }, + { key: 'swarm', Icon: Network, seedKey: 'cardSwarmSeed', titleKey: 'cardSwarmTitle', subKey: 'cardSwarmSub' }, + ] + return ( +
+

{t('mobileGreetingTitle')}

+

{t('mobileGreetingSubtitle')}

+
+ {cards.map(({ key, Icon, seed, seedKey, titleKey, subKey }) => ( + + ))} +
+
+ ) +} + interface TimelineDescriptor { label: string time: string diff --git a/web/frontend/src/components/DetailPanel.tsx b/web/frontend/src/components/DetailPanel.tsx deleted file mode 100644 index 0f83429c..00000000 --- a/web/frontend/src/components/DetailPanel.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { X, FileText, Shield, TableProperties } from 'lucide-react' -import { Tabs, TabsList, TabsTrigger, TabsContent, Button, Badge, EmptyState } from '@aspect/ui' -import { fetchScanReport, type ScanResult } from '../api' -import { buildFindings } from '../lib/scan-result' -import { MarkdownContent } from '@/markdown' -import AssetResultView from './AssetResultView' -import FindingsPanel from './FindingsPanel' - -interface Props { - scanID: string - result: ScanResult | null - report?: string - onClose: () => void -} - -type Tab = 'assets' | 'findings' | 'report' - -export default function DetailPanel({ scanID, result, report, onClose }: Props) { - const { t, i18n } = useTranslation('findings') - const findingsCount = useMemo(() => { - if (!result) return 0 - return buildFindings(result).length - }, [result]) - - const [tab, setTab] = useState(findingsCount > 0 ? 'findings' : 'assets') - // The findings tab is unmounted when a scan has no findings. This panel is - // reused (no key remount) when retargeted at another scan, so a stale - // tab==='findings' against a 0-findings scan would leave the controlled Tabs - // with no matching content and render blank — fall back to 'assets'. - const activeTab: Tab = tab === 'findings' && findingsCount === 0 ? 'assets' : tab - - // The report is (re)rendered server-side per language, so fetch it on open and - // whenever the UI locale flips — that gives zh users the zh report instead of - // the stored en one. The `report` prop, if ever passed, seeds the initial - // value; a 404 (report not ready) resolves to '' and shows the placeholder. - const [reportMd, setReportMd] = useState(report ?? '') - useEffect(() => { - let cancelled = false - const lang = (i18n.resolvedLanguage || i18n.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en' - fetchScanReport(scanID, lang) - .then((md) => { if (!cancelled) setReportMd(md) }) - .catch(() => { /* keep placeholder */ }) - return () => { cancelled = true } - }, [scanID, i18n.resolvedLanguage, i18n.language]) - - return ( - - ) -} diff --git a/web/frontend/src/components/chat/ScanSummaryCard.tsx b/web/frontend/src/components/chat/ScanSummaryCard.tsx index 4c5d48c2..a7cdb2bc 100644 --- a/web/frontend/src/components/chat/ScanSummaryCard.tsx +++ b/web/frontend/src/components/chat/ScanSummaryCard.tsx @@ -1,70 +1,132 @@ -import { ArrowRight, Shield, Server, Bug, FileText } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { CheckCircle2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { ScanResult } from '../../api' -import { cn } from '@aspect/theme' -import { Button } from '@aspect/ui' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@aspect/ui' +import { fetchScanReport, type ScanResult } from '../../api' +import { buildFindings } from '../../lib/scan-result' +import { MarkdownContent } from '@/markdown' +import AssetResultView from '../AssetResultView' +import FindingsPanel from '../FindingsPanel' interface Props { scanID: string result: ScanResult - onViewDetails: (scanID: string) => void } -export default function ScanSummaryCard({ scanID, result, onViewDetails }: Props) { +type ReportState = { + key: string + status: 'idle' | 'loading' | 'loaded' + content: string +} + +export default function ScanSummaryCard({ scanID, result }: Props) { const { t } = useTranslation('scan') + const { t: tf, i18n } = useTranslation('findings') + const findingsCount = useMemo(() => buildFindings(result).length, [result]) + const [tab, setTab] = useState('assets') + const [report, setReport] = useState({ key: '', status: 'idle', content: '' }) const s = result.summary + const lang = (i18n.resolvedLanguage || i18n.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en' + const reportKey = `${scanID}:${lang}` + + const selectTab = (value: string) => { + setTab(value) + if (value !== 'report') return + setReport((current) => ( + current.key === reportKey && current.status === 'loaded' && current.content + ? current + : { key: reportKey, status: 'loading', content: '' } + )) + } + + useEffect(() => { + if (tab === 'report' && report.key !== reportKey) { + setReport({ key: reportKey, status: 'loading', content: '' }) + } + }, [tab, report.key, reportKey]) + + useEffect(() => { + if (report.key !== reportKey || report.status !== 'loading') return + let cancelled = false + fetchScanReport(scanID, lang) + .then((content) => { + if (!cancelled) setReport({ key: reportKey, status: 'loaded', content }) + }) + .catch(() => { + if (!cancelled) setReport({ key: reportKey, status: 'loaded', content: '' }) + }) + return () => { cancelled = true } + }, [report.key, report.status, reportKey, scanID, lang]) + + const reportLoading = report.key !== reportKey || report.status !== 'loaded' return ( -
-
- - {t('scanComplete')} +
+
+
+
+
+
+ {tf('detailSummary', { targets: s.targets, services: s.services })} + {s.webs > 0 && } + {s.probes > 0 && } + {s.errors > 0 && } +
+
{s.duration && ( - {s.duration} + {s.duration} )} -
-
- } label={t('assetsLabel')} value={s.targets} /> - } label={t('servicesLabel')} value={s.services} /> - } label={t('lootsLabel')} value={s.loots} tone={s.loots > 0 ? 'warn' : 'muted'} /> - {s.errors > 0 && } label={t('errorsLabel')} value={s.errors} tone="error" />} -
- -
+
+ + + + {tf('assets')} + {findingsCount > 0 && ( + + {tf('findings')} + {findingsCount} + + )} + {tf('report')} + + + + + + {findingsCount > 0 && ( + + + + )} + + {reportLoading ? ( +
{tf('loadingReport')}
+ ) : ( +
+ +
+ )} +
+
+ + ) +} + +function MetaDivider({ label, tone = 'muted' }: { label: string; tone?: 'muted' | 'error' }) { + return ( + {label} ) } -function Metric({ - icon, - label, - value, - tone = 'muted', -}: { - icon: React.ReactNode - label: string - value: number - tone?: 'muted' | 'warn' | 'error' -}) { +function ResultTab({ value, children }: { value: string; children: React.ReactNode }) { return ( -
- {icon} - {label} - {value} -
+ {children} + ) } diff --git a/web/frontend/src/hooks/useChatSession.ts b/web/frontend/src/hooks/useChatSession.ts index d2948ba0..d96b57b5 100644 --- a/web/frontend/src/hooks/useChatSession.ts +++ b/web/frontend/src/hooks/useChatSession.ts @@ -105,7 +105,6 @@ interface SessionSnapshot { messages: ChatMessage[] timeline: TimelineItem[] scanResults: Map - detailScanID: string | null } // A node's stable identity across reconnects. The hub mints a fresh transient @@ -149,7 +148,6 @@ export function useChatSession() { const [timeline, setTimeline] = useState([]) const timelineRef = useRef([]) const [scanResults, setScanResults] = useState>(() => new Map()) - const [detailScanID, setDetailScanID] = useState(null) const [isThinking, setIsThinking] = useState(false) const [pendingResponse, setPendingResponse] = useState(false) const [error, setError] = useState('') @@ -185,8 +183,8 @@ export function useChatSession() { // never file the incoming session's state under the outgoing session's key. useEffect(() => { if (!activeSessionID) return - sessionCacheRef.current.set(activeSessionID, { messages, timeline, scanResults, detailScanID }) - }, [activeSessionID, messages, timeline, scanResults, detailScanID]) + sessionCacheRef.current.set(activeSessionID, { messages, timeline, scanResults }) + }, [activeSessionID, messages, timeline, scanResults]) const refreshAgents = useCallback(async () => { try { @@ -255,7 +253,6 @@ export function useChatSession() { timelineRef.current = [] setTimeline([]) setScanResults(new Map()) - setDetailScanID(null) resetTransientState() } @@ -267,7 +264,6 @@ export function useChatSession() { timelineRef.current = snap.timeline setTimeline(snap.timeline) setScanResults(snap.scanResults) - setDetailScanID(snap.detailScanID) resetTransientState() } @@ -582,7 +578,6 @@ export function useChatSession() { next.set(event.scan_id!, event.result!) return next }) - setDetailScanID((current) => current || event.scan_id!) appendTimeline({ id: `scanres-${event.scan_id}`, kind: 'scan_complete', @@ -783,6 +778,23 @@ export function useChatSession() { continue } + if (eventType === 'scan_complete') { + beginNextEvalRound() + const scanID = metadataString(msg.metadata, 'scan_id') + if (!scanID) continue + // The heavy Result isn't persisted in the message — the card pulls it from + // the scanResults map (loaded from the session's scan_ids on activation), + // so this item only needs to carry the scan_id. Same id as the live append + // so a rebuild that races the live event upserts instead of duplicating. + built.push({ + id: `scanres-${scanID}`, + kind: 'scan_complete', + timestamp, + scanID, + }) + continue + } + if (eventType === 'eval') { beginNextEvalRound() const pass = metadataBool(msg.metadata, 'eval_pass') @@ -913,7 +925,7 @@ export function useChatSession() { ) // A session switch during scan loading bumps activationRef; discard // these stale results instead of writing them into the new session's - // scanResults map (which would also mis-point detailScanID). + // scanResults map. if (activation !== activationRef.current) return const withResult = loaded.filter((e) => e.result) if (withResult.length) { @@ -922,7 +934,6 @@ export function useChatSession() { for (const e of withResult) next.set(e.scanID, e.result!) return next }) - setDetailScanID((current) => current || withResult[0].scanID) } } } catch {} @@ -1155,9 +1166,6 @@ export function useChatSession() { } }, []) - // Stable identities so memoized consumers (ChatPanel's TimelineEntry) aren't - // busted every render by a fresh closure. setState setters are already stable. - const showScanDetail = useCallback((scanID: string) => setDetailScanID(scanID), []) const clearError = useCallback(() => setError(''), []) return { @@ -1167,7 +1175,6 @@ export function useChatSession() { activeSessionID, timeline, scanResults, - detailScanID, isThinking, busy: pendingResponse || isThinking || timeline.some((item) => ( item.kind === 'assistant_response' && item.assistantResponse?.streaming @@ -1188,7 +1195,6 @@ export function useChatSession() { startReportSession, batchQuickDispatch, cancelMessage: handleCancelMessage, - showScanDetail, clearError, } } diff --git a/web/frontend/src/i18n/locales/en/agent.ts b/web/frontend/src/i18n/locales/en/agent.ts index a9fd2a8b..548fd124 100644 --- a/web/frontend/src/i18n/locales/en/agent.ts +++ b/web/frontend/src/i18n/locales/en/agent.ts @@ -63,6 +63,6 @@ export default { tdTools: 'Tools', tdTokens: 'Tokens', tdAssets: 'Assets', - tdLoots: 'Loots', + tdLoots: 'Findings', tdLast: 'Last', } diff --git a/web/frontend/src/i18n/locales/en/app.ts b/web/frontend/src/i18n/locales/en/app.ts index 93b925fa..ce8ff6ba 100644 --- a/web/frontend/src/i18n/locales/en/app.ts +++ b/web/frontend/src/i18n/locales/en/app.ts @@ -34,4 +34,6 @@ export default { llmChecking: 'Checking…', llmUnreachable: 'LLM unreachable', llmHealthSettings: 'Click to open settings', + // Mobile session drawer (opened from the header menu button) + openSessions: 'Chat history', } diff --git a/web/frontend/src/i18n/locales/en/chat.ts b/web/frontend/src/i18n/locales/en/chat.ts index fdb1d994..572d043c 100644 --- a/web/frontend/src/i18n/locales/en/chat.ts +++ b/web/frontend/src/i18n/locales/en/chat.ts @@ -11,6 +11,20 @@ export default { ready: 'Ready', readyHintBefore: 'Type a message or use ', readyHintAfter: ' to start scanning', + // Mobile greeting empty state + capability cards (mobile only; desktop keeps InstrumentIdle) + mobileGreetingTitle: "Hi, I'm AIScan", + mobileGreetingSubtitle: 'Name a target, or pick one below', + cardScanTitle: 'Run a scan', + cardScanSub: 'Ports + web fingerprint', + cardVerifyTitle: 'Verify a finding', + cardVerifySub: 'Re-check known issues', + cardVerifySeed: 'Re-verify this finding: ', + cardAssetsTitle: 'Asset roundup', + cardAssetsSub: 'Roll into inventory', + cardAssetsSeed: 'Roll the latest scan results into an asset inventory', + cardSwarmTitle: 'Fleet sweep', + cardSwarmSub: 'Parallel agents', + cardSwarmSeed: 'Dispatch multiple agents to reproduce these findings in parallel: ', you: 'You', system: 'System', scan: 'Scan', @@ -50,8 +64,8 @@ export default { agentOfflineBanner: 'The bound agent is offline — reconnect it to continue chatting (/help and /agents still work).', deleteSessionConfirm: 'Delete this session? Its transcript is removed and the live connection is closed. This cannot be undone.', // Screen-reader-only turn status (polite live region). - a11yThinking: 'Cortex is working', - a11yResponding: 'Cortex is responding', + a11yThinking: 'AIScan is working', + a11yResponding: 'AIScan is responding', a11yTurnDone: 'Response complete', // Backend system messages (localized by code; English here is also the fallback). // Mirrors the Sys* codes in pkg/web. diff --git a/web/frontend/src/i18n/locales/en/findings.ts b/web/frontend/src/i18n/locales/en/findings.ts index b6469266..5b5916e5 100644 --- a/web/frontend/src/i18n/locales/en/findings.ts +++ b/web/frontend/src/i18n/locales/en/findings.ts @@ -28,27 +28,26 @@ export default { severity_medium: 'Medium', severity_low: 'Low', severity_info: 'Info', - scanDetails: 'Scan Details', - closeDetailPanel: 'Close detail panel', + detailSummary: '{{targets}} targets · {{services}} services', assets: 'Assets', findings: 'Findings', report: 'Report', + loadingReport: 'Loading report…', noReportAvailable: 'No report available.', noResultsAvailable: 'No results available', hosts: 'Hosts', services: 'Services', web: 'Web', probes: 'Probes', - fingers: 'Fingers', + fingers: 'Fingerprints', fingerprints: 'Fingerprints', sources: 'Sources', - loots: 'Loots', errors: 'Errors', duration: 'Duration', noHosts: 'No hosts.', linkTo: 'Link to {{name}}', - sitemap: 'Sitemap', - webCount: '{{count}} web', + redirectsTo: 'Redirects to {{url}}', + sitemap: 'Directory tree', expandAll: 'Expand all', collapseAll: 'Collapse all', } diff --git a/web/frontend/src/i18n/locales/en/scan.ts b/web/frontend/src/i18n/locales/en/scan.ts index 421bf744..a814d5e6 100644 --- a/web/frontend/src/i18n/locales/en/scan.ts +++ b/web/frontend/src/i18n/locales/en/scan.ts @@ -25,7 +25,6 @@ export default { // ScanHistory noScansYet: 'No scans yet.', assets: 'assets', - loots: 'loots', // ScanWorkspace hideDetails: 'Hide details', @@ -43,11 +42,6 @@ export default { // chat/ScanSummaryCard scanComplete: 'Scan Complete', - assetsLabel: 'Assets', - servicesLabel: 'Services', - lootsLabel: 'Loots', - errorsLabel: 'Errors', - viewDetails: 'View Details', // analysis option badges (ScanForm / ScanView / ScanHistory) optVerify: 'Verify', diff --git a/web/frontend/src/i18n/locales/zh/agent.ts b/web/frontend/src/i18n/locales/zh/agent.ts index aa71be8f..d1984f48 100644 --- a/web/frontend/src/i18n/locales/zh/agent.ts +++ b/web/frontend/src/i18n/locales/zh/agent.ts @@ -63,6 +63,6 @@ export default { tdTools: '工具', tdTokens: 'Token', tdAssets: '资产', - tdLoots: '战利品', + tdLoots: '发现项', tdLast: '最近事件', } diff --git a/web/frontend/src/i18n/locales/zh/app.ts b/web/frontend/src/i18n/locales/zh/app.ts index 2a3605fd..4bfa463c 100644 --- a/web/frontend/src/i18n/locales/zh/app.ts +++ b/web/frontend/src/i18n/locales/zh/app.ts @@ -34,4 +34,6 @@ export default { llmChecking: '检测中…', llmUnreachable: 'LLM 不可达', llmHealthSettings: '点击打开设置', + // 手机端会话抽屉(顶栏汉堡打开) + openSessions: '对话历史', } diff --git a/web/frontend/src/i18n/locales/zh/chat.ts b/web/frontend/src/i18n/locales/zh/chat.ts index b00e6c32..bd6c4b74 100644 --- a/web/frontend/src/i18n/locales/zh/chat.ts +++ b/web/frontend/src/i18n/locales/zh/chat.ts @@ -11,6 +11,20 @@ export default { ready: '就绪', readyHintBefore: '输入消息,或使用 ', readyHintAfter: ' 开始扫描', + // 手机端问候空状态 + 能力卡(仅移动端;桌面仍用 InstrumentIdle) + mobileGreetingTitle: '嗨,我是 AIScan', + mobileGreetingSubtitle: '说个目标,或从下面挑一个开始', + cardScanTitle: '发起扫描', + cardScanSub: '端口 + Web 指纹', + cardVerifyTitle: '复测漏洞', + cardVerifySub: '重新验证发现', + cardVerifySeed: '复测一下这个发现:', + cardAssetsTitle: '资产梳理', + cardAssetsSub: '并成资产清单', + cardAssetsSeed: '把本次扫描结果整理成一张资产清单', + cardSwarmTitle: '机群协同', + cardSwarmSub: '多 Agent 并行', + cardSwarmSeed: '派多个 Agent 并行复现这些发现:', you: '你', system: '系统', scan: '扫描', @@ -50,8 +64,8 @@ export default { agentOfflineBanner: '绑定的 agent 已离线,重连后可继续对话(/help、/agents 命令仍可用)。', deleteSessionConfirm: '确定删除该会话?其对话记录将被移除、实时连接会断开,此操作不可撤销。', // 仅供屏幕阅读器的回合状态(polite live region)。 - a11yThinking: 'Cortex 正在处理', - a11yResponding: 'Cortex 正在回复', + a11yThinking: 'AIScan 正在处理', + a11yResponding: 'AIScan 正在回复', a11yTurnDone: '回复完成', // 后端系统消息(按 code 本地化;英文原文仅作兜底)。与 pkg/web 的 Sys* 常量一一对应。 sys: { diff --git a/web/frontend/src/i18n/locales/zh/findings.ts b/web/frontend/src/i18n/locales/zh/findings.ts index 6f4b0c5c..a9edfa08 100644 --- a/web/frontend/src/i18n/locales/zh/findings.ts +++ b/web/frontend/src/i18n/locales/zh/findings.ts @@ -28,11 +28,11 @@ export default { severity_medium: '中危', severity_low: '低危', severity_info: '信息', - scanDetails: '扫描详情', - closeDetailPanel: '关闭详情面板', + detailSummary: '{{targets}} 个目标 · {{services}} 个服务', assets: '资产', findings: '发现项', report: '报告', + loadingReport: '报告加载中…', noReportAvailable: '暂无可用报告', noResultsAvailable: '暂无结果', hosts: '主机', @@ -42,13 +42,12 @@ export default { fingers: '指纹', fingerprints: '指纹', sources: '来源', - loots: '战利品', errors: '错误', duration: '耗时', noHosts: '暂无主机', linkTo: '链接到 {{name}}', - sitemap: '站点地图', - webCount: '{{count}} Web', + redirectsTo: '重定向至 {{url}}', + sitemap: '目录树', expandAll: '全部展开', collapseAll: '全部收起', } diff --git a/web/frontend/src/i18n/locales/zh/scan.ts b/web/frontend/src/i18n/locales/zh/scan.ts index a3a82182..baf193f0 100644 --- a/web/frontend/src/i18n/locales/zh/scan.ts +++ b/web/frontend/src/i18n/locales/zh/scan.ts @@ -25,7 +25,6 @@ export default { // ScanHistory noScansYet: '暂无扫描记录', assets: '资产', - loots: '战利品', // ScanWorkspace hideDetails: '隐藏详情', @@ -43,11 +42,6 @@ export default { // chat/ScanSummaryCard scanComplete: '扫描完成', - assetsLabel: '资产', - servicesLabel: '服务', - lootsLabel: '战利品', - errorsLabel: '错误', - viewDetails: '查看详情', // analysis option badges (ScanForm / ScanView / ScanHistory) optVerify: '验证', diff --git a/web/frontend/src/lib/chat-extensions.tsx b/web/frontend/src/lib/chat-extensions.tsx index d1fb42dd..3767f18c 100644 --- a/web/frontend/src/lib/chat-extensions.tsx +++ b/web/frontend/src/lib/chat-extensions.tsx @@ -25,12 +25,19 @@ export function registerChatExtensions() { registerTimelineRenderer('scan_complete', { renderer: ({ item, context }) => { - const onShowScanDetail = context.onShowScanDetail as ((id: string) => void) | undefined + const scanID = item.data.scanID as string + // A live scan_complete event carries the Result inline; a card rebuilt from + // a persisted marker (page reload / session switch) does not, so fall back to + // the scanResults map the session loads from its scan_ids. Until that map + // resolves the result is absent — render nothing rather than an empty card; + // the row re-renders and the card appears once the map fills. + const scanResults = context.scanResults as Map | undefined + const result = (item.data.result as ScanResult) ?? scanResults?.get(scanID) + if (!result) return null return ( {})} + scanID={scanID} + result={result} /> ) }, diff --git a/web/frontend/src/lib/scan-result.ts b/web/frontend/src/lib/scan-result.ts index b97673e7..7ccef4c5 100644 --- a/web/frontend/src/lib/scan-result.ts +++ b/web/frontend/src/lib/scan-result.ts @@ -23,13 +23,11 @@ export type BadgeSpec = { } export type ResultMetrics = { - assets: number hosts: number services: number web: number probes: number fingers: number - loots: number errors: number duration: string } @@ -55,7 +53,6 @@ export type ServiceNode = { target: string title: string summary: string - sources: string[] states: string[] statuses: string[] fingers: string[] @@ -88,13 +85,11 @@ export function buildResultModel(result: ScanResult): ResultModel { return { hosts, metrics: { - assets: assets.length, hosts: hosts.length, services: result.summary.services, web: result.summary.webs, probes: result.summary.probes, fingers: countFingerprints(assets), - loots: result.summary.loots || result.loots?.length || countLootItems(assets), errors: result.summary.errors, duration: result.summary.duration, }, @@ -164,7 +159,6 @@ export function serviceNode(asset: ViewAsset): ServiceNode { target, title, summary, - sources: sourceValues(asset.items), states: stateValues(asset.items), statuses: statusCodeValues(paths), fingers: fingerprintValues(asset.items), @@ -256,6 +250,31 @@ export function pathSearch(item: AssetItem) { return idx >= 0 ? path.slice(idx) : '' } +// Short content-type token for an endpoint (JS / JSON / CSS / …), derived from +// the probe's content_type header, falling back to the URL extension so it +// still flags JS bundles etc. on scans that don't surface the header. HTML — +// the default page type — deliberately returns '' so it never adds noise. +export function contentToken(item: AssetItem): string { + const ct = dataString(item, 'content_type').toLowerCase() + const pathname = (dataString(item, 'path') || webPath(item.target)).split('?')[0].toLowerCase() + const ext = pathname.includes('.') ? pathname.slice(pathname.lastIndexOf('.') + 1) : '' + if (ct.includes('json') || ext === 'json') return 'JSON' + if (ct.includes('javascript') || /^m?jsx?$/.test(ext)) return 'JS' + if (ct.includes('css') || ext === 'css') return 'CSS' + if (ct.includes('xml') || ext === 'xml') return 'XML' + if (ct.includes('html')) return '' + if (ct.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp'].includes(ext)) return 'IMG' + if (ct.includes('font') || ['woff', 'woff2', 'ttf', 'otf', 'eot'].includes(ext)) return 'FONT' + if (ct.includes('pdf') || ext === 'pdf') return 'PDF' + if (ct.startsWith('text/plain') || ['txt', 'log', 'map'].includes(ext)) return 'TXT' + return '' +} + +// Redirect destination for an endpoint (empty when the probe was not a redirect). +export function redirectTarget(item: AssetItem): string { + return dataString(item, 'redirect_url') +} + export function pathIdentity(item: AssetItem) { return `${canonicalKey(dataString(item, 'url') || item.target || dataString(item, 'path'))}|host=${dataString(item, 'host_header')}` } @@ -343,10 +362,6 @@ export function statusCodeTone(status?: string): BadgeTone { return 'muted' } -export function formatCount(count: number, singular: string) { - return `${count} ${count === 1 ? singular : `${singular}s`}` -} - function serviceTitle(asset: ViewAsset, serviceItem: AssetItem | undefined, service: string) { const assetTitle = firstText(asset.title) if (assetTitle && assetTitle !== asset.target && labelKey(assetTitle) !== labelKey(service)) { @@ -364,14 +379,6 @@ function serviceSummary(asset: ViewAsset, serviceItem: AssetItem | undefined, ti return firstText(...values.filter((value) => labelKey(value) !== labelKey(title) && labelKey(value) !== labelKey(service))) } -function countLootItems(assets: ViewAsset[]) { - return assets.reduce((sum, asset) => ( - sum + asset.items.filter((item) => ( - item.kind === assetItemKind.loot && dataString(item, 'kind').toLowerCase() !== 'fingerprint' - )).length - ), 0) -} - function countFingerprints(assets: ViewAsset[]) { return uniqueStrings(assets.flatMap((asset) => fingerprintValues(asset.items))).length } @@ -379,6 +386,9 @@ function countFingerprints(assets: ViewAsset[]) { function serviceSort(a: ServiceNode, b: ServiceNode) { const ap = Number.parseInt(a.port, 10) const bp = Number.parseInt(b.port, 10) + if (Number.isFinite(ap) !== Number.isFinite(bp)) { + return Number.isFinite(ap) ? -1 : 1 + } if (Number.isFinite(ap) && Number.isFinite(bp) && ap !== bp) { return ap - bp } From 6ae2a79340f542b7ebe983821b2fa4820da17fcc Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Sat, 11 Jul 2026 03:46:47 -0700 Subject: [PATCH 010/348] =?UTF-8?q?feat(web):=20=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E9=80=82=E9=85=8D=20=E2=80=94=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8C=BA/dvh/16px=20=E8=BE=93=E5=85=A5/=E8=A7=A6=E5=B1=8F?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100dvh 根高避开手机浏览器工具栏;pt/pb-safe 清刘海与 home 指示条;iOS 16px 输入防聚焦放大;侧栏折叠态在手机隐藏(改由顶栏抽屉打开);触屏无 hover 的删除键常显并放大命中区;QuickConnect 手机改视口内定位;FindingsSummary 手机 3 列。桌面(md+)形态不变。 Co-Authored-By: Claude Opus 4.8 (1M context) --- web/frontend/index.html | 2 +- web/frontend/src/components/ErrorBoundary.tsx | 2 +- .../src/components/FindingsSummary.tsx | 2 +- web/frontend/src/components/QuickConnect.tsx | 21 ++++++++++++--- web/frontend/src/components/SessionList.tsx | 11 ++++++-- web/frontend/src/index.css | 26 ++++++++++++++++++- .../src/viewer/components/chat/ChatInput.tsx | 10 +++---- 7 files changed, 60 insertions(+), 14 deletions(-) diff --git a/web/frontend/index.html b/web/frontend/index.html index 85f35282..1c5ba4c7 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -2,7 +2,7 @@ - + AIScan `) - indexBytes = bytes.Replace(indexBytes, []byte(""), append(injection, []byte("")...), 1) - } fileServer := http.FileServer(http.FS(fsys)) return func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") @@ -160,10 +155,8 @@ func newSPAFileServer(fsys fs.FS, accessKey string) http.HandlerFunc { return } } - // Serve injected index.html for SPA routes. Never cache it: it's the one - // unfingerprinted document, it carries the per-start access key, and it - // points at the current asset hashes — a cached shell would keep loading a - // stale bundle (or a dead access key after a restart) until a hard refresh. + // Serve index.html for SPA routes. Never cache it: it is the one + // unfingerprinted document and points at the current asset hashes. if len(indexBytes) > 0 { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") diff --git a/cmd/runner/main.go b/cmd/runner/main.go index 8cb57400..f1e95146 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -90,6 +90,7 @@ func initTools(ctx context.Context, option *cfg.Option, logger telemetry.Logger, registry := commands.NewRegistry() deps := &commands.Deps{ WorkDir: workDir, + RunnerMode: true, EngineSet: engineSet, Logger: logger, DataBus: dataBus, diff --git a/cmd/runner/main_test.go b/cmd/runner/main_test.go index 1c517d64..fb6a7609 100644 --- a/cmd/runner/main_test.go +++ b/cmd/runner/main_test.go @@ -19,4 +19,7 @@ func TestInitToolsRegistersBash(t *testing.T) { if _, ok := registry.GetTool("bash"); !ok { t.Fatal("bash tool is not registered") } + if _, ok := registry.GetTool("ls"); !ok { + t.Fatal("native ls tool is not registered") + } } diff --git a/core/harness/result.go b/core/harness/result.go index 15f060a0..446b42e0 100644 --- a/core/harness/result.go +++ b/core/harness/result.go @@ -97,12 +97,21 @@ func (r *RunResult) ToolCallsNamed(name string) []ToolExecution { func (r *RunResult) Turns() int { max := 0 for _, event := range r.Events { - if event.Type != aop.TypeTurnStart && event.Type != aop.TypeTurnEnd { + turn := 0 + switch event.Type { + case aop.TypeTurnStart: + if data, err := aop.DecodeData[aop.TurnData](event); err == nil { + turn = data.Turn + } + case aop.TypeTurnEnd: + if data, err := aop.DecodeData[aop.TurnEndData](event); err == nil { + turn = data.Turn + } + default: continue } - data, err := aop.DecodeData[aop.TurnData](event) - if err == nil && data.Turn > max { - max = data.Turn + if turn > max { + max = turn } } return max diff --git a/core/runner/local_repl.go b/core/runner/local_repl.go new file mode 100644 index 00000000..302064df --- /dev/null +++ b/core/runner/local_repl.go @@ -0,0 +1,111 @@ +package runner + +import ( + "context" + "fmt" + "io" + "sync" + "time" + + rlterm "github.com/chainreactors/tui/readline/terminal" + "github.com/chainreactors/utils/pty" +) + +// AttachLocalREPL connects the process terminal to the Runtime-owned main REPL +// through the same PTY router used by WebSocket transport. +func (rt *AgentRuntime) AttachLocalREPL(ctx context.Context) error { + router, err := rt.NewPTYRouter() + if err != nil { + return err + } + defer router.Close() + + terminal := rlterm.Local() + restore, err := terminal.Control.MakeRaw() + if err != nil { + return err + } + defer restore() + + streamID := "local-repl" + cols, rows := terminal.Control.Size() + var sessionID string + router.Handle(ctx, pty.Frame{Type: pty.FrameList, StreamID: streamID}, func(frame pty.Frame) { + for _, session := range frame.Sessions { + if session.State == pty.StateRunning && session.Kind == "repl" && session.Name == MainREPLName { + sessionID = session.ID + return + } + } + }) + if sessionID == "" { + return fmt.Errorf("main repl is not running") + } + + done := make(chan error, 1) + var writeMu sync.Mutex + send := func(frame pty.Frame) { + switch frame.Type { + case pty.FrameOutput: + writeMu.Lock() + _, _ = terminal.Out.Write(frame.Data) + writeMu.Unlock() + case pty.FrameError: + select { + case done <- fmt.Errorf("pty: %s", frame.Error): + default: + } + case pty.FrameClosed: + select { + case done <- nil: + default: + } + } + } + router.Handle(ctx, pty.Frame{ + Type: pty.FrameAttach, + StreamID: streamID, + SessionID: sessionID, + Cols: cols, + Rows: rows, + }, send) + + readErr := make(chan error, 1) + go func() { + buf := make([]byte, 4096) + for { + n, readErrValue := terminal.In.Read(buf) + if n > 0 { + data := append([]byte(nil), buf[:n]...) + router.Handle(ctx, pty.Frame{Type: pty.FrameInput, StreamID: streamID, SessionID: sessionID, Data: data}, send) + } + if readErrValue != nil { + readErr <- readErrValue + return + } + } + }() + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + lastCols, lastRows := cols, rows + for { + select { + case err := <-done: + return err + case err := <-readErr: + if err == io.EOF { + return nil + } + return err + case <-ticker.C: + cols, rows := terminal.Control.Size() + if cols != lastCols || rows != lastRows { + lastCols, lastRows = cols, rows + router.Handle(ctx, pty.Frame{Type: pty.FrameResize, StreamID: streamID, SessionID: sessionID, Cols: cols, Rows: rows}, send) + } + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/core/runner/remote_repl.go b/core/runner/remote_repl.go index d26295ac..afdf25b3 100644 --- a/core/runner/remote_repl.go +++ b/core/runner/remote_repl.go @@ -6,55 +6,58 @@ import ( "io" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/tmux" "github.com/chainreactors/aiscan/pkg/tui" rlterm "github.com/chainreactors/tui/readline/terminal" "github.com/chainreactors/utils/pty" ) -func NewRemoteREPLOpener(rt *AgentRuntime, mgr *tmux.Manager) pty.OpenFunc { - return func(ctx context.Context, spec pty.OpenSpec) (pty.OpenResult, error) { - if rt == nil || rt.App == nil { - return pty.OpenResult{}, fmt.Errorf("remote repl requires an agent runtime") - } - if mgr == nil { - return pty.OpenResult{}, fmt.Errorf("pty manager unavailable") - } - option := rt.Option - if option == nil { - option = &cfg.Option{} - } - session := agent.NewAgent(rt.Config. - WithSystemPrompt(rt.SystemPrompt). - WithStream(true)) - appInfo := tui.AppInfo{ - Provider: rt.App.Provider, - ProviderConfig: rt.App.ProviderConfig, - ProviderFallbacks: rt.App.ProviderFallbacks, - Commands: rt.App.Commands, - Skills: rt.App.Skills, - OnProviderChange: func(provider agent.Provider, providerConfig agent.ProviderConfig) { - rt.App.Provider = provider - rt.App.ProviderConfig = providerConfig - rt.Config.Provider = provider - rt.Config.Model = providerConfig.Model - }, - } - control := rlterm.NewControl(true, 80, 24) - info, err := mgr.CreateInteractiveFunc(ctx, spec.Name, "aiscan remote repl", pty.DefaultSessionTimeout, false, func(replCtx context.Context, input io.Reader, output io.Writer) error { - return tui.RunRemoteAgentConsoleWithControl(replCtx, option, appInfo, session, input, output, control) - }) - if err != nil { - return pty.OpenResult{}, err +const MainREPLName = "main-repl" + +func (rt *AgentRuntime) startMainREPL() error { + if rt == nil || rt.App == nil { + return fmt.Errorf("main repl requires an agent runtime") + } + if rt.ptyManager == nil { + return fmt.Errorf("pty manager unavailable") + } + sess, err := rt.session(MainREPLName) + if err != nil { + return err + } + option := rt.Option + if option == nil { + option = &cfg.Option{} + } + control := rlterm.NewControl(true, 80, 24) + info, err := rt.ptyManager.CreateInteractiveFuncWithOptions(rt.ctx, MainREPLName, "aiscan repl", pty.InteractiveOptions{ + Timeout: 0, + StripANSI: false, + Resize: control.SetSize, + }, func(replCtx context.Context, input io.Reader, output io.Writer) error { + for { + err := tui.RunRemoteAgentConsoleWithControl(replCtx, option, rt.consoleAppInfo(), sess.agent, input, output, control) + if replCtx.Err() != nil { + return replCtx.Err() + } + if err != nil || rt.replMode != REPLPersistent { + return err + } } - mgr.SetKind(info.ID, "repl") - info.Kind = "repl" - return pty.OpenResult{ - Info: info, - Resize: func(cols, rows int) { - control.SetSize(cols, rows) - }, - }, nil + }) + if err != nil { + return err + } + rt.ptyManager.SetKind(info.ID, "repl") + return nil +} + +// NewPTYRouter returns a connection-scoped router over the Runtime-owned PTY +// manager. Closing the router only detaches its monitors; Runtime.Close owns +// session shutdown. +func (rt *AgentRuntime) NewPTYRouter() (*pty.Router, error) { + if rt == nil || rt.ptyManager == nil || rt.ptyManager.Manager == nil { + return nil, fmt.Errorf("pty manager unavailable") } + openers := pty.DefaultOpeners(rt.ptyManager.Manager, pty.DefaultSessionTimeout, pty.DefaultEnv()) + return pty.NewRouter(rt.ptyManager.Manager, pty.WithOpeners(openers)), nil } diff --git a/core/runner/remote_repl_test.go b/core/runner/remote_repl_test.go index ab7c279c..97b65168 100644 --- a/core/runner/remote_repl_test.go +++ b/core/runner/remote_repl_test.go @@ -7,13 +7,11 @@ import ( "time" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/agent/tmux" - "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/utils/pty" ) -func TestRemoteREPLOpenerUsesRuntimeManagerWithoutProvider(t *testing.T) { +func TestRuntimeOwnsPersistentMainREPLWithoutProvider(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -23,33 +21,53 @@ func TestRemoteREPLOpenerUsesRuntimeManagerWithoutProvider(t *testing.T) { rt, err := NewAgentRuntime(ctx, option, telemetry.NopLogger(), &RuntimeConfig{ ProviderOptional: true, NoOutput: true, + REPLMode: REPLPersistent, }) if err != nil { t.Fatalf("runtime without provider: %v", err) } defer rt.Close() - mgr := testRegistryPTYManager(rt.App.Commands) + mgr := rt.ptyManager if mgr == nil { t.Fatal("pty manager unavailable") } + var initial pty.Info + for _, info := range mgr.List() { + if info.State == pty.StateRunning && info.Kind == "repl" && info.Name == MainREPLName { + initial = info + break + } + } + if initial.ID == "" { + t.Fatal("main-repl was not created eagerly") + } + if initial.Name != MainREPLName || initial.Kind != "repl" || initial.State != pty.StateRunning { + t.Fatalf("unexpected resident repl: %+v", initial) + } + messages := make(chan pty.Frame, 64) - router := pty.NewRouter(mgr, pty.WithOpener("repl", NewRemoteREPLOpener(rt, mgr))) + router, err := rt.NewPTYRouter() + if err != nil { + t.Fatal(err) + } defer router.Close() router.Handle(ctx, pty.Frame{ - Type: pty.FrameOpen, - StreamID: "term-repl", - Kind: "repl", - Name: "remote-repl-test", + Type: pty.FrameAttach, + StreamID: "term-repl", + SessionID: initial.ID, }, func(frame pty.Frame) { messages <- frame }) - waitForFrame(t, messages, time.Second, func(frame pty.Frame) bool { + opened := waitForFrame(t, messages, time.Second, func(frame pty.Frame) bool { if frame.Type == pty.FrameError { t.Fatalf("unexpected pty error: %s", frame.Error) } - return frame.Type == pty.FrameOpened + return frame.Type == pty.FrameAttached }) + if opened.SessionID != initial.ID { + t.Fatalf("transport created a second repl: got %s want %s", opened.SessionID, initial.ID) + } router.Handle(ctx, pty.Frame{Type: pty.FrameInput, StreamID: "term-repl", Data: []byte("/status\n")}, func(frame pty.Frame) { messages <- frame @@ -61,6 +79,15 @@ func TestRemoteREPLOpenerUsesRuntimeManagerWithoutProvider(t *testing.T) { return frame.Type == pty.FrameOutput && strings.Contains(string(frame.Data), "not configured") }) + beforeExit, _ := mgr.Get(initial.ID) + router.Handle(ctx, pty.Frame{Type: pty.FrameInput, StreamID: "term-repl", Data: []byte("/exit\n")}, func(frame pty.Frame) { + messages <- frame + }) + waitForCondition(t, 3*time.Second, func() bool { + info, ok := mgr.Get(initial.ID) + return ok && info.State == pty.StateRunning && info.OutputBytes > beforeExit.OutputBytes + }) + router.Handle(ctx, pty.Frame{Type: pty.FrameInput, StreamID: "term-repl", Data: []byte("!tmux new-session -d -s webtask echo tmux_remote_ok\n")}, func(frame pty.Frame) { messages <- frame }) @@ -72,23 +99,38 @@ func TestRemoteREPLOpenerUsesRuntimeManagerWithoutProvider(t *testing.T) { } return false }) -} -func testRegistryPTYManager(reg *commands.CommandRegistry) *tmux.Manager { - if reg == nil { - return nil + // Closing one transport Router only detaches its monitor. A new transport + // must reuse the same process-owned session and buffered console. + router.Close() + if info, ok := mgr.Get(initial.ID); !ok || info.State != pty.StateRunning { + t.Fatalf("router close terminated resident repl: %+v ok=%v", info, ok) } - tool, ok := reg.GetTool("bash") - if !ok { - return nil + router2, err := rt.NewPTYRouter() + if err != nil { + t.Fatal(err) } - manager, ok := tool.(interface { - Manager() *tmux.Manager + defer router2.Close() + reconnected := make(chan pty.Frame, 16) + router2.Handle(ctx, pty.Frame{Type: pty.FrameAttach, StreamID: "term-repl-2", SessionID: initial.ID}, func(frame pty.Frame) { + reconnected <- frame }) - if !ok { - return nil + attached := waitForFrame(t, reconnected, time.Second, func(frame pty.Frame) bool { + return frame.Type == pty.FrameAttached + }) + if attached.SessionID != initial.ID { + t.Fatalf("reconnect session = %s, want %s", attached.SessionID, initial.ID) + } + + running := 0 + for _, info := range mgr.List() { + if info.State == pty.StateRunning && info.Kind == "repl" && info.Name == MainREPLName { + running++ + } + } + if running != 1 { + t.Fatalf("running main-repl count = %d, want 1", running) } - return manager.Manager() } func waitForCondition(t *testing.T, timeout time.Duration, predicate func() bool) { diff --git a/core/runner/runner.go b/core/runner/runner.go index 550c9de6..81843643 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "sync" "time" cfg "github.com/chainreactors/aiscan/core/config" @@ -38,10 +39,28 @@ type AgentRuntime struct { Output *tui.AgentOutput ConfigFile string ResumeMessages []agent.ChatMessage + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex + sessions map[string]*sessionState + requests map[string]runtimeRequestState + requestSeq uint64 + closeOnce sync.Once + wg sync.WaitGroup + ptyManager *tmuxpkg.Manager + replMode REPLMode ownsApp bool cleanup func() } +type REPLMode uint8 + +const ( + REPLDisabled REPLMode = iota + REPLEphemeral + REPLPersistent +) + type RuntimeConfig struct { ExistingApp *App IOA *cfg.IOAConfig @@ -49,10 +68,23 @@ type RuntimeConfig struct { NoOutput bool InteractiveOutput bool ProviderOptional bool + REPLMode REPLMode } func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.Logger, rc *RuntimeConfig) (*AgentRuntime, error) { - rt := &AgentRuntime{} + if ctx == nil { + ctx = context.Background() + } + runtimeCtx, runtimeCancel := context.WithCancel(ctx) + rt := &AgentRuntime{ + ctx: runtimeCtx, + cancel: runtimeCancel, + sessions: make(map[string]*sessionState), + requests: make(map[string]runtimeRequestState), + } + if rc != nil { + rt.replMode = rc.REPLMode + } if option != nil { optCopy := *option rt.Option = &optCopy @@ -141,22 +173,9 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L ib := inboxpkg.NewBuffered(agent.DefaultInboxCapacity) var ioaCancel func() - if rt.App.IOAStreamClient != nil && option.Space != "" { - nodeID := "" - if rt.App.IOAClient != nil { - nodeID = rt.App.IOAClient.NodeID() - } - spaceInfo, err := rt.App.IOAStreamClient.Space(ctx, option.Space, "aiscan agent") - if err != nil { - logger.Warnf("ioa space resolve: %s", err) - } else { - ioaCtx, cancel := context.WithCancel(ctx) - ioaCancel = cancel - go subscribeIOASpace(ioaCtx, rt.App.IOAStreamClient, spaceInfo.ID, nodeID, ib, logger) - } - } sessMgr, bashTool := bashToolAndManager(rt.App.Commands) + rt.ptyManager = sessMgr if bashTool != nil { bashTool.SetInbox(ib) } @@ -170,7 +189,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L "session_name": info.Name, "exit_code": info.ExitCode, } - if err := ib.Push(msg); err != nil { + if err := rt.inboxPush()(msg); err != nil { logger.Warnf("inbox push session completion: %s", err) } }) @@ -178,15 +197,6 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L scheduler := agent.NewLoopScheduler(ib, logger) - if option.Heartbeat > 0 { - _, _ = scheduler.Add(ctx, agent.LoopEntry{ - Name: "heartbeat", - Interval: time.Duration(option.Heartbeat) * time.Minute, - Mode: agent.ModeInbox, - Prompt: "Heartbeat: review current context, check on any running sessions, and decide if action is needed.", - }) - } - rt.Config = agent.Config{ Provider: rt.App.Provider, Fallbacks: rt.App.ProviderFallbacks, @@ -216,8 +226,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L }) } - parentAgent := agent.NewAgent(rt.Config) - subAgentTool := agent.NewSubAgentTool(parentAgent, ib, func(name string) (agent.AgentType, error) { + subAgentTool := agent.NewSubAgentTool(func(name string) (agent.AgentType, error) { if rt.App.Skills == nil { return agent.AgentType{}, fmt.Errorf("agent type %q not found", name) } @@ -234,6 +243,11 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L Background: s.AgentBackground, }, nil }) + ioaSpace := option.Space + if ioaSpace == "" && rc != nil && rc.IOA != nil { + ioaSpace = rc.IOA.Space + } + subscribeIOAHandoff(agentBus, rt.App.IOAClient, ioaSpace, logger) rt.App.Commands.RegisterTool(subAgentTool) loop := agent.NewLoopCommand(scheduler) rt.App.Commands.Register(cmdpkg.Command{Name: loop.Name(), Usage: loop.Usage(), Run: loop.Run}, "loop") @@ -248,6 +262,21 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L logger.Importantf("resumed %d messages from %s", len(data.Messages), path) } + if rt.App.IOAStreamClient != nil && option.Space != "" { + nodeID := "" + if rt.App.IOAClient != nil { + nodeID = rt.App.IOAClient.NodeID() + } + spaceInfo, err := rt.App.IOAStreamClient.Space(ctx, option.Space, "aiscan agent") + if err != nil { + logger.Warnf("ioa space resolve: %s", err) + } else { + ioaCtx, cancel := context.WithCancel(ctx) + ioaCancel = cancel + go subscribeIOASpace(ioaCtx, rt.App.IOAStreamClient, spaceInfo.ID, nodeID, rt.inboxPush(), logger) + } + } + rt.cleanup = func() { if ioaCancel != nil { ioaCancel() @@ -258,16 +287,38 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L } } + if rt.replMode != REPLDisabled { + if err := rt.startMainREPL(); err != nil { + runtimeCancel() + rt.cleanup() + if rt.ownsApp && rt.App != nil { + rt.App.Close() + } + return nil, fmt.Errorf("start main repl: %w", err) + } + } + return rt, nil } func (rt *AgentRuntime) Close() { - if rt.cleanup != nil { - rt.cleanup() - } - if rt.ownsApp && rt.App != nil { - rt.App.Close() + if rt == nil { + return } + rt.closeOnce.Do(func() { + if rt.cancel != nil { + rt.cancel() + } + rt.cancelAllRequests() + rt.wg.Wait() + rt.closeSessions() + if rt.cleanup != nil { + rt.cleanup() + } + if rt.ownsApp && rt.App != nil { + rt.App.Close() + } + }) } func (rt *AgentRuntime) SetLogger(logger telemetry.Logger) { @@ -281,6 +332,7 @@ func (rt *AgentRuntime) SetLogger(logger telemetry.Logger) { rt.App.SetLogger(logger) logger = rt.App.Logger() } + rt.mu.Lock() rt.Config.Logger = logger if rt.Config.LoopScheduler != nil { rt.Config.LoopScheduler.SetLogger(logger) @@ -288,6 +340,10 @@ func (rt *AgentRuntime) SetLogger(logger telemetry.Logger) { if sl, ok := rt.Config.Tools.(interface{ SetLogger(telemetry.Logger) }); ok { sl.SetLogger(logger) } + for _, sess := range rt.sessions { + sess.agent.SetLogger(logger) + } + rt.mu.Unlock() } // ReloadProvider rebuilds the LLM provider from option and hot-swaps it into the @@ -308,13 +364,31 @@ func (rt *AgentRuntime) ReloadProvider(option *cfg.Option) (agent.Provider, stri if err != nil { return nil, "", err } - rt.App.Provider = provider - rt.App.ProviderConfig = *resolved - rt.Config.Provider = provider - rt.Config.Model = resolved.Model + rt.SetProvider(provider, *resolved) return provider, resolved.Model, nil } +// SetProvider atomically updates the runtime template and every existing +// conversation session. Runs already in flight keep their provider snapshot. +func (rt *AgentRuntime) SetProvider(provider agent.Provider, providerConfig agent.ProviderConfig) { + if rt == nil { + return + } + rt.mu.Lock() + if rt.App != nil { + rt.App.Provider = provider + rt.App.ProviderConfig = providerConfig + } + rt.Config.Provider = provider + if providerConfig.Model != "" { + rt.Config.Model = providerConfig.Model + } + for _, sess := range rt.sessions { + sess.agent.SetProvider(provider, providerConfig.Model) + } + rt.mu.Unlock() +} + // --------------------------------------------------------------------------- // Mode dispatch // --------------------------------------------------------------------------- @@ -384,7 +458,10 @@ func runOneShotMode(ctx context.Context, option *cfg.Option, logger telemetry.Lo // --------------------------------------------------------------------------- func runInteractiveMode(ctx context.Context, option *cfg.Option, logger telemetry.Logger, setInterrupt func(func() bool)) error { - rt, err := NewAgentRuntime(ctx, option, logger, &RuntimeConfig{InteractiveOutput: true}) + rt, err := NewAgentRuntime(ctx, option, logger, &RuntimeConfig{ + NoOutput: true, + REPLMode: REPLEphemeral, + }) if err != nil { return err } @@ -394,32 +471,10 @@ func runInteractiveMode(ctx context.Context, option *cfg.Option, logger telemetr return err } - session := agent.NewAgent(rt.Config. - WithSystemPrompt(rt.SystemPrompt). - WithStream(true)) - if len(rt.ResumeMessages) > 0 { - session.LoadMessages(rt.ResumeMessages) - } - - repl := tui.NewAgentConsole(ctx, option, tui.AppInfo{ - Provider: rt.App.Provider, - ProviderConfig: rt.App.ProviderConfig, - ProviderFallbacks: rt.App.ProviderFallbacks, - Commands: rt.App.Commands, - Skills: rt.App.Skills, - OnProviderChange: func(provider agent.Provider, providerConfig agent.ProviderConfig) { - rt.App.Provider = provider - rt.App.ProviderConfig = providerConfig - rt.Config.Provider = provider - rt.Config.Model = providerConfig.Model - }, - OnLoggerChange: rt.SetLogger, - }, session, rt.Output) - repl.SetOnExit(rt.Close) if setInterrupt != nil { - setInterrupt(repl.InterruptCurrentRun) + setInterrupt(func() bool { return rt.CancelSession(MainREPLName) }) } - return repl.Start() + return rt.AttachLocalREPL(ctx) } // --------------------------------------------------------------------------- @@ -543,27 +598,14 @@ func buildEvalConfig(option *cfg.Option, rt *AgentRuntime, logger telemetry.Logg if option.EvalModel != "" { model = option.EvalModel } - maxRounds := option.EvalMaxRetries - if maxRounds <= 0 { - maxRounds = 3 - } - return evaluator.EvalLoopConfig{ - Evaluator: evaluator.New(evaluator.Config{ - Provider: rt.App.Provider, - Model: model, - Logger: logger, - }), - MaxEvalRounds: maxRounds, - Goal: task, - Criteria: option.EvalCriteria, - } + return evaluator.NewLoopConfig(rt.App.Provider, model, logger, task, option.EvalCriteria, option.EvalMaxRetries) } // --------------------------------------------------------------------------- // IOA inbox subscription // --------------------------------------------------------------------------- -func subscribeIOASpace(ctx context.Context, stream ioaclient.StreamAPI, spaceID, nodeID string, ib *inboxpkg.Buffered, logger telemetry.Logger) { +func subscribeIOASpace(ctx context.Context, stream ioaclient.StreamAPI, spaceID, nodeID string, push func(inboxpkg.Message) error, logger telemetry.Logger) { for attempt := 0; ctx.Err() == nil; attempt++ { msgs, errs, cancel, err := stream.Subscribe(ctx, spaceID) if err != nil { @@ -589,7 +631,7 @@ func subscribeIOASpace(ctx context.Context, stream ioaclient.StreamAPI, spaceID, } m := inboxpkg.NewMessage(inboxpkg.OriginPeer, "user", formatIOAMessage(msg)) m.Meta = map[string]any{"sender": msg.Sender, "message_id": msg.ID} - if err := ib.Push(m); err != nil { + if err := push(m); err != nil { logger.Warnf("inbox push ioa: %s", err) } case <-errs: diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go new file mode 100644 index 00000000..059f4906 --- /dev/null +++ b/core/runner/runtime_session.go @@ -0,0 +1,411 @@ +package runner + +// This file owns the runtime session layer. Three session concepts coexist in +// this package, each with a single responsibility: +// - runtime session (here): execution state + per-session FIFO work queue +// - tmux session (pkg/agent/tmux): the PTY process a terminal attaches to +// - agent session id (agent.Config.SessionID): the AOP protocol identifier +// +// The resident main REPL aligns all three under MainREPLName. + +import ( + "bytes" + "context" + "fmt" + "strings" + "sync" + "time" + + outputpkg "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/agent/evaluator" + inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" + "github.com/chainreactors/aiscan/pkg/aop/x/eval" + "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/pkg/tui" +) + +type sessionState struct { + id string + agent *agent.Agent + inbox inboxpkg.Inbox + scheduler *agent.LoopScheduler + work chan func() + + mu sync.Mutex + cancel context.CancelFunc + tail chan struct{} +} + +type runtimeRequestState struct { + sessionID string + cancel context.CancelFunc +} + +func (rt *AgentRuntime) Execute(ctx context.Context, requestID string, inbound agent.Inbound, onStart func(string)) (*agent.Result, error) { + wait, err := rt.Submit(ctx, requestID, inbound, onStart) + if err != nil { + return nil, err + } + return wait() +} + +// Submit admits an AOP request in call order and returns a waiter for its +// result. Admission is non-blocking for the transport; execution still crosses +// the session's unbuffered worker channel. +func (rt *AgentRuntime) Submit(ctx context.Context, requestID string, inbound agent.Inbound, onStart func(string)) (func() (*agent.Result, error), error) { + if inbound.Kind != agent.InboundUserMessage { + return nil, fmt.Errorf("runtime Execute requires an inbound user message") + } + sessionID := strings.TrimSpace(inbound.Event.SessionID) + if sessionID == "" { + return nil, fmt.Errorf("runtime session_id is required") + } + return submitSession(rt, ctx, sessionID, requestID, onStart, func(runCtx context.Context, ag *agent.Agent) (*agent.Result, error) { + return agent.ExecuteInbound(runCtx, ag, inbound, agent.InboundDependencies{ + DefaultMaxTurns: rt.Config.MaxTurns, + Eval: func(evalCtx context.Context, evalAgent *agent.Agent, goal string, control eval.Control) (*agent.Result, error) { + p, model, logger := rt.providerSnapshot() + result, _, evalErr := evaluator.RunWithEval(evalCtx, evalAgent, + evaluator.NewLoopConfig(p, model, logger, goal, control.Criteria, control.MaxRounds)) + return result, evalErr + }, + }) + }) +} + +// ExecuteLine serializes a slash/direct command through the same per-session +// FIFO used by AOP input. It is used by the Web chat adapter only; the resident +// terminal runs its console directly because it is the sole producer for its +// dedicated conversation session. +func (rt *AgentRuntime) ExecuteLine(ctx context.Context, requestID, sessionID, line string) (string, error) { + wait, err := rt.SubmitLine(ctx, requestID, sessionID, line) + if err != nil { + return "", err + } + return wait() +} + +func (rt *AgentRuntime) SubmitLine(ctx context.Context, requestID, sessionID, line string) (func() (string, error), error) { + if strings.TrimSpace(sessionID) == "" { + return nil, fmt.Errorf("runtime session_id is required") + } + return submitSession(rt, ctx, sessionID, requestID, nil, func(runCtx context.Context, ag *agent.Agent) (string, error) { + var stdout, stderr bytes.Buffer + option := rt.Option + if option != nil { + copy := *option + copy.NoColor = true + option = © + } + appInfo := rt.consoleAppInfo() + console := tui.NewAgentConsoleWithWriters(runCtx, option, appInfo, ag, &stdout, &stderr) + _, execErr := console.ExecuteLineAndWait(line) + out := strings.TrimRight(outputpkg.StripANSI(stdout.String()), " \t\r\n") + errOut := strings.TrimRight(outputpkg.StripANSI(stderr.String()), " \t\r\n") + if execErr != nil { + if errOut != "" { + return "", fmt.Errorf("%s: %w", errOut, execErr) + } + return "", execErr + } + if out == "" { + out = errOut + } else if errOut != "" { + out = strings.TrimRight(out+"\n"+errOut, " \t\r\n") + } + return out, nil + }) +} + +func (rt *AgentRuntime) PushInbox(sessionID string, message inboxpkg.Message) error { + sess, err := rt.session(sessionID) + if err != nil { + return err + } + return sess.inbox.Push(message) +} + +// inboxPush returns the push function for runtime-owned producers (tmux +// completions, IOA subscriptions): with a resident main REPL, messages route +// to its session inbox; otherwise to the shared runtime inbox. +func (rt *AgentRuntime) inboxPush() func(inboxpkg.Message) error { + if rt.replMode != REPLDisabled { + return func(message inboxpkg.Message) error { return rt.PushInbox(MainREPLName, message) } + } + return rt.Config.Inbox.Push +} + +func (rt *AgentRuntime) Cancel(requestID string) bool { + if rt == nil || requestID == "" { + return false + } + rt.mu.RLock() + state, ok := rt.requests[requestID] + rt.mu.RUnlock() + if ok && state.cancel != nil { + state.cancel() + } + return ok +} + +func (rt *AgentRuntime) CancelSession(sessionID string) bool { + if rt == nil || sessionID == "" { + return false + } + rt.mu.RLock() + sess := rt.sessions[sessionID] + cancels := make([]context.CancelFunc, 0) + for _, state := range rt.requests { + if state.sessionID == sessionID && state.cancel != nil { + cancels = append(cancels, state.cancel) + } + } + rt.mu.RUnlock() + if sess == nil && len(cancels) == 0 { + return false + } + for _, cancel := range cancels { + cancel() + } + if sess != nil { + sess.mu.Lock() + cancel := sess.cancel + sess.mu.Unlock() + if cancel != nil { + cancel() + } + } + return true +} + +func submitSession[T any](rt *AgentRuntime, ctx context.Context, sessionID, requestID string, onStart func(string), run func(context.Context, *agent.Agent) (T, error)) (func() (T, error), error) { + var zero T + if rt == nil || run == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + if ctx == nil { + ctx = context.Background() + } + sess, err := rt.session(sessionID) + if err != nil { + return nil, err + } + + reqCtx, reqCancel := context.WithCancel(ctx) + requestID, err = rt.registerRequest(requestID, sessionID, reqCancel) + if err != nil { + reqCancel() + return nil, err + } + finish := func() { + reqCancel() + rt.unregisterRequest(requestID) + } + + type result struct { + value T + err error + } + done := make(chan result, 1) + task := func() { + defer finish() + if reqCtx.Err() != nil { + done <- result{err: reqCtx.Err()} + return + } + runCtx, cancel := context.WithCancel(reqCtx) + sess.mu.Lock() + sess.cancel = cancel + sess.mu.Unlock() + if onStart != nil { + onStart(sess.id) + } + value, runErr := run(runCtx, sess.agent) + cancel() + sess.mu.Lock() + sess.cancel = nil + sess.mu.Unlock() + done <- result{value: value, err: runErr} + } + sess.mu.Lock() + previous := sess.tail + next := make(chan struct{}) + sess.tail = next + sess.mu.Unlock() + go func() { + select { + case <-previous: + case <-reqCtx.Done(): + finish() + done <- result{err: reqCtx.Err()} + close(next) + return + case <-rt.ctx.Done(): + finish() + done <- result{err: rt.ctx.Err()} + close(next) + return + } + select { + case sess.work <- task: + close(next) + case <-reqCtx.Done(): + finish() + done <- result{err: reqCtx.Err()} + close(next) + case <-rt.ctx.Done(): + finish() + done <- result{err: rt.ctx.Err()} + close(next) + } + }() + return func() (T, error) { + result := <-done + if result.err != nil { + return zero, result.err + } + return result.value, nil + }, nil +} + +func (rt *AgentRuntime) session(sessionID string) (*sessionState, error) { + if rt == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, fmt.Errorf("runtime session_id is required") + } + + rt.mu.Lock() + defer rt.mu.Unlock() + if rt.ctx.Err() != nil { + return nil, rt.ctx.Err() + } + if sess := rt.sessions[sessionID]; sess != nil { + return sess, nil + } + + ib := rt.Config.Inbox + scheduler := rt.Config.LoopScheduler + if sessionID != MainREPLName || ib == nil || scheduler == nil { + ib = inboxpkg.NewBuffered(agent.DefaultInboxCapacity) + scheduler = agent.NewLoopScheduler(ib, rt.Config.Logger) + } + if sessionID == MainREPLName && rt.Option != nil && rt.Option.Heartbeat > 0 { + _, _ = scheduler.Add(rt.ctx, agent.LoopEntry{ + Name: "heartbeat", + Interval: time.Duration(rt.Option.Heartbeat) * time.Minute, + Mode: agent.ModeInbox, + Prompt: "Heartbeat: review current context, check on any running sessions, and decide if action is needed.", + }) + } + agentCfg := rt.Config. + WithSystemPrompt(rt.SystemPrompt). + WithStream(true). + WithInbox(ib). + WithSessionID(sessionID) + agentCfg.LoopScheduler = scheduler + ag := agent.NewAgent(agentCfg) + if sessionID == MainREPLName && len(rt.ResumeMessages) > 0 { + ag.LoadMessages(rt.ResumeMessages) + } + sess := &sessionState{ + id: sessionID, + agent: ag, + inbox: ib, + scheduler: scheduler, + work: make(chan func()), + tail: make(chan struct{}), + } + close(sess.tail) + rt.sessions[sessionID] = sess + rt.wg.Add(1) + go rt.runSession(sess) + return sess, nil +} + +func (rt *AgentRuntime) runSession(sess *sessionState) { + defer rt.wg.Done() + for { + select { + case <-rt.ctx.Done(): + return + case task := <-sess.work: + task() + } + } +} + +func (rt *AgentRuntime) registerRequest(requestID, sessionID string, cancel context.CancelFunc) (string, error) { + rt.mu.Lock() + defer rt.mu.Unlock() + if requestID == "" { + rt.requestSeq++ + requestID = fmt.Sprintf("runtime-%d", rt.requestSeq) + } + if _, exists := rt.requests[requestID]; exists { + return "", fmt.Errorf("runtime request %q already exists", requestID) + } + rt.requests[requestID] = runtimeRequestState{sessionID: sessionID, cancel: cancel} + return requestID, nil +} + +func (rt *AgentRuntime) unregisterRequest(requestID string) { + rt.mu.Lock() + delete(rt.requests, requestID) + rt.mu.Unlock() +} + +func (rt *AgentRuntime) cancelAllRequests() { + rt.mu.RLock() + cancels := make([]context.CancelFunc, 0, len(rt.requests)) + for _, state := range rt.requests { + if state.cancel != nil { + cancels = append(cancels, state.cancel) + } + } + rt.mu.RUnlock() + for _, cancel := range cancels { + cancel() + } +} + +func (rt *AgentRuntime) closeSessions() { + rt.mu.Lock() + sessions := make([]*sessionState, 0, len(rt.sessions)) + for _, sess := range rt.sessions { + sessions = append(sessions, sess) + } + rt.sessions = make(map[string]*sessionState) + rt.mu.Unlock() + for _, sess := range sessions { + if sess.scheduler != nil { + sess.scheduler.Stop() + } + if sess.inbox != nil { + sess.inbox.Close() + } + } +} + +func (rt *AgentRuntime) providerSnapshot() (agent.Provider, string, telemetry.Logger) { + rt.mu.RLock() + defer rt.mu.RUnlock() + return rt.Config.Provider, rt.Config.Model, rt.Config.Logger +} + +func (rt *AgentRuntime) consoleAppInfo() tui.AppInfo { + rt.mu.RLock() + defer rt.mu.RUnlock() + return tui.AppInfo{ + Provider: rt.App.Provider, + ProviderConfig: rt.App.ProviderConfig, + ProviderFallbacks: rt.App.ProviderFallbacks, + Commands: rt.App.Commands, + Skills: rt.App.Skills, + OnProviderChange: rt.SetProvider, + OnLoggerChange: rt.SetLogger, + } +} diff --git a/core/runner/stdio.go b/core/runner/stdio.go index 808fdc28..1eedadcf 100644 --- a/core/runner/stdio.go +++ b/core/runner/stdio.go @@ -12,16 +12,10 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/evaluator" "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/webproto" ) -// stdioQueueCapacity bounds each session's pending inbound messages. A full -// queue rejects the message with an error event instead of growing unbounded. -const stdioQueueCapacity = 64 - // RunStdio hosts a persistent multi-session AOP endpoint. stdin carries AOP // JSONL; each inbound user message selects (or creates) the agent session named // by its envelope session_id. Messages to one session run FIFO; sessions run @@ -56,43 +50,27 @@ func RunStdio( return host.err() } -// stdioQueuedMessage is one inbound user message waiting on a session's FIFO. -type stdioQueuedMessage struct { - event aop.Event - data aop.MessageData - goal webproto.GoalExt -} - -type stdioSession struct { - id string - agent *agent.Agent - queue chan stdioQueuedMessage - done chan struct{} // closed when the FIFO goroutine exits -} - type stdioHost struct { ctx context.Context option *cfg.Option logger telemetry.Logger - encMu sync.Mutex - enc *json.Encoder + encMu sync.Mutex + enc *json.Encoder encErr error - rt *AgentRuntime + rt *AgentRuntime rtErr error - mu sync.Mutex - sessions map[string]*stdioSession + wg sync.WaitGroup } func newStdioHost(ctx context.Context, option *cfg.Option, logger telemetry.Logger, output io.Writer) *stdioHost { return &stdioHost{ - ctx: ctx, - option: option, - logger: logger, - enc: json.NewEncoder(output), - sessions: make(map[string]*stdioSession), + ctx: ctx, + option: option, + logger: logger, + enc: json.NewEncoder(output), } } @@ -163,114 +141,30 @@ func (h *stdioHost) accept(line string) { h.emitLocal(aop.TypeError, "stdio", aop.ErrorData{Message: "decode inbound event: " + err.Error()}) return } - if event.Type != aop.TypeMessage { - return // only user messages are executable inbound units - } - data, err := aop.DecodeData[aop.MessageData](event) - if err != nil || data.Role != "user" { - h.emitLocal(aop.TypeError, event.SessionID, aop.ErrorData{Message: "inbound message must be a user message"}) + inbound, err := agent.Classify(event) + if err != nil || inbound.Kind != agent.InboundUserMessage { + h.emitLocal(aop.TypeError, event.SessionID, aop.ErrorData{Message: "invalid inbound event"}) return } - goal := webproto.DecodeGoalExt(event) - - h.mu.Lock() - sess := h.sessions[event.SessionID] - if sess == nil { - sess = h.startSessionLocked(event.SessionID) - } - select { - case sess.queue <- stdioQueuedMessage{event: event, data: data, goal: goal}: - default: - h.emitLocal(aop.TypeError, sess.id, aop.ErrorData{Message: "session queue full"}) - } - h.mu.Unlock() -} - -func (h *stdioHost) startSessionLocked(id string) *stdioSession { - if id == "" { - id = fmt.Sprintf("stdio-%d", time.Now().UnixNano()) - } - agentCfg := h.rt.Config. - WithSystemPrompt(h.rt.SystemPrompt). - WithStream(true). - WithInbox(nil). - WithSessionID(id) - sess := &stdioSession{ - id: id, - agent: agent.NewAgent(agentCfg), - queue: make(chan stdioQueuedMessage, stdioQueueCapacity), - done: make(chan struct{}), - } - h.sessions[id] = sess - go h.runSession(sess) - return sess -} -// runSession is the per-session FIFO: one run at a time, in arrival order. -func (h *stdioHost) runSession(sess *stdioSession) { - defer close(sess.done) - for { - select { - case <-h.ctx.Done(): - return - case queued, ok := <-sess.queue: - if !ok { - return - } - h.runOne(sess, queued) - } - } -} - -func (h *stdioHost) runOne(sess *stdioSession, queued stdioQueuedMessage) { - input := agent.InputFromAOPMessage(queued.data) - input.NoEcho = queued.goal.NoEcho - text := strings.TrimSpace(inputText(input)) - if text == "" { - h.emitLocal(aop.TypeError, sess.id, aop.ErrorData{Message: "empty prompt"}) + wait, err := h.rt.Submit(h.ctx, "", inbound, nil) + if err != nil { + h.emitLocal(aop.TypeError, event.SessionID, aop.ErrorData{Message: err.Error()}) return } - - if queued.goal.EvalCriteria != "" { - maxRounds := queued.goal.EvalMaxRounds - if maxRounds <= 0 { - maxRounds = 3 - } - sess.agent.SetMaxTurns(h.rt.Config.MaxTurns) - evalCfg := evaluator.EvalLoopConfig{ - Evaluator: evaluator.New(evaluator.Config{ - Provider: h.rt.App.Provider, - Model: h.rt.Config.Model, - Logger: h.rt.Config.Logger, - }), - MaxEvalRounds: maxRounds, - Goal: text, - Criteria: queued.goal.EvalCriteria, + h.wg.Add(1) + go func() { + defer h.wg.Done() + if _, err := wait(); err != nil { + h.emitLocal(aop.TypeError, event.SessionID, aop.ErrorData{Message: err.Error()}) } - _, _, _ = evaluator.RunWithEval(h.ctx, sess.agent, evalCfg) - return - } - - if queued.goal.PersistMaxTurns > 0 { - sess.agent.SetMaxTurns(queued.goal.PersistMaxTurns) - } else { - sess.agent.SetMaxTurns(h.rt.Config.MaxTurns) - } - _, _ = sess.agent.Run(h.ctx, input) + }() } -// drain closes every session queue and waits for the FIFO goroutines to exit. +// drain waits for every accepted Runtime request. Session workers themselves +// remain Runtime-owned and are stopped by Runtime.Close. func (h *stdioHost) drain() { - h.mu.Lock() - sessions := make([]*stdioSession, 0, len(h.sessions)) - for _, sess := range h.sessions { - close(sess.queue) - sessions = append(sessions, sess) - } - h.mu.Unlock() - for _, sess := range sessions { - <-sess.done - } + h.wg.Wait() } // inputText flattens the text parts of an agent Input. diff --git a/core/runner/stdio_concurrency_test.go b/core/runner/stdio_concurrency_test.go index 28ff46d7..06a4b09d 100644 --- a/core/runner/stdio_concurrency_test.go +++ b/core/runner/stdio_concurrency_test.go @@ -63,24 +63,32 @@ func lastUserText(messages []agent.ChatMessage) string { } func newStdioTestSession(h *stdioHost, output *bytes.Buffer, id string, prov agent.Provider) { - if h.rt == nil { - h.rt = &AgentRuntime{Config: agent.Config{MaxTurns: 4}} + _ = id + if h.rt == nil || h.rt.ctx == nil { + initialized := newRuntimeStdioHost(output, prov) + h.rt = initialized.rt } +} + +func newRuntimeStdioHost(output *bytes.Buffer, prov agent.Provider) *stdioHost { + h := newStdioHost(context.Background(), nil, nil, output) + ctx, cancel := context.WithCancel(context.Background()) bus := eventbus.New[aop.Event]() bus.Subscribe(func(e aop.Event) { _ = h.emit(e) }) - sess := &stdioSession{ - id: id, - agent: agent.NewAgent(agent.Config{ - Provider: prov, - Model: "test", - Bus: bus, - SessionID: id, - }), - queue: make(chan stdioQueuedMessage, stdioQueueCapacity), - done: make(chan struct{}), + h.rt = &AgentRuntime{ + ctx: ctx, + cancel: cancel, + sessions: make(map[string]*sessionState), + requests: make(map[string]runtimeRequestState), + Config: agent.Config{ + Provider: prov, + Model: "test", + Bus: bus, + Logger: h.logger, + MaxTurns: 4, + }, } - h.sessions[id] = sess - go h.runSession(sess) + return h } func waitForCalls(t *testing.T, prov *stdioGateProvider, n int, what string) { @@ -100,6 +108,7 @@ func TestStdioSameSessionFIFOOrder(t *testing.T) { h := newTestStdioHost(&output) prov := newStdioGateProvider() newStdioTestSession(h, &output, "s1", prov) + defer h.rt.Close() for _, text := range []string{"first", "second", "third"} { h.accept(userMessageLine(t, "s1", text)) @@ -116,21 +125,17 @@ func TestStdioSameSessionFIFOOrder(t *testing.T) { func TestStdioSessionsRunConcurrently(t *testing.T) { var output bytes.Buffer - h := newTestStdioHost(&output) - prov1 := newStdioGateProvider() - prov2 := newStdioGateProvider() - newStdioTestSession(h, &output, "s1", prov1) - newStdioTestSession(h, &output, "s2", prov2) + prov := newStdioGateProvider() + h := newRuntimeStdioHost(&output, prov) + defer h.rt.Close() h.accept(userMessageLine(t, "s1", "one")) h.accept(userMessageLine(t, "s2", "two")) // Both sessions are mid-run at the same time: neither FIFO blocks the other. - waitForCalls(t, prov1, 1, "s1 run to start") - waitForCalls(t, prov2, 1, "s2 run to start") + waitForCalls(t, prov, 2, "both session runs to start") - close(prov1.gate) - close(prov2.gate) + close(prov.gate) h.drain() // Interleaved output must stay valid AOP: every line decodes, and both @@ -159,6 +164,7 @@ func TestStdioDrainWaitsForInFlightAndQueued(t *testing.T) { h := newTestStdioHost(&output) prov := newStdioGateProvider() newStdioTestSession(h, &output, "s1", prov) + defer h.rt.Close() h.accept(userMessageLine(t, "s1", "first")) h.accept(userMessageLine(t, "s1", "second")) diff --git a/core/runner/stdio_test.go b/core/runner/stdio_test.go index 2b9794cd..d7c86a17 100644 --- a/core/runner/stdio_test.go +++ b/core/runner/stdio_test.go @@ -14,7 +14,9 @@ import ( ) func newTestStdioHost(output io.Writer) *stdioHost { - return newStdioHost(context.Background(), nil, telemetry.NopLogger(), output) + host := newStdioHost(context.Background(), nil, telemetry.NopLogger(), output) + host.rt = &AgentRuntime{} + return host } func userMessageLine(t *testing.T, sessionID, text string) string { @@ -53,7 +55,7 @@ func TestStdioAcceptRejectsMalformedJSON(t *testing.T) { } } -func TestStdioAcceptIgnoresNonMessageEvents(t *testing.T) { +func TestStdioAcceptRejectsNonMessageEvents(t *testing.T) { var output bytes.Buffer h := newTestStdioHost(&output) @@ -70,8 +72,9 @@ func TestStdioAcceptIgnoresNonMessageEvents(t *testing.T) { } h.accept(string(line)) - if output.Len() != 0 { - t.Fatalf("unexpected output: %q", output.String()) + events := decodeAOPLines(t, &output) + if len(events) != 1 || events[0].Type != aop.TypeError { + t.Fatalf("events = %#v", events) } } @@ -102,46 +105,12 @@ func TestStdioAcceptRejectsNonUserMessage(t *testing.T) { } } -func TestStdioSessionQueueFullEmitsError(t *testing.T) { - var output bytes.Buffer - h := newTestStdioHost(&output) - sess := &stdioSession{ - id: "s1", - queue: make(chan stdioQueuedMessage, stdioQueueCapacity), - done: make(chan struct{}), - } - h.sessions["s1"] = sess - - line := userMessageLine(t, "s1", "hello") - for i := 0; i < stdioQueueCapacity; i++ { - h.accept(line) - } - if output.Len() != 0 { - t.Fatalf("unexpected output before overflow: %q", output.String()) - } - - h.accept(line) - events := decodeAOPLines(t, &output) - if len(events) != 1 || events[0].Type != aop.TypeError { - t.Fatalf("events = %#v", events) - } - data, err := aop.DecodeData[aop.ErrorData](events[0]) - if err != nil || !strings.Contains(data.Message, "queue full") { - t.Fatalf("error data = %+v, %v", data, err) - } -} - func TestStdioRunOneRejectsEmptyPrompt(t *testing.T) { var output bytes.Buffer - h := newTestStdioHost(&output) - sess := &stdioSession{id: "s1"} - - h.runOne(sess, stdioQueuedMessage{ - data: aop.MessageData{ - Role: "user", - Parts: []aop.MessagePart{{Type: aop.PartText, Text: " "}}, - }, - }) + h := newRuntimeStdioHost(&output, nil) + defer h.rt.Close() + h.accept(userMessageLine(t, "s1", " ")) + h.drain() events := decodeAOPLines(t, &output) if len(events) != 1 || events[0].Type != aop.TypeError { diff --git a/core/runner/subagent_handoff.go b/core/runner/subagent_handoff.go new file mode 100644 index 00000000..62f589eb --- /dev/null +++ b/core/runner/subagent_handoff.go @@ -0,0 +1,253 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/pkg/aop/x/delegation" + "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/ioa/protocols" +) + +// subscribeIOAHandoff records the two subagent lifecycle boundaries as native +// IOA handoff messages by observing the agent AOP bus: a child session.start +// carrying a delegation extension is the delegation, and the matching child +// session.end is the return. The return references the delegation message so +// other IOA implementations can reconstruct the thread without aiscan-specific +// APIs. +func subscribeIOAHandoff(bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) { + if bus == nil || client == nil || spaceName == "" { + return + } + if logger == nil { + logger = telemetry.NopLogger() + } + r := &ioaHandoffRecorder{ + client: client, + spaceName: spaceName, + logger: logger, + events: make(chan aop.Event, 256), + pending: make(map[string]*handoffState), + bySession: make(map[string]string), + } + bus.Subscribe(func(event aop.Event) { + select { + case r.events <- event: + default: + r.logger.Warnf("ioa handoff queue full, dropping %s", event.Type) + } + }) + go r.run() +} + +type handoffState struct { + msgID string + name string + typeName string + mode string + model string + parentSessionID string + toolCallID string + sessionID string + output string +} + +type ioaHandoffRecorder struct { + client protocols.ClientAPI + spaceName string + logger telemetry.Logger + events chan aop.Event + + mu sync.Mutex + spaceID string + pending map[string]*handoffState // parent tool call id -> state + bySession map[string]string // child session id -> parent tool call id +} + +func (r *ioaHandoffRecorder) run() { + for event := range r.events { + switch event.Type { + case aop.TypeSessionStart: + r.onSessionStart(event) + case aop.TypeMessage: + r.onMessage(event) + case aop.TypeSessionEnd: + r.onSessionEnd(event) + } + } +} + +func (r *ioaHandoffRecorder) onSessionStart(event aop.Event) { + data, err := aop.DecodeData[aop.SessionStartData](event) + if err != nil || data.ParentToolCallID == "" { + return + } + detail, ok, err := delegation.Get(event) + if err != nil || !ok { + return + } + state := &handoffState{ + name: detail.AgentName, + typeName: detail.AgentType, + mode: handoffMode(detail), + model: data.Model, + parentSessionID: data.ParentSessionID, + toolCallID: data.ParentToolCallID, + sessionID: event.SessionID, + } + title, message := formatSubAgentHandoff(true, state.name, "delegated", detail.Task, nil) + msgID, err := r.send("delegate", "delegated", state, title, message, "") + if err != nil { + r.logger.Warnf("record subagent handoff %s: %s", state.name, err) + return + } + state.msgID = msgID + r.mu.Lock() + r.pending[state.toolCallID] = state + r.bySession[state.sessionID] = state.toolCallID + r.mu.Unlock() +} + +func (r *ioaHandoffRecorder) onMessage(event aop.Event) { + r.mu.Lock() + toolCallID, ok := r.bySession[event.SessionID] + r.mu.Unlock() + if !ok { + return + } + data, err := aop.DecodeData[aop.MessageData](event) + if err != nil || data.Role != "assistant" { + return + } + var sb strings.Builder + for _, part := range data.Parts { + if part.Type == aop.PartText { + sb.WriteString(part.Text) + } + } + if sb.Len() == 0 { + return + } + r.mu.Lock() + if state := r.pending[toolCallID]; state != nil { + state.output = sb.String() + } + r.mu.Unlock() +} + +func (r *ioaHandoffRecorder) onSessionEnd(event aop.Event) { + r.mu.Lock() + toolCallID, ok := r.bySession[event.SessionID] + var state *handoffState + if ok { + state = r.pending[toolCallID] + delete(r.pending, toolCallID) + delete(r.bySession, event.SessionID) + } + r.mu.Unlock() + if state == nil { + return + } + data, err := aop.DecodeData[aop.SessionEndData](event) + if err != nil { + return + } + status := data.Stop + if status == string(agent.StopReasonError) { + status = "failed" + } + if status == "" { + status = "completed" + } + var runErr error + if data.Error != "" { + runErr = errors.New(data.Error) + } + title, message := formatSubAgentHandoff(false, state.name, status, state.output, runErr) + if _, err := r.send("return", status, state, title, message, state.msgID); err != nil { + r.logger.Warnf("record subagent return %s: %s", state.name, err) + } +} + +func (r *ioaHandoffRecorder) send(phase, status string, state *handoffState, title, message, refID string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + spaceID, err := r.resolveSpace(ctx) + if err != nil { + return "", err + } + body := protocols.SendMessage{ + ContentType: "handoff", + Content: map[string]any{ + "title": title, + "message": message, + }, + Meta: map[string]any{ + "subagent": map[string]any{ + "phase": phase, + "status": status, + "name": state.name, + "type": state.typeName, + "mode": state.mode, + "model": state.model, + "parent_session_id": state.parentSessionID, + "parent_tool_call_id": state.toolCallID, + "session_id": state.sessionID, + }, + }, + } + if refID != "" { + body.Refs = &protocols.Ref{Messages: []string{refID}} + } + msg, err := r.client.Send(ctx, spaceID, body) + if err != nil { + return "", err + } + return msg.ID, nil +} + +func (r *ioaHandoffRecorder) resolveSpace(ctx context.Context) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.spaceID != "" { + return r.spaceID, nil + } + space, err := r.client.Space(ctx, r.spaceName, "aiscan agent") + if err != nil { + return "", fmt.Errorf("resolve IOA space %q: %w", r.spaceName, err) + } + r.spaceID = space.ID + return r.spaceID, nil +} + +func handoffMode(detail delegation.DelegationDetail) string { + if detail.ContextMode == delegation.DelegationDetailContextModeFork { + return "fork" + } + if detail.RunMode == delegation.DelegationDetailRunModeForeground { + return "sync" + } + return "async" +} + +func formatSubAgentHandoff(delegate bool, name, status, text string, runErr error) (string, string) { + if delegate { + return fmt.Sprintf("Delegate to subagent %q", name), text + } + message := text + if runErr != nil { + if message == "" { + message = runErr.Error() + } else { + message = fmt.Sprintf("%s\n\nPartial output:\n%s", runErr, message) + } + } + return fmt.Sprintf("Return from subagent %q (%s)", name, status), message +} diff --git a/core/runner/subagent_handoff_test.go b/core/runner/subagent_handoff_test.go new file mode 100644 index 00000000..87350564 --- /dev/null +++ b/core/runner/subagent_handoff_test.go @@ -0,0 +1,165 @@ +package runner + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/pkg/aop/x/delegation" + "github.com/chainreactors/ioa/protocols" +) + +type handoffClient struct { + spaceCalls int + bodies []protocols.SendMessage +} + +func (c *handoffClient) NodeID() string { return "parent-node" } +func (c *handoffClient) RegisterNode(context.Context, string, string, map[string]any) (protocols.Node, error) { + return protocols.Node{ID: c.NodeID()}, nil +} +func (c *handoffClient) Space(context.Context, string, string, ...string) (protocols.SpaceInfo, error) { + c.spaceCalls++ + return protocols.SpaceInfo{ID: "space-1", Name: "test"}, nil +} +func (c *handoffClient) Send(_ context.Context, spaceID string, body protocols.SendMessage) (protocols.Message, error) { + c.bodies = append(c.bodies, body) + return protocols.Message{ID: "message-" + string(rune('0'+len(c.bodies))), SpaceID: spaceID}, nil +} +func (c *handoffClient) Read(context.Context, string, protocols.ReadOptions) ([]protocols.Message, error) { + return nil, nil +} + +func handoffEvent(t *testing.T, typ, sessionID, agentName string, data any) aop.Event { + t.Helper() + raw, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + return aop.Event{Type: typ, SessionID: sessionID, Agent: agentName, Data: raw} +} + +func TestIOAHandoffFromAOPBus(t *testing.T) { + client := &handoffClient{} + bus := eventbus.New[aop.Event]() + subscribeIOAHandoff(bus, client, "test", nil) + + start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ + Model: "test-model", + ParentSessionID: "parent-session", + ParentToolCallID: "spawn-1", + }) + if err := delegation.Set(&start, delegation.DelegationDetail{ + Task: "inspect target", + AgentName: "worker", + RunMode: delegation.DelegationDetailRunModeForeground, + }); err != nil { + t.Fatal(err) + } + bus.Emit(start) + + bus.Emit(handoffEvent(t, aop.TypeMessage, "child-session", "worker", aop.MessageData{ + MessageID: "m-1", Role: "assistant", + Parts: []aop.MessagePart{{Type: aop.PartText, Text: "inspection complete"}}, + })) + bus.Emit(handoffEvent(t, aop.TypeSessionEnd, "child-session", "worker", aop.SessionEndData{Stop: "completed"})) + + deadline := time.Now().Add(2 * time.Second) + for len(client.bodies) < 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if client.spaceCalls != 1 { + t.Fatalf("space calls = %d, want 1", client.spaceCalls) + } + if len(client.bodies) != 2 { + t.Fatalf("messages = %d, want 2", len(client.bodies)) + } + for i, body := range client.bodies { + if body.ContentType != "handoff" { + t.Fatalf("message %d content_type = %q", i, body.ContentType) + } + if len(body.Content) != 2 || body.Content["title"] == nil || body.Content["message"] == nil { + t.Fatalf("message %d content = %#v, want native handoff title/message", i, body.Content) + } + } + delegate, returned := client.bodies[0], client.bodies[1] + if delegate.Refs != nil { + t.Fatalf("delegate refs = %#v, want nil", delegate.Refs) + } + meta, ok := delegate.Meta["subagent"].(map[string]any) + if !ok { + t.Fatalf("delegate meta = %#v", delegate.Meta) + } + if meta["phase"] != "delegate" || meta["parent_tool_call_id"] != "spawn-1" || meta["mode"] != "sync" { + t.Fatalf("delegate meta = %#v", meta) + } + if delegate.Content["message"] != "inspect target" { + t.Fatalf("delegate message = %#v", delegate.Content["message"]) + } + retMeta, ok := returned.Meta["subagent"].(map[string]any) + if !ok || retMeta["phase"] != "return" || retMeta["status"] != "completed" { + t.Fatalf("return meta = %#v", returned.Meta) + } + if returned.Content["message"] != "inspection complete" { + t.Fatalf("return message = %#v", returned.Content["message"]) + } + refs := returned.Refs + if refs == nil || len(refs.Messages) != 1 || refs.Messages[0] != "message-1" { + t.Fatalf("return refs = %#v, want delegation message %q", refs, "message-1") + } +} + +func TestIOAHandoffFailedRun(t *testing.T) { + client := &handoffClient{} + bus := eventbus.New[aop.Event]() + subscribeIOAHandoff(bus, client, "test", nil) + + start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ + ParentSessionID: "parent-session", + ParentToolCallID: "spawn-2", + }) + if err := delegation.Set(&start, delegation.DelegationDetail{ + Task: "inspect target", + AgentName: "worker", + RunMode: delegation.DelegationDetailRunModeBackground, + }); err != nil { + t.Fatal(err) + } + bus.Emit(start) + bus.Emit(handoffEvent(t, aop.TypeSessionEnd, "child-session", "worker", aop.SessionEndData{Stop: "error", Error: "boom"})) + + deadline := time.Now().Add(2 * time.Second) + for len(client.bodies) < 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if len(client.bodies) != 2 { + t.Fatalf("messages = %d, want 2", len(client.bodies)) + } + retMeta, ok := client.bodies[1].Meta["subagent"].(map[string]any) + if !ok || retMeta["status"] != "failed" || retMeta["mode"] != "async" { + t.Fatalf("return meta = %#v", client.bodies[1].Meta) + } + if client.bodies[1].Content["message"] != "boom" { + t.Fatalf("return message = %#v", client.bodies[1].Content["message"]) + } +} + +func TestIOAHandoffIgnoresNonDelegationSessions(t *testing.T) { + client := &handoffClient{} + bus := eventbus.New[aop.Event]() + subscribeIOAHandoff(bus, client, "test", nil) + + bus.Emit(handoffEvent(t, aop.TypeSessionStart, "root-session", "aiscan", aop.SessionStartData{Model: "test-model"})) + bus.Emit(handoffEvent(t, aop.TypeSessionEnd, "root-session", "aiscan", aop.SessionEndData{Stop: "completed"})) + + deadline := time.Now().Add(200 * time.Millisecond) + for time.Now().Before(deadline) { + if len(client.bodies) > 0 { + t.Fatalf("unexpected handoff messages: %#v", client.bodies) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/core/transport/transport.go b/core/transport/transport.go new file mode 100644 index 00000000..a5cac729 --- /dev/null +++ b/core/transport/transport.go @@ -0,0 +1,28 @@ +package transport + +import ( + "context" + "io" + + cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/runner" + "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/pkg/webagent" +) + +// Run selects exactly one Agent transport. Session, provider and PTY state stay +// inside the single AgentRuntime created by that transport. +func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger, input io.Reader, output io.Writer, setInterrupt func(func() bool)) error { + selected, err := cfg.ResolveAgentTransport(option) + if err != nil { + return err + } + switch selected { + case cfg.AgentTransportWeb: + return webagent.RunWebSocket(ctx, option, logger) + case cfg.AgentTransportStdio: + return runner.RunStdio(ctx, option, logger, input, output) + default: + return runner.RunAgentMode(ctx, option, logger, setInterrupt) + } +} diff --git a/go.mod b/go.mod index ada5c967..e941677f 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/chainreactors/utils v0.0.0-20260707181750-8aa6ca296863 github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863 - github.com/chainreactors/utils/pty v0.0.0-20260720064434-8bb63d351632 + github.com/chainreactors/utils/pty v0.0.0-20260722063955-84fd9fbf150a github.com/chainreactors/zombie v1.3.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/glamour v0.8.0 @@ -312,3 +312,7 @@ require ( replace github.com/projectdiscovery/katana => github.com/chainreactors/katana v1.6.2-0.20260716115809-46dd3ac126d2 replace github.com/wasilibs/go-re2 => github.com/chainreactors/go-re2 v1.11.1-0.20260718064805-1d8511959320 + +replace github.com/chainreactors/tui/readline => ../tui/readline + +replace github.com/chainreactors/utils/pty => ../utils/pty diff --git a/go.sum b/go.sum index 807b2236..0a65ad7b 100644 --- a/go.sum +++ b/go.sum @@ -222,6 +222,8 @@ github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863 h1:u github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863/go.mod h1:S9lkpQ1I4wcBq0YEBde/UPmR061IPok3bLl7aPz6Vkk= github.com/chainreactors/utils/pty v0.0.0-20260720064434-8bb63d351632 h1:/Oo4JDpO5hxRPmEnj6o3dEjAykcNrmqyyfvSHSFpaXU= github.com/chainreactors/utils/pty v0.0.0-20260720064434-8bb63d351632/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4= +github.com/chainreactors/utils/pty v0.0.0-20260722063955-84fd9fbf150a h1:189konWQqDt1Zxy4CVbTdKlzXHpRZd2RbNs8EawY6C8= +github.com/chainreactors/utils/pty v0.0.0-20260722063955-84fd9fbf150a/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4= github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 h1:lvnDYEkatmZFHP5i321qQXK9L4vKRfso/uUfr5tOeC8= github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4/go.mod h1:zfz367PUmyaX6oAqV9SktVqyRXKlEh0sel9Wsq9dd2c= github.com/chainreactors/zombie v1.3.0 h1:gUIrV3syRlqGmNptAi5oKvrctxU/MybWMAVwIfd77SY= diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index a4d24dbb..c9db1ac0 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -6,6 +6,7 @@ import ( "sync" "github.com/chainreactors/aiscan/pkg/agent/inbox" + "github.com/chainreactors/aiscan/pkg/aop/x/delegation" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -20,7 +21,21 @@ type Agent struct { // Run executes the agent with an input and returns the result. // For one-shot usage, create an agent and call Run once. // For multi-turn, call Run repeatedly — message history accumulates. -func (a *Agent) Run(ctx context.Context, input Input) (*Result, error) { +type RunOption func(*Config) + +func InSession(sessionID, parentSessionID string) RunOption { + return func(cfg *Config) { + cfg.SessionID = sessionID + cfg.ParentSessionID = parentSessionID + cfg.emitter = cfg.emitter.scoped(sessionID, parentSessionID, "", nil) + } +} + +func WithRunMaxTurns(maxTurns int) RunOption { + return func(cfg *Config) { cfg.MaxTurns = maxTurns } +} + +func (a *Agent) Run(ctx context.Context, input Input, opts ...RunOption) (*Result, error) { userMsg, err := input.chatMessage() if err != nil { return nil, err @@ -34,6 +49,11 @@ func (a *Agent) Run(ctx context.Context, input Input) (*Result, error) { cfg := a.configSnapshot() cfg = cfg.init() + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } cfg.Messages = a.MessagesSnapshot() if cfg.Inbox == nil { cfg.Inbox = inbox.NewBuffered(SubInboxCapacity) @@ -51,6 +71,28 @@ func (a *Agent) Run(ctx context.Context, input Input) (*Result, error) { return result, runErr } +func (a *Agent) SessionID() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.Cfg.SessionID +} + +// BeginEvalSession opens the root bracket used by the evaluator coordinator. +func (a *Agent) BeginEvalSession() { + a.mu.Lock() + em, model := a.Cfg.emitter, a.Cfg.Model + a.mu.Unlock() + em.sessionStart(model) +} + +// EndEvalSession closes the evaluator coordinator's root bracket. +func (a *Agent) EndEvalSession(stop StopReason, turns int, usage Usage, err error) { + a.mu.Lock() + em := a.Cfg.emitter + a.mu.Unlock() + em.sessionEnd(stop, turns, usage, err) +} + // Continue resumes the agent without a new prompt (e.g. after tool results). func (a *Agent) Continue(ctx context.Context) (*Result, error) { if err := a.validateContinue(); err != nil { @@ -122,6 +164,16 @@ func (a *Agent) configSnapshot() Config { // Derive creates a new Agent with the same infrastructure (provider, tools, // model, logger) but clean state. Use for spawning independent agent tasks. func (a *Agent) Derive() *Agent { + return a.DeriveNamed(a.Cfg.AgentName) +} + +// DeriveNamed creates an isolated child agent and gives its AOP stream a +// distinct actor name while preserving the current session as its parent. +func (a *Agent) DeriveNamed(name string) *Agent { + return a.deriveNamed(name, "", nil) +} + +func (a *Agent) deriveNamed(name, parentToolCallID string, detail *delegation.DelegationDetail) *Agent { return NewAgent(Config{ Provider: a.Cfg.Provider, Fallbacks: a.Cfg.Fallbacks, @@ -134,19 +186,21 @@ func (a *Agent) Derive() *Agent { Temperature: a.Cfg.Temperature, CacheRetention: a.Cfg.CacheRetention, Bus: a.Cfg.Bus, - AgentName: a.Cfg.AgentName, + AgentName: name, ParentSessionID: a.Cfg.SessionID, + ParentToolCallID: parentToolCallID, + Delegation: detail, }) } // EmitStatus emits an AOP status event on the agent's session. Used by // out-of-kernel helpers (evaluator) so their events carry session/seq. -func (a *Agent) EmitStatus(state string, ext map[string]any) { +func (a *Agent) EmitStatus(state, namespace string, detail any) { a.mu.Lock() em := a.Cfg.emitter a.mu.Unlock() if em != nil { - em.status(state, ext) + em.status(state, namespace, detail) } } diff --git a/pkg/agent/aop_emit.go b/pkg/agent/aop_emit.go index 83d5c6af..c34041ab 100644 --- a/pkg/agent/aop_emit.go +++ b/pkg/agent/aop_emit.go @@ -8,98 +8,109 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/pkg/aop" -) - -// Status states emitted on the AOP status channel. Internal agent semantics -// (eval, compact, token budget, llm request summaries) ride here with detail -// in ext..* rather than as first-class event types. -const ( - StatusEvalStart = "eval_start" - StatusEvalEnd = "eval_end" - StatusEvalError = "eval_error" - StatusCompactStart = "compact_start" - StatusCompactEnd = "compact_end" - StatusCompactError = "compact_error" - StatusTokenBudgetWarning = "token_budget_warning" - StatusLLMRequest = "llm_request" + "github.com/chainreactors/aiscan/pkg/aop/x/delegation" ) // aopEmitter is the agent kernel's single event-emission path. Every event // leaves through it so seq numbering, message_id allocation, and session // tagging stay consistent per session. Safe for concurrent use. type aopEmitter struct { - bus *eventbus.Bus[aop.Event] - agentName string - sessionID string - parentSessionID string - seq atomic.Int64 - msgCounter atomic.Int64 + bus *eventbus.Bus[aop.Event] + agentName string + sessionID string + parentSessionID string + parentToolCallID string + delegation *delegation.DelegationDetail + state *emitState +} + +type emitState struct { + seq atomic.Int64 + messageSeq atomic.Int64 } -func newAOPEmitter(bus *eventbus.Bus[aop.Event], agentName, sessionID, parentSessionID string, msgCounter int64) *aopEmitter { +func newAOPEmitter(bus *eventbus.Bus[aop.Event], agentName, sessionID, parentSessionID, parentToolCallID string, detail *delegation.DelegationDetail, msgCounter int64) *aopEmitter { em := &aopEmitter{ - bus: bus, - agentName: agentName, - sessionID: sessionID, - parentSessionID: parentSessionID, + bus: bus, + agentName: agentName, + sessionID: sessionID, + parentSessionID: parentSessionID, + parentToolCallID: parentToolCallID, + delegation: detail, + state: &emitState{}, } - em.msgCounter.Store(msgCounter) + em.state.messageSeq.Store(msgCounter) return em } -func (e *aopEmitter) emit(typ string, data any, ext map[string]any) { +func (e *aopEmitter) scoped(sessionID, parentSessionID, parentToolCallID string, detail *delegation.DelegationDetail) *aopEmitter { + return &aopEmitter{bus: e.bus, agentName: e.agentName, sessionID: sessionID, parentSessionID: parentSessionID, parentToolCallID: parentToolCallID, delegation: detail, state: e.state} +} + +func (e *aopEmitter) event(typ string, data any) aop.Event { raw, err := json.Marshal(data) if err != nil { raw, _ = json.Marshal(map[string]string{"marshal_error": err.Error()}) } - ev := aop.Event{ + return aop.Event{ Type: typ, TS: time.Now().UTC().Format(time.RFC3339Nano), SessionID: e.sessionID, Agent: e.agentName, - Seq: int(e.seq.Add(1)), + Seq: int(e.state.seq.Add(1)), Data: raw, } - if len(ext) > 0 { - ev.Ext = map[string]any{e.agentName: ext} + +} + +func (e *aopEmitter) emit(typ string, data any) { + ev := e.event(typ, data) + e.bus.Emit(ev) +} + +func (e *aopEmitter) emitWithExt(typ string, data any, namespace string, ext any) { + ev := e.event(typ, data) + if err := aop.SetExt(&ev, namespace, ext); err != nil { + return } e.bus.Emit(ev) } func (e *aopEmitter) allocMessageID() string { - return fmt.Sprintf("m-%d", e.msgCounter.Add(1)) + return fmt.Sprintf("m-%d", e.state.messageSeq.Add(1)) } func (e *aopEmitter) messageCounter() int64 { - return e.msgCounter.Load() + return e.state.messageSeq.Load() } func (e *aopEmitter) sessionStart(model string) { - e.emit(aop.TypeSessionStart, aop.SessionStartData{ - Model: model, - ParentSessionID: e.parentSessionID, - }, nil) + data := aop.SessionStartData{ + Model: model, + ParentSessionID: e.parentSessionID, + ParentToolCallID: e.parentToolCallID, + } + if e.delegation != nil { + e.emitWithExt(aop.TypeSessionStart, data, delegation.NS, *e.delegation) + return + } + e.emit(aop.TypeSessionStart, data) } -func (e *aopEmitter) sessionEnd(stop StopReason, turns int, runErr error) { - data := aop.SessionEndData{Stop: string(stop), Turns: turns} +func (e *aopEmitter) sessionEnd(stop StopReason, turns int, usage Usage, runErr error) { + data := aop.SessionEndData{Stop: string(stop), Turns: turns, Usage: usageData(usage)} if runErr != nil { data.Error = runErr.Error() } - e.emit(aop.TypeSessionEnd, data, nil) + e.emit(aop.TypeSessionEnd, data) } func (e *aopEmitter) turnStart(turn int) { - e.emit(aop.TypeTurnStart, aop.TurnData{Turn: turn}, nil) + e.emit(aop.TypeTurnStart, aop.TurnData{Turn: turn}) } func (e *aopEmitter) turnEnd(turn int, totalUsage Usage, contextTokens int) { - e.emit(aop.TypeTurnEnd, aop.TurnData{Turn: turn}, map[string]any{ - "total_input_tokens": totalUsage.PromptTokens, - "total_output_tokens": totalUsage.CompletionTokens, - "total_tokens": totalUsage.TotalTokens, - "context_tokens": contextTokens, - }) + e.emit(aop.TypeTurnEnd, aop.TurnEndData{Turn: turn, Usage: usageData(totalUsage), ContextTokens: contextTokens}) } // message emits a complete message event, allocating a fresh message_id. @@ -114,7 +125,7 @@ func (e *aopEmitter) message(role string, parts []aop.MessagePart) string { // used when a streaming message's id was allocated before the retry loop so // deltas and the final message share it across retries. func (e *aopEmitter) messageWithID(id, role string, parts []aop.MessagePart) { - e.emit(aop.TypeMessage, aop.MessageData{MessageID: id, Role: role, Parts: parts}, nil) + e.emit(aop.TypeMessage, aop.MessageData{MessageID: id, Role: role, Parts: parts}) } func (e *aopEmitter) messageDelta(messageID string, partIndex int, partType, delta string) { @@ -123,25 +134,33 @@ func (e *aopEmitter) messageDelta(messageID string, partIndex int, partType, del PartIndex: partIndex, PartType: partType, Delta: delta, - }, nil) + }) } -func (e *aopEmitter) toolCall(toolCallID, toolName string, args any) { - e.emit(aop.TypeToolCall, aop.ToolCallData{ +func (e *aopEmitter) toolCall(toolCallID, toolName string, args any, workDir string) { + data := aop.ToolCallData{ ToolCallID: toolCallID, ToolName: toolName, Args: args, - }, nil) + WorkDir: workDir, + } + if detail, ok := delegationFromToolCall(toolName, args); ok { + e.emitWithExt(aop.TypeToolCall, data, delegation.NS, detail) + return + } + e.emit(aop.TypeToolCall, data) } -func (e *aopEmitter) toolResult(toolCallID, toolName string, content any, isError bool, durationMs int) { +func (e *aopEmitter) toolResult(toolCallID, toolName string, content, details any, terminate, isError bool, durationMs int) { e.emit(aop.TypeToolResult, aop.ToolResultData{ ToolCallID: toolCallID, ToolName: toolName, Content: content, + Details: details, + Terminate: terminate, IsError: isError, DurationMs: durationMs, - }, nil) + }) } func (e *aopEmitter) usage(u *Usage, model string) { @@ -155,15 +174,26 @@ func (e *aopEmitter) usage(u *Usage, model string) { CacheReadTokens: u.CacheReadTokens, CacheWriteTokens: u.CacheWriteTokens, Model: model, - }, nil) + }) } func (e *aopEmitter) errorEvt(err error, retryable bool) { - e.emit(aop.TypeError, aop.ErrorData{Message: err.Error(), Retryable: retryable}, nil) + e.emit(aop.TypeError, aop.ErrorData{Message: err.Error(), Retryable: retryable}) } -func (e *aopEmitter) status(state string, ext map[string]any) { - e.emit(aop.TypeStatus, aop.StatusData{State: state}, ext) +func (e *aopEmitter) status(state, namespace string, detail any) { + if detail == nil { + e.emit(aop.TypeStatus, aop.StatusData{State: state}) + return + } + e.emitWithExt(aop.TypeStatus, aop.StatusData{State: state}, namespace, detail) +} + +func usageData(u Usage) *aop.UsageData { + if u == (Usage{}) { + return nil + } + return &aop.UsageData{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens, TotalTokens: u.TotalTokens, CacheReadTokens: u.CacheReadTokens, CacheWriteTokens: u.CacheWriteTokens} } // messagePartsFromChat flattens a ChatMessage into AOP parts for echo/persist. diff --git a/pkg/agent/compact.go b/pkg/agent/compact.go index bca7dead..b1cff999 100644 --- a/pkg/agent/compact.go +++ b/pkg/agent/compact.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/chainreactors/aiscan/pkg/agent/truncate" + xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" ) const defaultKeepRecentTokens = 20000 @@ -72,18 +73,18 @@ func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, cfg.KeepRecentTokens = defaultKeepRecentTokens } - em.status(StatusCompactStart, nil) + em.status(xcompact.StateStart, "", nil) tokensBefore := estimateAllTokens(msgs) cutIdx := findCutPoint(msgs, cfg.KeepRecentTokens) if cutIdx <= 0 { - em.status(StatusCompactError, map[string]any{"compact_error": "context already fits"}) + em.status(xcompact.StateError, xcompact.NS, xcompact.Detail{Error: "context already fits"}) return nil, fmt.Errorf("nothing to compact (context already fits in %d tokens)", cfg.KeepRecentTokens) } summary, err := summarize(ctx, cfg.Provider, cfg.Model, msgs[:cutIdx], cfg.CustomInstructions) if err != nil { - em.status(StatusCompactError, map[string]any{"compact_error": err.Error()}) + em.status(xcompact.StateError, xcompact.NS, xcompact.Detail{Error: err.Error()}) return nil, fmt.Errorf("compact summarize: %w", err) } @@ -104,11 +105,7 @@ func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, a.state.Messages = newMsgs a.mu.Unlock() - em.status(StatusCompactEnd, map[string]any{ - "compact_tokens_before": result.TokensBefore, - "compact_tokens_after": result.TokensAfter, - "compact_kept_messages": result.KeptMessages, - }) + em.status(xcompact.StateEnd, xcompact.NS, xcompact.Detail{TokensBefore: result.TokensBefore, TokensAfter: result.TokensAfter, KeptMessages: result.KeptMessages}) return result, nil } diff --git a/pkg/agent/compact_test.go b/pkg/agent/compact_test.go index c7f077db..7ad625b5 100644 --- a/pkg/agent/compact_test.go +++ b/pkg/agent/compact_test.go @@ -19,8 +19,8 @@ func TestEstimateMessageTokens(t *testing.T) { want int }{ {"empty", ChatMessage{Role: "user"}, 0}, - {"short text", msg("user", "hello"), 2}, // 5 chars → ceil(5/4) = 2 - {"exact boundary", msg("user", "abcd"), 1}, // 4 chars → 1 + {"short text", msg("user", "hello"), 2}, // 5 chars → ceil(5/4) = 2 + {"exact boundary", msg("user", "abcd"), 1}, // 4 chars → 1 {"longer text", msg("user", "hello world, this is a test message"), 9}, // 35 chars → ceil(35/4) = 9 {"with tool calls", ChatMessage{ Role: "assistant", @@ -41,8 +41,8 @@ func TestEstimateMessageTokens(t *testing.T) { func TestEstimateAllTokens(t *testing.T) { msgs := []ChatMessage{ - msg("user", "hello"), // 2 - msg("assistant", "world"), // 2 + msg("user", "hello"), // 2 + msg("assistant", "world"), // 2 msg("user", "how are you"), // 3 } got := estimateAllTokens(msgs) diff --git a/pkg/agent/evaluator/evaluator.go b/pkg/agent/evaluator/evaluator.go index b7af74ba..734ab26f 100644 --- a/pkg/agent/evaluator/evaluator.go +++ b/pkg/agent/evaluator/evaluator.go @@ -66,10 +66,10 @@ func (e *Evaluator) Evaluate(ctx context.Context, goal, criteria string, message e.cfg.Logger.Warnf("evaluate attempt %d failed: %s", attempt+1, err) if attempt < e.cfg.MaxRetries-1 { select { - case <-time.After(time.Duration(attempt+1) * time.Second): - case <-ctx.Done(): - return nil, ctx.Err() - } + case <-time.After(time.Duration(attempt+1) * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } } } return nil, fmt.Errorf("evaluate failed after %d attempts: %w", e.cfg.MaxRetries, lastErr) diff --git a/pkg/agent/evaluator/loop.go b/pkg/agent/evaluator/loop.go index 2a5b9006..f1bf1e19 100644 --- a/pkg/agent/evaluator/loop.go +++ b/pkg/agent/evaluator/loop.go @@ -3,8 +3,12 @@ package evaluator import ( "context" "fmt" + "time" "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/agent/provider" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" + "github.com/chainreactors/aiscan/pkg/telemetry" ) const defaultMaxEvalRounds = 3 @@ -16,17 +20,53 @@ type EvalLoopConfig struct { Criteria string } +// NewLoopConfig builds an EvalLoopConfig around a fresh Evaluator. A +// maxRounds of zero (or negative) defers to RunWithEval's default. +func NewLoopConfig(p provider.Provider, model string, logger telemetry.Logger, goal, criteria string, maxRounds int) EvalLoopConfig { + return EvalLoopConfig{ + Evaluator: New(Config{Provider: p, Model: model, Logger: logger}), + MaxEvalRounds: maxRounds, + Goal: goal, + Criteria: criteria, + } +} + func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig) (*agent.Result, *Verdict, error) { if cfg.MaxEvalRounds <= 0 { cfg.MaxEvalRounds = defaultMaxEvalRounds } - - result, err := a.Run(ctx, agent.TextInput(cfg.Goal)) - if err != nil { - return result, nil, err - } - - for attempt := 0; attempt < cfg.MaxEvalRounds; attempt++ { + rootID := a.SessionID() + runID := fmt.Sprintf("%s-eval-%d", rootID, time.Now().UnixNano()) + a.BeginEvalSession() + var ( + totalUsage agent.Usage + totalTurns int + rootStop = agent.StopReasonStopped + rootErr error + ) + defer func() { a.EndEvalSession(rootStop, totalTurns, totalUsage, rootErr) }() + + input := agent.TextInput(cfg.Goal) + var lastVerdict *Verdict + for round := 1; round <= cfg.MaxEvalRounds; round++ { + result, err := a.Run(ctx, input, agent.InSession(fmt.Sprintf("%s-round-%d", runID, round), rootID)) + if result != nil { + totalTurns += result.Turns + totalUsage.PromptTokens += result.TotalUsage.PromptTokens + totalUsage.CompletionTokens += result.TotalUsage.CompletionTokens + totalUsage.TotalTokens += result.TotalUsage.TotalTokens + totalUsage.CacheReadTokens += result.TotalUsage.CacheReadTokens + totalUsage.CacheWriteTokens += result.TotalUsage.CacheWriteTokens + } + if err != nil { + rootErr = err + if ctx.Err() != nil { + rootStop = agent.StopReasonCanceled + } else { + rootStop = agent.StopReasonError + } + return result, lastVerdict, err + } // Judge whenever the run produced work worth evaluating. Only bail on a // hard error or a user cancel — a run that merely hit its turn or token // budget (Stopped/Budget) still did work the criteria should be checked @@ -34,10 +74,12 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig) (*agen // (The old gate skipped everything but Terminated/Completed, so a // turn-capped agent silently never got evaluated.) if result.Stop == agent.StopReasonError || result.Stop == agent.StopReasonCanceled { - return result, nil, nil + rootStop = result.Stop + rootErr = result.Err + return result, lastVerdict, result.Err } - a.EmitStatus(agent.StatusEvalStart, map[string]any{"eval_round": attempt}) + a.EmitStatus(xeval.StateStart, xeval.NS, xeval.Detail{Round: round, MaxRounds: cfg.MaxEvalRounds}) verdict, evalErr := cfg.Evaluator.Evaluate( ctx, cfg.Goal, cfg.Criteria, @@ -45,27 +87,27 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig) (*agen ) if evalErr != nil { - cfg.Evaluator.cfg.Logger.Warnf("evaluate error (round %d): %s", attempt+1, evalErr) - a.EmitStatus(agent.StatusEvalError, map[string]any{ - "eval_round": attempt, - "eval_error": evalErr.Error(), - }) - feedback := fmt.Sprintf("Evaluation could not determine if the task is complete. Original criteria: %s. Please review your work and continue if the goal is not yet fully achieved.", cfg.Criteria) - result, err = a.Run(ctx, agent.TextInput(feedback)) - if err != nil { - return result, nil, err + cfg.Evaluator.cfg.Logger.Warnf("evaluate error (round %d): %s", round, evalErr) + a.EmitStatus(xeval.StateError, xeval.NS, xeval.Detail{Round: round, MaxRounds: cfg.MaxEvalRounds, Error: evalErr.Error()}) + if round == cfg.MaxEvalRounds { + rootStop, rootErr = agent.StopReasonError, evalErr + return result, lastVerdict, evalErr } + feedback := fmt.Sprintf("Evaluation could not determine if the task is complete. Original criteria: %s. Please review your work and continue if the goal is not yet fully achieved.", cfg.Criteria) + input = agent.TextInput(feedback) continue } - a.EmitStatus(agent.StatusEvalEnd, map[string]any{ - "eval_round": attempt, - "eval_pass": verdict.Pass, - "eval_reason": verdict.Reason, - }) - cfg.Evaluator.cfg.Logger.Importantf("evaluate round %d: pass=%v inherit_context=%v reason=%q", attempt+1, verdict.Pass, verdict.InheritContext, verdict.Reason) + lastVerdict = verdict + a.EmitStatus(xeval.StateEnd, xeval.NS, xeval.Detail{Round: round, MaxRounds: cfg.MaxEvalRounds, Pass: verdict.Pass, Reason: verdict.Reason}) + cfg.Evaluator.cfg.Logger.Importantf("evaluate round %d: pass=%v inherit_context=%v reason=%q", round, verdict.Pass, verdict.InheritContext, verdict.Reason) if verdict.Pass { + rootStop = agent.StopReasonCompleted + return result, verdict, nil + } + if round == cfg.MaxEvalRounds { + rootStop = agent.StopReasonStopped return result, verdict, nil } @@ -75,7 +117,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig) (*agen } if !verdict.InheritContext { - cfg.Evaluator.cfg.Logger.Importantf("evaluate: compacting context (round %d)", attempt+1) + cfg.Evaluator.cfg.Logger.Importantf("evaluate: compacting context (round %d)", round) if _, err := a.Compact(ctx, agent.CompactConfig{ Provider: cfg.Evaluator.cfg.Provider, Model: cfg.Evaluator.cfg.Model, @@ -85,15 +127,8 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig) (*agen } } - cfg.Evaluator.cfg.Logger.Importantf("evaluate: injecting feedback (round %d): %s", attempt+1, feedback) - - result, err = a.Run(ctx, agent.TextInput(feedback)) - if err != nil { - cfg.Evaluator.cfg.Logger.Warnf("evaluate: agent.Run failed after feedback: %s", err) - return result, verdict, err - } - cfg.Evaluator.cfg.Logger.Importantf("evaluate: agent completed after feedback (round %d), stop=%s turns=%d", attempt+1, result.Stop, result.Turns) + cfg.Evaluator.cfg.Logger.Importantf("evaluate: injecting feedback (round %d): %s", round, feedback) + input = agent.TextInput(feedback) } - - return result, nil, nil + return nil, lastVerdict, nil } diff --git a/pkg/agent/inbound.go b/pkg/agent/inbound.go new file mode 100644 index 00000000..70ebbb4d --- /dev/null +++ b/pkg/agent/inbound.go @@ -0,0 +1,107 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/chainreactors/aiscan/pkg/aop" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" +) + +type InboundKind uint8 + +const ( + InboundUnknown InboundKind = iota + InboundUserMessage + InboundToolCall +) + +type Inbound struct { + Event aop.Event + Kind InboundKind + Message aop.MessageData + ToolCall aop.ToolCallData + RunControl aop.RunControl + Eval xeval.Control +} + +func Classify(event aop.Event) (Inbound, error) { + in := Inbound{Event: event} + if !event.Valid() { + return in, fmt.Errorf("invalid AOP envelope") + } + if control, ok, err := aop.Ext[aop.RunControl](event, aop.NSAOP); err != nil { + return in, err + } else if ok { + in.RunControl = control + } + if control, ok, err := xeval.Get(event); err != nil { + return in, err + } else if ok { + in.Eval = control + } + switch event.Type { + case aop.TypeMessage: + data, err := aop.DecodeData[aop.MessageData](event) + if err != nil { + return in, err + } + if data.Role != "user" { + return in, fmt.Errorf("inbound message role must be user") + } + in.Kind, in.Message = InboundUserMessage, data + case aop.TypeToolCall: + data, err := aop.DecodeData[aop.ToolCallData](event) + if err != nil { + return in, err + } + if data.ToolCallID == "" || data.ToolName == "" { + return in, fmt.Errorf("invalid inbound tool.call") + } + in.Kind, in.ToolCall = InboundToolCall, data + default: + return in, fmt.Errorf("unsupported inbound AOP type %q", event.Type) + } + return in, nil +} + +type EvalExecutor func(context.Context, *Agent, string, xeval.Control) (*Result, error) + +type InboundDependencies struct { + DefaultMaxTurns int + Eval EvalExecutor +} + +func ExecuteInbound(ctx context.Context, ag *Agent, in Inbound, deps InboundDependencies) (*Result, error) { + if in.Kind != InboundUserMessage { + return nil, fmt.Errorf("ExecuteInbound requires a user message") + } + input := InputFromAOPMessage(in.Message) + input.NoEcho = in.RunControl.NoEcho + prompt := strings.TrimSpace(inboundMessageText(in.Message)) + if prompt == "" { + return nil, fmt.Errorf("empty prompt") + } + if in.Eval.Criteria != "" { + if deps.Eval == nil { + return nil, fmt.Errorf("eval executor is not configured") + } + return deps.Eval(ctx, ag, prompt, in.Eval) + } + maxTurns := deps.DefaultMaxTurns + if in.RunControl.MaxTurns > 0 { + maxTurns = in.RunControl.MaxTurns + } + return ag.Run(ctx, input, WithRunMaxTurns(maxTurns)) +} + +func inboundMessageText(message aop.MessageData) string { + var parts []string + for _, part := range message.Parts { + if part.Type == aop.PartText && part.Text != "" { + parts = append(parts, part.Text) + } + } + return strings.Join(parts, "\n") +} diff --git a/pkg/agent/inbox/message.go b/pkg/agent/inbox/message.go index 08a5b306..6350eb25 100644 --- a/pkg/agent/inbox/message.go +++ b/pkg/agent/inbox/message.go @@ -11,10 +11,10 @@ import ( type Origin string const ( - OriginUser Origin = "user" - OriginPeer Origin = "peer" + OriginUser Origin = "user" + OriginPeer Origin = "peer" OriginSession Origin = "session" - OriginSystem Origin = "system" + OriginSystem Origin = "system" ) type Priority int diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 349f646d..2cfbcef3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -49,7 +49,7 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { if result.Err != nil && stop == StopReasonError { em.errorEvt(result.Err, isRetryableError(result.Err)) } - em.sessionEnd(stop, result.Turns, result.Err) + em.sessionEnd(stop, result.Turns, result.TotalUsage, result.Err) if cfg.OnRunEnd != nil { cfg.OnRunEnd(result) } @@ -122,10 +122,7 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { return end(result, result.Err, StopReasonBudget) } if transcript.totalUsage.TotalTokens >= cfg.TokenBudget*DefaultTokenBudgetWarningPct/100 { - em.status(StatusTokenBudgetWarning, map[string]any{ - "context_tokens": transcript.contextTokens, - "token_budget": cfg.TokenBudget, - }) + em.status(aop.StatusTokenBudgetWarning, aop.NSAOP, aop.BudgetWarning{ContextTokens: transcript.contextTokens, TokenBudget: cfg.TokenBudget}) cfg.Logger.Warnf("token budget warning: %d/%d (80%%)", transcript.totalUsage.TotalTokens, cfg.TokenBudget) } } @@ -260,7 +257,7 @@ func executeToolCalls(ctx context.Context, cfg Config, em *aopEmitter, assistant slots[i] = toolCallSlot{tc: tc} } for _, tc := range toolCalls { - em.toolCall(tc.ID, tc.Function.Name, parseToolArgs(tc.Function.Arguments)) + em.toolCall(tc.ID, tc.Function.Name, parseToolArgs(tc.Function.Arguments), "") } sem := make(chan struct{}, cfg.MaxParallelTools) @@ -281,7 +278,11 @@ func executeToolCalls(ctx context.Context, cfg Config, em *aopEmitter, assistant messages := make([]ChatMessage, 0, len(slots)) terminations := 0 for _, s := range slots { - em.toolResult(s.tc.ID, s.tc.Function.Name, s.result.eventContent(), s.result.isError, + var details any + if s.result.fullResult != nil { + details = s.result.fullResult.Details + } + em.toolResult(s.tc.ID, s.tc.Function.Name, s.result.eventContent(), details, s.result.flow == ToolFlowTerminate, s.result.isError, int(time.Since(s.startedAt).Milliseconds())) cfg.Logger.Debugf("[turn %d] tool_result name=%s bytes=%d", turn, s.tc.Function.Name, len(s.result.result)) toolMsg := toolResultToMessage(s.tc.ID, s.result) @@ -313,6 +314,7 @@ type toolExecution struct { func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall, turn int) toolExecution { toolCtx := output.ContextWithCallID(ctx, tc.ID) + toolCtx = withToolAgentConfig(toolCtx, cfg) execution := beforeToolCall(toolCtx, cfg, assistantMsg, tc) if execution.result == "" && !execution.isError { toolResult, execErr := cfg.Tools.ExecuteTool(toolCtx, tc.Function.Name, tc.Function.Arguments) @@ -326,7 +328,7 @@ func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc T if toolResult.Terminate { execution.flow = ToolFlowTerminate } - if toolResult.HasImages() { + if toolResult.HasImages() || toolResult.Details != nil || toolResult.Terminate { execution.fullResult = &toolResult } } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 9036bca6..e1c58aa4 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -409,7 +409,7 @@ func TestTokenBudgetWarning(t *testing.T) { return } data, err := aop.DecodeData[aop.StatusData](event) - if err == nil && data.State == StatusTokenBudgetWarning { + if err == nil && data.State == aop.StatusTokenBudgetWarning { sawWarning = true } }), @@ -584,13 +584,8 @@ func TestTurnEndEventCarriesUsage(t *testing.T) { turnEndUsage = &u } case aop.TypeTurnEnd: - if ext, ok := event.Ext["aiscan"].(map[string]any); ok { - switch v := ext["context_tokens"].(type) { - case int: - turnEndContext = v - case float64: - turnEndContext = int(v) - } + if data, err := aop.DecodeData[aop.TurnEndData](event); err == nil { + turnEndContext = data.ContextTokens } } }), diff --git a/pkg/agent/probe/conn.go b/pkg/agent/probe/conn.go index 060b6f92..9f17a76b 100644 --- a/pkg/agent/probe/conn.go +++ b/pkg/agent/probe/conn.go @@ -321,4 +321,3 @@ func firstCSV(s string) string { } return "" } - diff --git a/pkg/agent/probe/llm.go b/pkg/agent/probe/llm.go index b73f614c..0fc8c725 100644 --- a/pkg/agent/probe/llm.go +++ b/pkg/agent/probe/llm.go @@ -37,7 +37,6 @@ type LLMTestResult struct { // unreachable endpoint fails fast instead of hanging the settings dialog. const llmProbeTimeout = 30 * time.Second - // LLMModelsResult reports the model IDs discovered at the endpoint. ok=false // carries the reason (unsupported provider, auth failure, unreachable, …). type LLMModelsResult struct { diff --git a/pkg/agent/provider/cache_test.go b/pkg/agent/provider/cache_test.go index 00884f6a..4e7da411 100644 --- a/pkg/agent/provider/cache_test.go +++ b/pkg/agent/provider/cache_test.go @@ -902,8 +902,8 @@ func newAnthropicMockServer(t *testing.T, cache *cachedPrefix) *httptest.Server "usage": map[string]interface{}{ "input_tokens": promptTokens, "output_tokens": 0, - "cache_creation_input_tokens": cacheWrite, - "cache_read_input_tokens": cacheRead, + "cache_creation_input_tokens": cacheWrite, + "cache_read_input_tokens": cacheRead, }, }, })) @@ -948,8 +948,8 @@ func newAnthropicMockServer(t *testing.T, cache *cachedPrefix) *httptest.Server "usage": map[string]interface{}{ "input_tokens": promptTokens, "output_tokens": completionTokens, - "cache_creation_input_tokens": cacheWrite, - "cache_read_input_tokens": cacheRead, + "cache_creation_input_tokens": cacheWrite, + "cache_read_input_tokens": cacheRead, }, } w.Header().Set("Content-Type", "application/json") @@ -1004,8 +1004,8 @@ func newAnthropicToolMockServer(t *testing.T, cache *cachedPrefix) *httptest.Ser "usage": map[string]interface{}{ "input_tokens": promptTokens, "output_tokens": completionTokens, - "cache_creation_input_tokens": cacheWrite, - "cache_read_input_tokens": cacheRead, + "cache_creation_input_tokens": cacheWrite, + "cache_read_input_tokens": cacheRead, }, } w.Header().Set("Content-Type", "application/json") @@ -1021,8 +1021,8 @@ func newAnthropicToolMockServer(t *testing.T, cache *cachedPrefix) *httptest.Ser "usage": map[string]interface{}{ "input_tokens": promptTokens, "output_tokens": completionTokens, - "cache_creation_input_tokens": cacheWrite, - "cache_read_input_tokens": cacheRead, + "cache_creation_input_tokens": cacheWrite, + "cache_read_input_tokens": cacheRead, }, } w.Header().Set("Content-Type", "application/json") @@ -1096,7 +1096,7 @@ func TestAnthropicProtocol_ToolCallCache(t *testing.T) { // Turn 1: triggers tool_use req1 := &ChatCompletionRequest{ Messages: []ChatMessage{sys, user1}, - Tools: tools, CacheRetention: CacheShort, SessionID: "sess-tool", + Tools: tools, CacheRetention: CacheShort, SessionID: "sess-tool", } resp1, err := prov.ChatCompletion(ctx, req1) if err != nil { @@ -1111,7 +1111,7 @@ func TestAnthropicProtocol_ToolCallCache(t *testing.T) { req2 := &ChatCompletionRequest{ Messages: []ChatMessage{sys, user1, assistant1, toolResult, user2}, - Tools: tools, CacheRetention: CacheShort, SessionID: "sess-tool", + Tools: tools, CacheRetention: CacheShort, SessionID: "sess-tool", } resp2, err := prov.ChatCompletion(ctx, req2) if err != nil { @@ -1172,7 +1172,7 @@ func runMultiTurnScenario(t *testing.T, prov Provider, label string) { // Turn 1 req1 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1}, + Messages: []ChatMessage{sys, user1}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-mt", } resp1, err := prov.ChatCompletion(ctx, req1) @@ -1185,7 +1185,7 @@ func runMultiTurnScenario(t *testing.T, prov Provider, label string) { a1 := resp1.Choices[0].Message user2 := NewTextMessage("user", "What is 3+3?") req2 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1, a1, user2}, + Messages: []ChatMessage{sys, user1, a1, user2}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-mt", } resp2, err := prov.ChatCompletion(ctx, req2) @@ -1198,7 +1198,7 @@ func runMultiTurnScenario(t *testing.T, prov Provider, label string) { a2 := resp2.Choices[0].Message user3 := NewTextMessage("user", "What is 4+4?") req3 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1, a1, user2, a2, user3}, + Messages: []ChatMessage{sys, user1, a1, user2, a2, user3}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-mt", } resp3, err := prov.ChatCompletion(ctx, req3) @@ -1236,7 +1236,7 @@ func runStreamingMultiTurnScenario(t *testing.T, prov Provider, label string) { // Turn 1 user1 := NewTextMessage("user", "Hello") req1 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1}, + Messages: []ChatMessage{sys, user1}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-stream", Stream: true, } msg1, usage1 := collectStream(t, sp, ctx, req1) @@ -1245,7 +1245,7 @@ func runStreamingMultiTurnScenario(t *testing.T, prov Provider, label string) { a1 := NewTextMessage("assistant", msg1) user2 := NewTextMessage("user", "Goodbye") req2 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1, a1, user2}, + Messages: []ChatMessage{sys, user1, a1, user2}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-stream", Stream: true, } _, usage2 := collectStream(t, sp, ctx, req2) @@ -1279,7 +1279,7 @@ func runForkScenario(t *testing.T, prov Provider, label string) { // Parent's next request parentReq := &ChatCompletionRequest{ - Messages: append(append([]ChatMessage(nil), parentMsgs...), NewTextMessage("user", "parent question 4")), + Messages: append(append([]ChatMessage(nil), parentMsgs...), NewTextMessage("user", "parent question 4")), MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-fork", } parentResp, err := prov.ChatCompletion(ctx, parentReq) @@ -1289,7 +1289,7 @@ func runForkScenario(t *testing.T, prov Provider, label string) { // Fork child: inherits parent messages, new prompt childReq := &ChatCompletionRequest{ - Messages: append(append([]ChatMessage(nil), parentMsgs...), NewTextMessage("user", "forked child task")), + Messages: append(append([]ChatMessage(nil), parentMsgs...), NewTextMessage("user", "forked child task")), MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-fork", } childResp, err := prov.ChatCompletion(ctx, childReq) diff --git a/pkg/agent/retry.go b/pkg/agent/retry.go index c8d5bae5..de5f9807 100644 --- a/pkg/agent/retry.go +++ b/pkg/agent/retry.go @@ -209,12 +209,7 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm CacheRetention: cfg.CacheRetention, SessionID: cfg.SessionID, } - em.status(StatusLLMRequest, map[string]any{ - "llm_model": req.Model, - "llm_messages": len(req.Messages), - "llm_max_tokens": req.MaxTokens, - "llm_stream": cfg.Stream, - }) + em.status(aop.StatusLLMRequest, aop.NSAOP, aop.LLMRequest{Model: req.Model, Messages: len(req.Messages), MaxTokens: req.MaxTokens, Stream: cfg.Stream}) if cfg.Stream { if streaming, ok := cfg.Provider.(StreamingProvider); ok { return streamAssistantMessageWithUsage(ctx, streaming, req, em, cfg.Logger, turn, messageID) diff --git a/pkg/agent/retry_test.go b/pkg/agent/retry_test.go index cc092ada..bb392569 100644 --- a/pkg/agent/retry_test.go +++ b/pkg/agent/retry_test.go @@ -118,7 +118,7 @@ func TestStreamAssistantMessageReturnsContextErrorOnClosedCanceledStream(t *test _, _, err := streamAssistantMessageWithUsage(ctx, &scriptedProvider{}, &ChatCompletionRequest{Model: "test"}, - newAOPEmitter(eventbus.New[aop.Event](), "aiscan", "test-session", "", 0), + newAOPEmitter(eventbus.New[aop.Event](), "aiscan", "test-session", "", "", nil, 0), telemetry.NopLogger(), 1, "m-1", diff --git a/pkg/agent/subagent.go b/pkg/agent/subagent.go index db476650..71fb756b 100644 --- a/pkg/agent/subagent.go +++ b/pkg/agent/subagent.go @@ -10,8 +10,10 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/agent/inbox" + "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/tool" + "github.com/chainreactors/aiscan/pkg/agent/inbox" + "github.com/chainreactors/aiscan/pkg/aop/x/delegation" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -33,42 +35,18 @@ type subAgentInfo struct { } type SubAgentTool struct { - agent *Agent - inbox inbox.Inbox - messages func() []ChatMessage - resolve AgentTypeResolver - mu sync.Mutex - running map[string]*subAgentInfo + resolve AgentTypeResolver + mu sync.Mutex + running map[string]*subAgentInfo } -func NewSubAgentTool(agent *Agent, parentInbox inbox.Inbox, resolve AgentTypeResolver) *SubAgentTool { +func NewSubAgentTool(resolve AgentTypeResolver) *SubAgentTool { return &SubAgentTool{ - agent: agent, - inbox: parentInbox, resolve: resolve, running: make(map[string]*subAgentInfo), } } -func (t *SubAgentTool) SetMessages(fn func() []ChatMessage) { - t.messages = fn -} - -func (t *SubAgentTool) InitLogger(logger telemetry.Logger) { - if t == nil || t.agent == nil { - return - } - if logger == nil { - logger = telemetry.NopLogger() - } - t.agent.mu.Lock() - t.agent.Cfg.Logger = logger - if t.agent.Cfg.LoopScheduler != nil { - t.agent.Cfg.LoopScheduler.SetLogger(logger) - } - t.agent.mu.Unlock() -} - func (t *SubAgentTool) Name() string { return "subagent" } func (t *SubAgentTool) Description() string { @@ -125,6 +103,7 @@ func (t *SubAgentTool) create(ctx context.Context, prompt, typeName, name, mode, if strings.TrimSpace(prompt) == "" { return "", fmt.Errorf("prompt is required") } + task := prompt var resolved *AgentType if typeName != "" && t.resolve != nil { @@ -151,7 +130,16 @@ func (t *SubAgentTool) create(ctx context.Context, prompt, typeName, name, mode, } } - sub := t.agent.Derive() + parent, parentInbox, err := t.executionParent(ctx) + if err != nil { + return "", err + } + parentToolCallID := output.CallIDFromContext(ctx) + if parentToolCallID == "" { + return "", fmt.Errorf("subagent create requires the spawning tool call id") + } + detail := delegationDetail(task, typeName, name, mode) + sub := parent.deriveNamed(name, parentToolCallID, &detail) if resolved != nil { if resolved.FormattedPrompt != "" { prompt = resolved.FormattedPrompt + "\n\n" + prompt @@ -160,15 +148,60 @@ func (t *SubAgentTool) create(ctx context.Context, prompt, typeName, name, mode, sub.Cfg.Model = resolved.Model } } + if mode == "fork" { + sub.Cfg.Messages = truncateToLastCompleteBoundary(parent.Cfg.Messages) + sub.Cfg.SystemPrompt = parent.Cfg.SystemPrompt + } switch mode { case "sync": return t.runSync(ctx, sub, prompt, name, typeName, timeout) case "fork": - return t.runFork(ctx, sub, prompt, name, typeName) + return t.runFork(ctx, sub, prompt, name, typeName, parentInbox, parent.Cfg.Logger) default: - return t.runAsync(ctx, sub, prompt, name, typeName) + return t.runAsync(ctx, sub, prompt, name, typeName, parentInbox, parent.Cfg.Logger) + } +} + +func delegationFromToolCall(toolName string, args any) (delegation.DelegationDetail, bool) { + if toolName != "subagent" { + return delegation.DelegationDetail{}, false + } + values, ok := args.(map[string]any) + if !ok { + return delegation.DelegationDetail{}, false + } + if action, _ := values["action"].(string); action != "" && action != "create" { + return delegation.DelegationDetail{}, false + } + task, _ := values["prompt"].(string) + if strings.TrimSpace(task) == "" { + return delegation.DelegationDetail{}, false + } + name, _ := values["name"].(string) + typeName, _ := values["type"].(string) + mode, _ := values["mode"].(string) + return delegationDetail(task, typeName, name, mode), true +} + +func delegationDetail(task, typeName, name, mode string) delegation.DelegationDetail { + detail := delegation.DelegationDetail{ + Task: task, + AgentName: name, + AgentType: typeName, + } + switch mode { + case "sync": + detail.RunMode = delegation.DelegationDetailRunModeForeground + detail.ContextMode = delegation.DelegationDetailContextModeFresh + case "async": + detail.RunMode = delegation.DelegationDetailRunModeBackground + detail.ContextMode = delegation.DelegationDetailContextModeFresh + case "fork": + detail.RunMode = delegation.DelegationDetailRunModeBackground + detail.ContextMode = delegation.DelegationDetailContextModeFork } + return detail } func (t *SubAgentTool) runSync(ctx context.Context, sub *Agent, prompt, name, typeName, timeoutStr string) (string, error) { @@ -191,76 +224,84 @@ func (t *SubAgentTool) runSync(ctx context.Context, sub *Agent, prompt, name, ty } return fmt.Sprintf("subagent %q failed: %s", name, err), nil } - output := "" - if r != nil { - output = r.Output - } - return fmt.Sprintf("\n%s\n", name, typeName, output), nil + return fmt.Sprintf("\n%s\n", name, typeName, resultOutput(r)), nil } -func (t *SubAgentTool) runAsync(ctx context.Context, sub *Agent, prompt, name, typeName string) (string, error) { +func (t *SubAgentTool) runAsync(ctx context.Context, sub *Agent, prompt, name, typeName string, parentInbox inbox.Inbox, logger telemetry.Logger) (string, error) { subCtx, cancel := context.WithCancel(ctx) sub.Cfg.Inbox = inbox.NewBuffered(SubInboxCapacity) t.track(name, typeName, "async", cancel, sub.Cfg.Inbox) - producer := t.inbox.RegisterProducer("subagent:" + name) + producer := parentInbox.RegisterProducer("subagent:" + name) go func() { defer producer.Done() defer t.untrack(name) defer cancel() r, err := sub.Run(subCtx, TextInput(prompt)) - t.pushCompletion(name, typeName, r, err) + t.pushCompletion(parentInbox, logger, name, typeName, r, err) }() return fmt.Sprintf("Started subagent %q (mode=async, type=%s). Will notify on completion.", name, typeName), nil } -func (t *SubAgentTool) runFork(ctx context.Context, sub *Agent, directive, name, typeName string) (string, error) { - if t.messages != nil { - sub.Cfg.Messages = truncateToLastCompleteBoundary(t.messages()) - } - if t.agent.Cfg.SystemPrompt != "" { - sub.Cfg.SystemPrompt = t.agent.Cfg.SystemPrompt - } - +func (t *SubAgentTool) runFork(ctx context.Context, sub *Agent, directive, name, typeName string, parentInbox inbox.Inbox, logger telemetry.Logger) (string, error) { subCtx, cancel := context.WithCancel(ctx) sub.Cfg.Inbox = inbox.NewBuffered(SubInboxCapacity) t.track(name, typeName, "fork", cancel, sub.Cfg.Inbox) - producer := t.inbox.RegisterProducer("subagent:" + name) + producer := parentInbox.RegisterProducer("subagent:" + name) go func() { defer producer.Done() defer t.untrack(name) defer cancel() r, err := sub.Run(subCtx, TextInput(directive)) - t.pushCompletion(name, typeName, r, err) + t.pushCompletion(parentInbox, logger, name, typeName, r, err) }() return fmt.Sprintf("Started subagent %q (mode=fork, type=%s). Inherits parent context. Will notify on completion.", name, typeName), nil } -func (t *SubAgentTool) pushCompletion(name, typeName string, r *Result, err error) { - result := "" - if r != nil { - result = r.Output - } - status := "completed" - content := result - if err != nil { - status = "failed" - if result != "" { - content = fmt.Sprintf("Error: %s\n\nPartial output:\n%s", err, result) - } else { - content = fmt.Sprintf("Error: %s", err) - } - } +func (t *SubAgentTool) pushCompletion(parentInbox inbox.Inbox, logger telemetry.Logger, name, typeName string, r *Result, err error) { + status, content := subagentCompletion(r, err) msg := inbox.NewMessage(inbox.OriginSystem, "user", fmt.Sprintf("\n%s\n", name, typeName, status, content)) msg.Meta = map[string]any{"subagent": name, "type": typeName, "status": status} - if err := t.inbox.Push(msg); err != nil { - t.agent.Cfg.Logger.Warnf("inbox push subagent completion %s: %s", name, err) + if err := parentInbox.Push(msg); err != nil { + logger.Warnf("inbox push subagent completion %s: %s", name, err) + } +} + +func (t *SubAgentTool) executionParent(ctx context.Context) (*Agent, inbox.Inbox, error) { + cfg, ok := toolAgentConfig(ctx) + if !ok { + return nil, nil, fmt.Errorf("subagent create requires the executing agent context") + } + return NewAgent(cfg), cfg.Inbox, nil +} + +func resultOutput(r *Result) string { + if r == nil { + return "" + } + return r.Output +} + +func subagentCompletion(r *Result, err error) (string, string) { + result := resultOutput(r) + if err == nil { + return "completed", result + } + status := "failed" + if errors.Is(err, context.DeadlineExceeded) { + status = "timed_out" + } else if errors.Is(err, context.Canceled) { + status = "canceled" + } + if result != "" { + return status, fmt.Sprintf("Error: %s\n\nPartial output:\n%s", err, result) } + return status, fmt.Sprintf("Error: %s", err) } func (t *SubAgentTool) sendMessage(name, message string) (string, error) { diff --git a/pkg/agent/subagent_test.go b/pkg/agent/subagent_test.go new file mode 100644 index 00000000..c6752e85 --- /dev/null +++ b/pkg/agent/subagent_test.go @@ -0,0 +1,151 @@ +package agent + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/agent/inbox" + "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/pkg/aop/x/delegation" + "github.com/chainreactors/aiscan/pkg/commands" +) + +func TestSubAgentSyncReturnsResult(t *testing.T) { + parent := NewAgent(Config{ + Provider: &scriptedProvider{responses: []*ChatCompletionResponse{chatResponse(NewTextMessage("assistant", "child result"))}}, + Tools: commands.NewRegistry(), + Model: "test-model", + SessionID: "parent-session", + }) + tool := NewSubAgentTool(nil) + + ctx := output.ContextWithCallID(withToolAgentConfig(context.Background(), parent.Cfg), "spawn-sync") + result, err := tool.Execute(ctx, `{"action":"create","mode":"sync","name":"worker","prompt":"do the work"}`) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := result.Text(); got != ` +child result +` { + t.Fatalf("result = %q", got) + } +} + +func TestSubAgentCreateRequiresExecutingAgentContext(t *testing.T) { + tool := NewSubAgentTool(nil) + + _, err := tool.Execute(context.Background(), `{"action":"create","mode":"sync","name":"worker","prompt":"work"}`) + if err == nil || err.Error() != "subagent create requires the executing agent context" { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestSubAgentCreateRequiresSpawningToolCallID(t *testing.T) { + parent := NewAgent(Config{ + Provider: &scriptedProvider{}, + Tools: commands.NewRegistry(), + Model: "test-model", + }) + tool := NewSubAgentTool(nil) + + _, err := tool.Execute(withToolAgentConfig(context.Background(), parent.Cfg), `{"action":"create","mode":"sync","name":"worker","prompt":"work"}`) + if err == nil || err.Error() != "subagent create requires the spawning tool call id" { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestSubAgentUsesExecutingAgentContext(t *testing.T) { + provider := &scriptedProvider{responses: []*ChatCompletionResponse{ + chatResponse(NewTextMessage("assistant", "context result")), + }} + tool := NewSubAgentTool(nil) + + activeInbox := inbox.NewBuffered(DefaultInboxCapacity) + var mu sync.Mutex + var events []aop.Event + bus := eventbus.New[aop.Event]() + bus.Subscribe(func(event aop.Event) { + mu.Lock() + events = append(events, event) + mu.Unlock() + }) + active := NewAgent(Config{ + Provider: provider, + Tools: commands.NewRegistry(), + Model: "test-model", + SessionID: "active-session", + Inbox: activeInbox, + Bus: bus, + }) + + ctx := output.ContextWithCallID(withToolAgentConfig(context.Background(), active.Cfg), "spawn-context") + if _, err := tool.Execute(ctx, `{"action":"create","mode":"async","name":"context-worker","prompt":"work"}`); err != nil { + t.Fatalf("Execute() error = %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for activeInbox.Len() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + completed := activeInbox.Drain() + if len(completed) != 1 || completed[0].Meta["subagent"] != "context-worker" { + t.Fatalf("active inbox completion = %#v", completed) + } + + mu.Lock() + defer mu.Unlock() + for _, event := range events { + if event.Type != aop.TypeSessionStart || event.Agent != "context-worker" { + continue + } + data, err := aop.DecodeData[aop.SessionStartData](event) + if err != nil { + t.Fatalf("decode session.start: %v", err) + } + if data.ParentSessionID != "active-session" { + t.Fatalf("parent session = %q, want active-session", data.ParentSessionID) + } + if data.ParentToolCallID != "spawn-context" { + t.Fatalf("parent tool call = %q, want spawn-context", data.ParentToolCallID) + } + detail, ok, err := delegation.Get(event) + if err != nil || !ok { + t.Fatalf("delegation ext = %#v, %v, %v", detail, ok, err) + } + if detail.AgentName != "context-worker" || detail.Task != "work" || detail.RunMode != delegation.DelegationDetailRunModeBackground { + t.Fatalf("delegation detail = %#v", detail) + } + return + } + t.Fatal("missing child session.start event") +} + +func TestSubAgentToolCallCarriesDelegationExtension(t *testing.T) { + bus := eventbus.New[aop.Event]() + events := make(chan aop.Event, 1) + bus.Subscribe(func(event aop.Event) { events <- event }) + em := newAOPEmitter(bus, "aiscan", "parent-session", "", "", nil, 0) + + em.toolCall("spawn-1", "subagent", map[string]any{ + "action": "create", + "prompt": "inspect the repository", + "name": "explorer", + "type": "reviewer", + "mode": "fork", + }, "") + + event := <-events + detail, ok, err := delegation.Get(event) + if err != nil || !ok { + t.Fatalf("delegation ext = %#v, %v, %v", detail, ok, err) + } + if detail.Task != "inspect the repository" || detail.AgentName != "explorer" || detail.AgentType != "reviewer" { + t.Fatalf("delegation detail = %#v", detail) + } + if detail.RunMode != delegation.DelegationDetailRunModeBackground || detail.ContextMode != delegation.DelegationDetailContextModeFork { + t.Fatalf("delegation modes = %#v", detail) + } +} diff --git a/pkg/agent/tool_context.go b/pkg/agent/tool_context.go new file mode 100644 index 00000000..13ac7b49 --- /dev/null +++ b/pkg/agent/tool_context.go @@ -0,0 +1,14 @@ +package agent + +import "context" + +type toolAgentContextKey struct{} + +func withToolAgentConfig(ctx context.Context, cfg Config) context.Context { + return context.WithValue(ctx, toolAgentContextKey{}, cfg) +} + +func toolAgentConfig(ctx context.Context) (Config, bool) { + cfg, ok := ctx.Value(toolAgentContextKey{}).(Config) + return cfg, ok +} diff --git a/pkg/agent/types.go b/pkg/agent/types.go index 0ffc5794..63c84b8c 100644 --- a/pkg/agent/types.go +++ b/pkg/agent/types.go @@ -10,6 +10,7 @@ import ( "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/agent/provider" "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/pkg/aop/x/delegation" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -148,6 +149,8 @@ type Config struct { CacheRetention CacheRetention SessionID string ParentSessionID string + ParentToolCallID string + Delegation *delegation.DelegationDetail // AgentName tags emitted AOP events; defaults to "aiscan". AgentName string // MessageCounter seeds message_id allocation ("m-") when a session is @@ -159,20 +162,20 @@ type Config struct { // Builder methods — each returns a modified copy (Config is a value type). -func (c Config) WithProvider(p Provider) Config { c.Provider = p; return c } -func (c Config) WithTools(t tool.Executor) Config { c.Tools = t; return c } -func (c Config) WithModel(m string) Config { c.Model = m; return c } -func (c Config) WithSystemPrompt(s string) Config { c.SystemPrompt = s; return c } -func (c Config) WithMessages(msgs []ChatMessage) Config { c.Messages = msgs; return c } -func (c Config) WithStream(s bool) Config { c.Stream = s; return c } -func (c Config) WithInbox(ib inbox.Inbox) Config { c.Inbox = ib; return c } -func (c Config) WithLogger(l telemetry.Logger) Config { c.Logger = l; return c } +func (c Config) WithProvider(p Provider) Config { c.Provider = p; return c } +func (c Config) WithTools(t tool.Executor) Config { c.Tools = t; return c } +func (c Config) WithModel(m string) Config { c.Model = m; return c } +func (c Config) WithSystemPrompt(s string) Config { c.SystemPrompt = s; return c } +func (c Config) WithMessages(msgs []ChatMessage) Config { c.Messages = msgs; return c } +func (c Config) WithStream(s bool) Config { c.Stream = s; return c } +func (c Config) WithInbox(ib inbox.Inbox) Config { c.Inbox = ib; return c } +func (c Config) WithLogger(l telemetry.Logger) Config { c.Logger = l; return c } func (c Config) WithBus(b *eventbus.Bus[aop.Event]) Config { c.Bus = b; return c } -func (c Config) WithMaxTokens(n int) Config { c.MaxTokens = n; return c } -func (c Config) WithTemperature(t float64) Config { c.Temperature = &t; return c } -func (c Config) WithMaxRetries(n int) Config { c.MaxRetries = n; return c } -func (c Config) WithTokenBudget(n int) Config { c.TokenBudget = n; return c } -func (c Config) WithExpander(e *inbox.Expander) Config { c.Expander = e; return c } +func (c Config) WithMaxTokens(n int) Config { c.MaxTokens = n; return c } +func (c Config) WithTemperature(t float64) Config { c.Temperature = &t; return c } +func (c Config) WithMaxRetries(n int) Config { c.MaxRetries = n; return c } +func (c Config) WithTokenBudget(n int) Config { c.TokenBudget = n; return c } +func (c Config) WithExpander(e *inbox.Expander) Config { c.Expander = e; return c } func (c Config) WithTransformContext(fn TransformContextFunc) Config { c.TransformContext = fn return c @@ -217,7 +220,7 @@ func (c Config) init() Config { c.Bus = eventbus.New[aop.Event]() } if c.emitter == nil { - c.emitter = newAOPEmitter(c.Bus, c.AgentName, c.SessionID, c.ParentSessionID, c.MessageCounter) + c.emitter = newAOPEmitter(c.Bus, c.AgentName, c.SessionID, c.ParentSessionID, c.ParentToolCallID, c.Delegation, c.MessageCounter) } return c } diff --git a/pkg/aop/event.go b/pkg/aop/event.go index 2639ed89..219167fb 100644 --- a/pkg/aop/event.go +++ b/pkg/aop/event.go @@ -1,27 +1,26 @@ -// Package aop implements Agent Output Protocol — a language-neutral -// JSONL event protocol for AI coding agents. +// Package aop implements Agent Output Protocol — a language-neutral JSONL +// event protocol for AI coding agents. package aop import "encoding/json" -// Event is the AOP envelope. Every JSONL line is one Event. +// Event is the stable hand-written AOP envelope. Data and extension namespaces +// stay raw until a consumer explicitly decodes them, so bridges can forward +// unknown protocol additions without rewriting them. type Event struct { - Type string `json:"type"` - TS string `json:"ts"` - SessionID string `json:"session_id"` - Agent string `json:"agent"` - Seq int `json:"seq,omitempty"` - Data json.RawMessage `json:"data"` - Ext map[string]any `json:"ext,omitempty"` + Type string `json:"type"` + TS string `json:"ts"` + SessionID string `json:"session_id"` + Agent string `json:"agent"` + Seq int `json:"seq,omitempty"` + Data json.RawMessage `json:"data"` + Ext map[string]json.RawMessage `json:"ext,omitempty"` } -// Valid reports whether the required AOP envelope fields are present. func (e Event) Valid() bool { return e.Type != "" && e.TS != "" && e.SessionID != "" && e.Agent != "" && len(e.Data) > 0 } -// ── Core event types ──────────────────────────────────────────── - const ( TypeSessionStart = "session.start" TypeSessionEnd = "session.end" @@ -36,100 +35,22 @@ const ( TypeStatus = "status" ) -// ── Message parts ─────────────────────────────────────────────── - const ( PartText = "text" PartReasoning = "reasoning" PartImage = "image" ) -// ImageSource carries an image by local path or inline base64. URLs are -// not supported; exactly one of Path or Base64 is set. -type ImageSource struct { - Path string `json:"path,omitempty"` - Base64 string `json:"base64,omitempty"` - MediaType string `json:"media_type,omitempty"` -} - -type MessagePart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Image *ImageSource `json:"image,omitempty"` -} - -// ── Data payloads ─────────────────────────────────────────────── - -type SessionStartData struct { - Model string `json:"model,omitempty"` - ParentSessionID string `json:"parent_session_id,omitempty"` -} - -type SessionEndData struct { - Stop string `json:"stop"` - Turns int `json:"turns,omitempty"` - Error string `json:"error,omitempty"` -} - -// MessageData is a complete message. Assistant streaming produces a run of -// message.delta events followed by one authoritative message event; only the -// complete message is persisted. -type MessageData struct { - MessageID string `json:"message_id"` - Role string `json:"role"` - Parts []MessagePart `json:"parts"` -} - -// MessageDeltaData is an incremental fragment of one message part. -type MessageDeltaData struct { - MessageID string `json:"message_id"` - PartIndex int `json:"part_index"` - PartType string `json:"part_type"` - Delta string `json:"delta"` -} - -type ToolCallData struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Args any `json:"args"` -} +const ( + NSAOP = "aop" -type ToolResultData struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name,omitempty"` - // Content is a plain string, or a ToolResultContent when the tool - // returned images alongside text. - Content any `json:"content"` - IsError bool `json:"is_error,omitempty"` - DurationMs int `json:"duration_ms,omitempty"` -} + StatusTokenBudgetWarning = "token_budget_warning" + StatusLLMRequest = "llm_request" +) -// ToolResultContent is the structured Content variant for tool results that -// include images. +// ToolResultContent is the structured Content variant used when a tool result +// contains images alongside its text. type ToolResultContent struct { Content string `json:"content"` Images []ImageSource `json:"images,omitempty"` } - -type UsageData struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - CacheWriteTokens int `json:"cache_write_tokens,omitempty"` - Model string `json:"model,omitempty"` -} - -type TurnData struct { - Turn int `json:"turn"` -} - -type ErrorData struct { - Message string `json:"message"` - Code string `json:"code,omitempty"` - Retryable bool `json:"retryable,omitempty"` -} - -type StatusData struct { - State string `json:"state"` -} diff --git a/pkg/aop/ext.go b/pkg/aop/ext.go new file mode 100644 index 00000000..614abdb0 --- /dev/null +++ b/pkg/aop/ext.go @@ -0,0 +1,39 @@ +package aop + +import ( + "encoding/json" + "fmt" +) + +// Ext decodes one extension namespace without touching the others. +// +// Ext/SetExt are the codec primitives for the extension map. Business code +// must not call them directly — use the typed namespace packages under +// pkg/aop/x/ (or pkg/webproto for hub-owned namespaces) instead. +func Ext[T any](event Event, namespace string) (T, bool, error) { + var value T + raw, ok := event.Ext[namespace] + if !ok { + return value, false, nil + } + if err := json.Unmarshal(raw, &value); err != nil { + return value, true, fmt.Errorf("decode AOP ext.%s: %w", namespace, err) + } + return value, true, nil +} + +// SetExt serializes one namespace while preserving all other raw namespaces. +func SetExt[T any](event *Event, namespace string, value T) error { + if event == nil { + return fmt.Errorf("set AOP ext.%s on nil event", namespace) + } + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode AOP ext.%s: %w", namespace, err) + } + if event.Ext == nil { + event.Ext = make(map[string]json.RawMessage) + } + event.Ext[namespace] = raw + return nil +} diff --git a/pkg/aop/ext_types_gen.go b/pkg/aop/ext_types_gen.go new file mode 100644 index 00000000..88d8bd8c --- /dev/null +++ b/pkg/aop/ext_types_gen.go @@ -0,0 +1,43 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type BudgetWarning struct { + // ContextTokens corresponds to the JSON schema field "context_tokens". + ContextTokens int `json:"context_tokens"` + + // TokenBudget corresponds to the JSON schema field "token_budget". + TokenBudget int `json:"token_budget"` +} + +type LLMRequest struct { + // MaxTokens corresponds to the JSON schema field "max_tokens". + MaxTokens int `json:"max_tokens"` + + // Messages corresponds to the JSON schema field "messages". + Messages int `json:"messages"` + + // Model corresponds to the JSON schema field "model". + Model string `json:"model"` + + // Stream corresponds to the JSON schema field "stream". + Stream bool `json:"stream"` +} + +type MessageMeta struct { + // AgentID corresponds to the JSON schema field "agent_id". + AgentID string `json:"agent_id,omitempty,omitzero"` + + // Metadata corresponds to the JSON schema field "metadata". + Metadata MessageMetaMetadata `json:"metadata,omitempty,omitzero"` +} + +type MessageMetaMetadata map[string]interface{} + +type RunControl struct { + // MaxTurns corresponds to the JSON schema field "max_turns". + MaxTurns int `json:"max_turns,omitempty,omitzero"` + + // NoEcho corresponds to the JSON schema field "no_echo". + NoEcho bool `json:"no_echo,omitempty,omitzero"` +} diff --git a/pkg/aop/gen_error.go b/pkg/aop/gen_error.go new file mode 100644 index 00000000..45119908 --- /dev/null +++ b/pkg/aop/gen_error.go @@ -0,0 +1,14 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type ErrorData struct { + // Code corresponds to the JSON schema field "code". + Code string `json:"code,omitempty,omitzero"` + + // Message corresponds to the JSON schema field "message". + Message string `json:"message"` + + // Retryable corresponds to the JSON schema field "retryable". + Retryable bool `json:"retryable,omitempty,omitzero"` +} diff --git a/pkg/aop/gen_message.go b/pkg/aop/gen_message.go new file mode 100644 index 00000000..da18c98f --- /dev/null +++ b/pkg/aop/gen_message.go @@ -0,0 +1,36 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type ImageSource struct { + // Base64 corresponds to the JSON schema field "base64". + Base64 string `json:"base64,omitempty,omitzero"` + + // MediaType corresponds to the JSON schema field "media_type". + MediaType string `json:"media_type,omitempty,omitzero"` + + // Path corresponds to the JSON schema field "path". + Path string `json:"path,omitempty,omitzero"` +} + +type MessageData struct { + // MessageID corresponds to the JSON schema field "message_id". + MessageID string `json:"message_id"` + + // Parts corresponds to the JSON schema field "parts". + Parts []MessagePart `json:"parts"` + + // Role corresponds to the JSON schema field "role". + Role string `json:"role"` +} + +type MessagePart struct { + // Image corresponds to the JSON schema field "image". + Image *ImageSource `json:"image,omitempty,omitzero"` + + // Text corresponds to the JSON schema field "text". + Text string `json:"text,omitempty,omitzero"` + + // Type corresponds to the JSON schema field "type". + Type string `json:"type"` +} diff --git a/pkg/aop/gen_message_delta.go b/pkg/aop/gen_message_delta.go new file mode 100644 index 00000000..d4acf0ee --- /dev/null +++ b/pkg/aop/gen_message_delta.go @@ -0,0 +1,17 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type MessageDeltaData struct { + // Delta corresponds to the JSON schema field "delta". + Delta string `json:"delta"` + + // MessageID corresponds to the JSON schema field "message_id". + MessageID string `json:"message_id"` + + // PartIndex corresponds to the JSON schema field "part_index". + PartIndex int `json:"part_index"` + + // PartType corresponds to the JSON schema field "part_type". + PartType string `json:"part_type"` +} diff --git a/pkg/aop/gen_session_start.go b/pkg/aop/gen_session_start.go new file mode 100644 index 00000000..c177a173 --- /dev/null +++ b/pkg/aop/gen_session_start.go @@ -0,0 +1,14 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type SessionStartData struct { + // Model corresponds to the JSON schema field "model". + Model string `json:"model,omitempty,omitzero"` + + // ParentSessionID corresponds to the JSON schema field "parent_session_id". + ParentSessionID string `json:"parent_session_id,omitempty,omitzero"` + + // ParentToolCallID corresponds to the JSON schema field "parent_tool_call_id". + ParentToolCallID string `json:"parent_tool_call_id,omitempty,omitzero"` +} diff --git a/pkg/aop/gen_status.go b/pkg/aop/gen_status.go new file mode 100644 index 00000000..f2ddd2e8 --- /dev/null +++ b/pkg/aop/gen_status.go @@ -0,0 +1,8 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type StatusData struct { + // State corresponds to the JSON schema field "state". + State string `json:"state"` +} diff --git a/pkg/aop/gen_tool_call.go b/pkg/aop/gen_tool_call.go new file mode 100644 index 00000000..f5f87c64 --- /dev/null +++ b/pkg/aop/gen_tool_call.go @@ -0,0 +1,17 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type ToolCallData struct { + // Args corresponds to the JSON schema field "args". + Args any `json:"args"` + + // ToolCallID corresponds to the JSON schema field "tool_call_id". + ToolCallID string `json:"tool_call_id"` + + // ToolName corresponds to the JSON schema field "tool_name". + ToolName string `json:"tool_name"` + + // WorkDir corresponds to the JSON schema field "work_dir". + WorkDir string `json:"work_dir,omitempty,omitzero"` +} diff --git a/pkg/aop/gen_tool_result.go b/pkg/aop/gen_tool_result.go new file mode 100644 index 00000000..76cbc358 --- /dev/null +++ b/pkg/aop/gen_tool_result.go @@ -0,0 +1,26 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type ToolResultData struct { + // Content corresponds to the JSON schema field "content". + Content any `json:"content"` + + // Details corresponds to the JSON schema field "details". + Details any `json:"details,omitempty,omitzero"` + + // DurationMs corresponds to the JSON schema field "duration_ms". + DurationMs int `json:"duration_ms,omitempty,omitzero"` + + // IsError corresponds to the JSON schema field "is_error". + IsError bool `json:"is_error,omitempty,omitzero"` + + // Terminate corresponds to the JSON schema field "terminate". + Terminate bool `json:"terminate,omitempty,omitzero"` + + // ToolCallID corresponds to the JSON schema field "tool_call_id". + ToolCallID string `json:"tool_call_id"` + + // ToolName corresponds to the JSON schema field "tool_name". + ToolName string `json:"tool_name,omitempty,omitzero"` +} diff --git a/pkg/aop/gen_turn.go b/pkg/aop/gen_turn.go new file mode 100644 index 00000000..45a51ed4 --- /dev/null +++ b/pkg/aop/gen_turn.go @@ -0,0 +1,8 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type TurnData struct { + // Turn corresponds to the JSON schema field "turn". + Turn int `json:"turn"` +} diff --git a/pkg/aop/gen_usage_session.go b/pkg/aop/gen_usage_session.go new file mode 100644 index 00000000..27d3dbc6 --- /dev/null +++ b/pkg/aop/gen_usage_session.go @@ -0,0 +1,48 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package aop + +type SessionEndData struct { + // Error corresponds to the JSON schema field "error". + Error string `json:"error,omitempty,omitzero"` + + // Stop corresponds to the JSON schema field "stop". + Stop string `json:"stop"` + + // Turns corresponds to the JSON schema field "turns". + Turns int `json:"turns,omitempty,omitzero"` + + // Usage corresponds to the JSON schema field "usage". + Usage *UsageData `json:"usage,omitempty,omitzero"` +} + +type TurnEndData struct { + // ContextTokens corresponds to the JSON schema field "context_tokens". + ContextTokens int `json:"context_tokens,omitempty,omitzero"` + + // Turn corresponds to the JSON schema field "turn". + Turn int `json:"turn"` + + // Usage corresponds to the JSON schema field "usage". + Usage *UsageData `json:"usage,omitempty,omitzero"` +} + +type UsageData struct { + // CacheReadTokens corresponds to the JSON schema field "cache_read_tokens". + CacheReadTokens int `json:"cache_read_tokens,omitempty,omitzero"` + + // CacheWriteTokens corresponds to the JSON schema field "cache_write_tokens". + CacheWriteTokens int `json:"cache_write_tokens,omitempty,omitzero"` + + // InputTokens corresponds to the JSON schema field "input_tokens". + InputTokens int `json:"input_tokens"` + + // Model corresponds to the JSON schema field "model". + Model string `json:"model,omitempty,omitzero"` + + // OutputTokens corresponds to the JSON schema field "output_tokens". + OutputTokens int `json:"output_tokens"` + + // TotalTokens corresponds to the JSON schema field "total_tokens". + TotalTokens int `json:"total_tokens"` +} diff --git a/pkg/aop/generate.go b/pkg/aop/generate.go new file mode 100644 index 00000000..b2e0f2e9 --- /dev/null +++ b/pkg/aop/generate.go @@ -0,0 +1,12 @@ +package aop + +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_message.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/message.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_message_delta.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/message.delta.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_tool_call.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/tool.call.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_tool_result.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/tool.result.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_usage_session.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/usage.schema.json ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/session.end.schema.json ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/turn.end.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_session_start.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/session.start.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_turn.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/turn.start.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_error.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/error.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_status.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/status.schema.json +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o ext_types_gen.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/aop.schema.json diff --git a/pkg/aop/schema_test.go b/pkg/aop/schema_test.go index a57e46fc..5ebe8344 100644 --- a/pkg/aop/schema_test.go +++ b/pkg/aop/schema_test.go @@ -2,61 +2,116 @@ package aop import ( "bufio" + "bytes" "encoding/json" "os" "path/filepath" "testing" + + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" ) func TestCanonicalCyberUIFixtures(t *testing.T) { - path := filepath.Join("..", "..", "web", "frontend", "cyber-ui", "packages", "agent-protocol", "fixtures", "events.jsonl") - file, err := os.Open(path) + protocolRoot := filepath.Join("..", "..", "web", "frontend", "cyber-ui", "packages", "agent-protocol") + fixtureRoot := filepath.Join(protocolRoot, "fixtures") + compiler := jsonschema.NewCompiler() + err := filepath.WalkDir(filepath.Join(protocolRoot, "schema"), func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() || filepath.Ext(path) != ".json" { + return walkErr + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var document map[string]any + if err := json.Unmarshal(data, &document); err != nil { + return err + } + id, _ := document["$id"].(string) + if id != "" { + return compiler.AddResource(id, document) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile("https://github.com/chainreactors/cyber-ui/packages/agent-protocol/schema/aop.schema.json") + if err != nil { + t.Fatal(err) + } + delegationSchema, err := compiler.Compile("https://github.com/chainreactors/cyber-ui/packages/agent-protocol/schema/ext/delegation.schema.json") + if err != nil { + t.Fatal(err) + } + paths, err := filepath.Glob(filepath.Join(fixtureRoot, "*.jsonl")) if err != nil { t.Fatal(err) } - defer file.Close() var ( seenReasoningDelta = false seenComplete = false seenStatusExt = false ) - scanner := bufio.NewScanner(file) - for scanner.Scan() { - var event Event - if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { - t.Fatalf("decode fixture: %v", err) - } - if !event.Valid() { - t.Fatalf("invalid fixture envelope: %+v", event) + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) } - switch event.Type { - case TypeMessage: - var data MessageData - if err := json.Unmarshal(event.Data, &data); err != nil { - t.Fatal(err) + scanner := bufio.NewScanner(bytes.NewReader(content)) + for scanner.Scan() { + var document any + if err := json.Unmarshal(scanner.Bytes(), &document); err != nil { + t.Fatalf("decode fixture document %s: %v", path, err) } - if data.MessageID == "" || data.Role == "" || len(data.Parts) == 0 { - t.Fatalf("invalid message payload: %+v", data) + if err := schema.Validate(document); err != nil { + t.Fatalf("schema validation failed for %s: %v\n%s", path, err, scanner.Text()) } - seenComplete = true - case TypeMessageDelta: - var data MessageDeltaData - if err := json.Unmarshal(event.Data, &data); err != nil { - t.Fatal(err) + var event Event + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + t.Fatalf("decode fixture %s: %v", path, err) } - if data.MessageID == "" || data.PartType == "" { - t.Fatalf("invalid message.delta payload: %+v", data) + if !event.Valid() { + t.Fatalf("invalid fixture envelope in %s: %+v", path, event) } - seenReasoningDelta = seenReasoningDelta || data.PartType == PartReasoning - case TypeStatus: - if len(event.Ext) > 0 { - seenStatusExt = true + if raw, ok := event.Ext["delegation"]; ok { + var detail any + if err := json.Unmarshal(raw, &detail); err != nil { + t.Fatalf("decode delegation fixture %s: %v", path, err) + } + if err := delegationSchema.Validate(detail); err != nil { + t.Fatalf("delegation schema validation failed for %s: %v", path, err) + } + } + switch event.Type { + case TypeMessage: + var data MessageData + if err := json.Unmarshal(event.Data, &data); err != nil { + t.Fatal(err) + } + if data.MessageID == "" || data.Role == "" || len(data.Parts) == 0 { + t.Fatalf("invalid message payload: %+v", data) + } + seenComplete = true + case TypeMessageDelta: + var data MessageDeltaData + if err := json.Unmarshal(event.Data, &data); err != nil { + t.Fatal(err) + } + if data.MessageID == "" || data.PartType == "" { + t.Fatalf("invalid message.delta payload: %+v", data) + } + seenReasoningDelta = seenReasoningDelta || data.PartType == PartReasoning + case TypeStatus: + if len(event.Ext) > 0 { + seenStatusExt = true + } } } - } - if err := scanner.Err(); err != nil { - t.Fatal(err) + if err := scanner.Err(); err != nil { + t.Fatal(err) + } } if !seenReasoningDelta { t.Fatal("canonical fixtures do not cover reasoning deltas") diff --git a/pkg/aop/x/compact/compact.go b/pkg/aop/x/compact/compact.go new file mode 100644 index 00000000..aa0987ab --- /dev/null +++ b/pkg/aop/x/compact/compact.go @@ -0,0 +1,14 @@ +package compact + +import "github.com/chainreactors/aiscan/pkg/aop" + +const ( + NS = "compact" + + StateStart = "compact_start" + StateEnd = "compact_end" + StateError = "compact_error" +) + +func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) } +func SetDetail(event *aop.Event, value Detail) error { return aop.SetExt(event, NS, value) } diff --git a/pkg/aop/x/compact/generate.go b/pkg/aop/x/compact/generate.go new file mode 100644 index 00000000..e9d9e06e --- /dev/null +++ b/pkg/aop/x/compact/generate.go @@ -0,0 +1,3 @@ +package compact + +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p compact -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/compact.schema.json diff --git a/pkg/aop/x/compact/types_gen.go b/pkg/aop/x/compact/types_gen.go new file mode 100644 index 00000000..889fed9e --- /dev/null +++ b/pkg/aop/x/compact/types_gen.go @@ -0,0 +1,17 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package compact + +type Detail struct { + // Error corresponds to the JSON schema field "error". + Error string `json:"error,omitempty,omitzero"` + + // KeptMessages corresponds to the JSON schema field "kept_messages". + KeptMessages int `json:"kept_messages,omitempty,omitzero"` + + // TokensAfter corresponds to the JSON schema field "tokens_after". + TokensAfter int `json:"tokens_after,omitempty,omitzero"` + + // TokensBefore corresponds to the JSON schema field "tokens_before". + TokensBefore int `json:"tokens_before,omitempty,omitzero"` +} diff --git a/pkg/aop/x/delegation/delegation.go b/pkg/aop/x/delegation/delegation.go new file mode 100644 index 00000000..a4204828 --- /dev/null +++ b/pkg/aop/x/delegation/delegation.go @@ -0,0 +1,13 @@ +package delegation + +import "github.com/chainreactors/aiscan/pkg/aop" + +const NS = "delegation" + +func Get(event aop.Event) (DelegationDetail, bool, error) { + return aop.Ext[DelegationDetail](event, NS) +} + +func Set(event *aop.Event, value DelegationDetail) error { + return aop.SetExt(event, NS, value) +} diff --git a/pkg/aop/x/delegation/generate.go b/pkg/aop/x/delegation/generate.go new file mode 100644 index 00000000..eca41c3f --- /dev/null +++ b/pkg/aop/x/delegation/generate.go @@ -0,0 +1,3 @@ +package delegation + +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p delegation -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/delegation.schema.json diff --git a/pkg/aop/x/delegation/types_gen.go b/pkg/aop/x/delegation/types_gen.go new file mode 100644 index 00000000..11ef0a19 --- /dev/null +++ b/pkg/aop/x/delegation/types_gen.go @@ -0,0 +1,33 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package delegation + +type DelegationDetail struct { + // AgentID corresponds to the JSON schema field "agent_id". + AgentID string `json:"agent_id,omitempty,omitzero"` + + // AgentName corresponds to the JSON schema field "agent_name". + AgentName string `json:"agent_name,omitempty,omitzero"` + + // AgentType corresponds to the JSON schema field "agent_type". + AgentType string `json:"agent_type,omitempty,omitzero"` + + // ContextMode corresponds to the JSON schema field "context_mode". + ContextMode DelegationDetailContextMode `json:"context_mode,omitempty,omitzero"` + + // RunMode corresponds to the JSON schema field "run_mode". + RunMode DelegationDetailRunMode `json:"run_mode,omitempty,omitzero"` + + // Task corresponds to the JSON schema field "task". + Task string `json:"task,omitempty,omitzero"` +} + +type DelegationDetailContextMode string + +const DelegationDetailContextModeFork DelegationDetailContextMode = "fork" +const DelegationDetailContextModeFresh DelegationDetailContextMode = "fresh" + +type DelegationDetailRunMode string + +const DelegationDetailRunModeBackground DelegationDetailRunMode = "background" +const DelegationDetailRunModeForeground DelegationDetailRunMode = "foreground" diff --git a/pkg/aop/x/eval/eval.go b/pkg/aop/x/eval/eval.go new file mode 100644 index 00000000..237f9cbf --- /dev/null +++ b/pkg/aop/x/eval/eval.go @@ -0,0 +1,16 @@ +package eval + +import "github.com/chainreactors/aiscan/pkg/aop" + +const ( + NS = "eval" + + StateStart = "eval_start" + StateEnd = "eval_end" + StateError = "eval_error" +) + +func Get(event aop.Event) (Control, bool, error) { return aop.Ext[Control](event, NS) } +func Set(event *aop.Event, value Control) error { return aop.SetExt(event, NS, value) } +func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) } +func SetDetail(event *aop.Event, value Detail) error { return aop.SetExt(event, NS, value) } diff --git a/pkg/aop/x/eval/generate.go b/pkg/aop/x/eval/generate.go new file mode 100644 index 00000000..6f47cca3 --- /dev/null +++ b/pkg/aop/x/eval/generate.go @@ -0,0 +1,3 @@ +package eval + +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p eval -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/eval.schema.json diff --git a/pkg/aop/x/eval/types_gen.go b/pkg/aop/x/eval/types_gen.go new file mode 100644 index 00000000..df02ce02 --- /dev/null +++ b/pkg/aop/x/eval/types_gen.go @@ -0,0 +1,28 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package eval + +type Control struct { + // Criteria corresponds to the JSON schema field "criteria". + Criteria string `json:"criteria"` + + // MaxRounds corresponds to the JSON schema field "max_rounds". + MaxRounds int `json:"max_rounds,omitempty,omitzero"` +} + +type Detail struct { + // Error corresponds to the JSON schema field "error". + Error string `json:"error,omitempty,omitzero"` + + // MaxRounds corresponds to the JSON schema field "max_rounds". + MaxRounds int `json:"max_rounds"` + + // Pass corresponds to the JSON schema field "pass". + Pass bool `json:"pass,omitempty,omitzero"` + + // Reason corresponds to the JSON schema field "reason". + Reason string `json:"reason,omitempty,omitzero"` + + // Round corresponds to the JSON schema field "round". + Round int `json:"round"` +} diff --git a/pkg/aop/x/ioa/generate.go b/pkg/aop/x/ioa/generate.go new file mode 100644 index 00000000..0e58ca51 --- /dev/null +++ b/pkg/aop/x/ioa/generate.go @@ -0,0 +1,3 @@ +package ioa + +//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p ioa -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/ioa.schema.json diff --git a/pkg/aop/x/ioa/ioa.go b/pkg/aop/x/ioa/ioa.go new file mode 100644 index 00000000..a93a4a1b --- /dev/null +++ b/pkg/aop/x/ioa/ioa.go @@ -0,0 +1,10 @@ +package ioa + +import "github.com/chainreactors/aiscan/pkg/aop" + +const NS = "ioa" + +func GetDetail(event aop.Event) (HandoffDetail, bool, error) { + return aop.Ext[HandoffDetail](event, NS) +} +func SetDetail(event *aop.Event, value HandoffDetail) error { return aop.SetExt(event, NS, value) } diff --git a/pkg/aop/x/ioa/types_gen.go b/pkg/aop/x/ioa/types_gen.go new file mode 100644 index 00000000..64f0161e --- /dev/null +++ b/pkg/aop/x/ioa/types_gen.go @@ -0,0 +1,34 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package ioa + +type HandoffDetail struct { + // Mode corresponds to the JSON schema field "mode". + Mode *string `json:"mode,omitempty,omitzero"` + + // Model corresponds to the JSON schema field "model". + Model *string `json:"model,omitempty,omitzero"` + + // Name corresponds to the JSON schema field "name". + Name *string `json:"name,omitempty,omitzero"` + + // ParentSessionID corresponds to the JSON schema field "parent_session_id". + ParentSessionID *string `json:"parent_session_id,omitempty,omitzero"` + + // Phase corresponds to the JSON schema field "phase". + Phase *string `json:"phase,omitempty,omitzero"` + + // Refs corresponds to the JSON schema field "refs". + Refs []string `json:"refs,omitempty,omitzero"` + + // SessionID corresponds to the JSON schema field "session_id". + SessionID *string `json:"session_id,omitempty,omitzero"` + + // Status corresponds to the JSON schema field "status". + Status *string `json:"status,omitempty,omitzero"` + + // Type corresponds to the JSON schema field "type". + Type *string `json:"type,omitempty,omitzero"` + + AdditionalProperties interface{} `mapstructure:",remain"` +} diff --git a/pkg/commands/factory.go b/pkg/commands/factory.go index bd749af1..9847a050 100644 --- a/pkg/commands/factory.go +++ b/pkg/commands/factory.go @@ -17,6 +17,7 @@ type Deps struct { WorkDir string BashTimeout int SkillStore any + RunnerMode bool EngineSet any Resources any diff --git a/pkg/commands/list.go b/pkg/commands/list.go new file mode 100644 index 00000000..d9c8521c --- /dev/null +++ b/pkg/commands/list.go @@ -0,0 +1,93 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + coretool "github.com/chainreactors/aiscan/core/tool" + "github.com/chainreactors/aiscan/pkg/agent/truncate" +) + +// ListTool lists directory entries through the host filesystem API. +type ListTool struct { + workDir string +} + +func NewListTool(workDir string) *ListTool { + return &ListTool{workDir: workDir} +} + +func (t *ListTool) Name() string { return "ls" } + +func (t *ListTool) Description() string { + return "List a directory using native filesystem access. Use this instead of bash ls. Returns structured JSON with names, types, and sizes." +} + +type ListArgs struct { + Path string `json:"path,omitempty" jsonschema:"description=Directory path to list (absolute or relative to working directory; default: .)"` +} + +type ListEntry struct { + Name string `json:"name"` + IsDirectory bool `json:"isDirectory"` + Size int64 `json:"size"` +} + +type ListResult struct { + Path string `json:"path"` + Entries []ListEntry `json:"entries"` + Truncated bool `json:"truncated,omitempty"` +} + +func (t *ListTool) Definition() ToolDefinition { + return ToolDef("ls", t.Description(), ListArgs{}) +} + +func (t *ListTool) Execute(ctx context.Context, arguments string) (ToolResult, error) { + args, err := ParseArgs[ListArgs](arguments) + if err != nil { + return ToolResult{}, err + } + if args.Path == "" { + args.Path = "." + } + + workDir := coretool.WorkDirFromContext(ctx, t.workDir) + resolved := args.Path + if !filepath.IsAbs(resolved) { + resolved = filepath.Join(workDir, resolved) + } + entries, err := os.ReadDir(filepath.Clean(resolved)) + if err != nil { + return ToolResult{}, fmt.Errorf("list directory: %w", err) + } + + result := ListResult{Path: args.Path, Entries: make([]ListEntry, 0, min(len(entries), truncate.MaxGlobResults))} + if len(entries) > truncate.MaxGlobResults { + entries = entries[:truncate.MaxGlobResults] + result.Truncated = true + } + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return ToolResult{}, fmt.Errorf("stat %s: %w", entry.Name(), err) + } + result.Entries = append(result.Entries, ListEntry{ + Name: entry.Name(), + IsDirectory: entry.IsDir(), + Size: info.Size(), + }) + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return ToolResult{}, err + } + return ToolResult{ + Content: []ContentBlock{TextBlock(string(content))}, + Details: result, + }, nil +} diff --git a/pkg/commands/list_test.go b/pkg/commands/list_test.go new file mode 100644 index 00000000..30de2eed --- /dev/null +++ b/pkg/commands/list_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + coretool "github.com/chainreactors/aiscan/core/tool" +) + +func TestListToolReturnsStructuredDirectoryEntries(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("body"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil { + t.Fatal(err) + } + + result, err := NewListTool(dir).Execute(context.Background(), `{}`) + if err != nil { + t.Fatal(err) + } + var listing ListResult + if err := json.Unmarshal([]byte(result.Text()), &listing); err != nil { + t.Fatalf("result is not structured JSON: %v\n%s", err, result.Text()) + } + if listing.Path != "." || len(listing.Entries) != 2 { + t.Fatalf("listing = %+v", listing) + } + byName := map[string]ListEntry{} + for _, entry := range listing.Entries { + byName[entry.Name] = entry + } + if !byName["nested"].IsDirectory { + t.Fatalf("directory entry = %+v", byName["nested"]) + } + if byName["note.txt"].IsDirectory || byName["note.txt"].Size != 4 { + t.Fatalf("file entry = %+v", byName["note.txt"]) + } +} + +func TestListToolUsesInvocationWorkdir(t *testing.T) { + defaultDir := t.TempDir() + invocationDir := t.TempDir() + if err := os.WriteFile(filepath.Join(invocationDir, "proof.txt"), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + ctx := coretool.ContextWithInvocation(context.Background(), coretool.Invocation{WorkDir: invocationDir}) + + result, err := NewListTool(defaultDir).Execute(ctx, `{"path":"."}`) + if err != nil { + t.Fatal(err) + } + var listing ListResult + if err := json.Unmarshal([]byte(result.Text()), &listing); err != nil { + t.Fatal(err) + } + if len(listing.Entries) != 1 || listing.Entries[0].Name != "proof.txt" { + t.Fatalf("listing = %+v", listing) + } +} diff --git a/pkg/commands/register.go b/pkg/commands/register.go index f690ac15..0efd9294 100644 --- a/pkg/commands/register.go +++ b/pkg/commands/register.go @@ -24,6 +24,9 @@ func init() { } reg.RegisterTool(NewReadTool(workDir, readers...)) reg.RegisterTool(NewWriteTool(workDir)) + if deps.RunnerMode { + reg.RegisterTool(NewListTool(workDir)) + } reg.RegisterTool(NewGlobTool(workDir, globbers...)) bash := NewBashTool(workDir, timeout).WithScannerProxy(deps.ScannerProxy) diff --git a/pkg/commands/register_test.go b/pkg/commands/register_test.go new file mode 100644 index 00000000..072bc4eb --- /dev/null +++ b/pkg/commands/register_test.go @@ -0,0 +1,27 @@ +package commands + +import "testing" + +func closeRegistryTools(registry *CommandRegistry) { + for _, tool := range registry.Tools() { + if closer, ok := tool.(interface{ Close() }); ok { + closer.Close() + } + } +} + +func TestNativeListToolIsRunnerOnly(t *testing.T) { + regular := NewRegistry() + BuildGroup("core", &Deps{WorkDir: t.TempDir()}, regular) + defer closeRegistryTools(regular) + if _, ok := regular.GetTool("ls"); ok { + t.Fatal("regular agent must not expose the runner-only ls tool") + } + + runner := NewRegistry() + BuildGroup("core", &Deps{WorkDir: t.TempDir(), RunnerMode: true}, runner) + defer closeRegistryTools(runner) + if _, ok := runner.GetTool("ls"); !ok { + t.Fatal("runner mode must expose the native ls tool") + } +} diff --git a/pkg/tools/ioa/commands_test.go b/pkg/tools/ioa/commands_test.go index b8a84226..5c9ab8df 100644 --- a/pkg/tools/ioa/commands_test.go +++ b/pkg/tools/ioa/commands_test.go @@ -343,6 +343,24 @@ func TestSendCheckpointWithoutSpace(t *testing.T) { } } +func TestSendHandoff(t *testing.T) { + client := newFakeIOAClient(protocols.SpaceInfo{ID: knownSpaceID, Name: "my-space"}) + cmds := NewCommands(client, "tester", nil) + joinSpace(t, cmds) + + if err := findCmd(t, cmds, "ioa_send").Execute(context.Background(), []string{ + "handoff", "--title", "Delegate scan", "--message", "Inspect the target", + }); err != nil { + t.Fatalf("ioa_send handoff: %v", err) + } + if client.lastSentBody.ContentType != "handoff" { + t.Fatalf("content_type = %q, want handoff", client.lastSentBody.ContentType) + } + if client.lastSentBody.Content["title"] != "Delegate scan" || client.lastSentBody.Content["message"] != "Inspect the target" { + t.Fatalf("content = %#v", client.lastSentBody.Content) + } +} + // --------------------------------------------------------------------------- // ioa_read subcommands // --------------------------------------------------------------------------- diff --git a/pkg/tools/ioa/register.go b/pkg/tools/ioa/register.go index ebd59e8a..f5097b5e 100644 --- a/pkg/tools/ioa/register.go +++ b/pkg/tools/ioa/register.go @@ -5,6 +5,7 @@ import ( "github.com/chainreactors/ioa/protocols" _ "github.com/chainreactors/ioa/protocols/checkpoint" + _ "github.com/chainreactors/ioa/protocols/handoff" _ "github.com/chainreactors/ioa/protocols/swarm" ) diff --git a/pkg/tui/console.go b/pkg/tui/console.go index 738e5e44..c69ca0fb 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -80,7 +80,10 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf // while all agent output routes to the upper scroll region. var split *SplitTerminal isTerminal := t.Control != nil && t.Control.IsTerminal() - useSplit := isTerminal && splitEnabled(int(os.Stdout.Fd()), resolveRenderMode()) + // Split rendering owns the process' physical terminal and cannot be reused + // for remote terminals with independent dimensions and cursor state. + useSplit := isTerminal && isLocalAgentTerminal(t) && + splitEnabled(int(os.Stdout.Fd()), resolveRenderMode()) var consoleTerminal *rlterm.Terminal if useSplit { @@ -167,6 +170,15 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf return repl } +func isLocalAgentTerminal(t *rlterm.Terminal) bool { + if t == nil { + return false + } + in, inOK := t.In.(*os.File) + out, outOK := t.Out.(*os.File) + return inOK && outOK && in == os.Stdin && out == os.Stdout +} + // NewAgentConsoleWithWriters builds a non-interactive console that executes // individual REPL lines against the same command implementation as the TUI. func NewAgentConsoleWithWriters(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, stdout, stderr io.Writer) *AgentConsole { @@ -1407,6 +1419,9 @@ func (r *AgentConsole) executeBashDirect(ctx context.Context, cmdLine string) er } if text := result.Text(); text != "" { fmt.Fprint(r.stdout, text) + if !strings.HasSuffix(text, "\n") { + fmt.Fprintln(r.stdout) + } } return nil } diff --git a/pkg/tui/console_test.go b/pkg/tui/console_test.go index f56cb638..a34d4eb7 100644 --- a/pkg/tui/console_test.go +++ b/pkg/tui/console_test.go @@ -9,15 +9,31 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" "time" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/tui/readline/inputrc" + rlterm "github.com/chainreactors/tui/readline/terminal" ) +func TestIsLocalAgentTerminal(t *testing.T) { + local := rlterm.Local() + if !isLocalAgentTerminal(local) { + t.Fatal("local terminal should be eligible for split rendering") + } + + var output bytes.Buffer + remote := rlterm.Stream(bytes.NewReader(nil), &output, &output, rlterm.NewControl(true, 80, 24)) + if isLocalAgentTerminal(remote) { + t.Fatal("remote terminal must not use local split rendering") + } +} + type captureConsoleProvider struct { requests []*agent.ChatCompletionRequest } @@ -46,6 +62,25 @@ func TestAgentConsoleArgsForLineBangCommand(t *testing.T) { } } +func TestAgentConsoleBangCommandTerminatesOutputLine(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell assertion is unix-only") + } + var stdout, stderr bytes.Buffer + registry := commands.NewRegistry() + bash := commands.NewBashTool(t.TempDir(), 5) + defer bash.Close() + registry.RegisterTool(bash) + repl := NewAgentConsoleWithWriters(context.Background(), &cfg.Option{}, AppInfo{Commands: registry}, nil, &stdout, &stderr) + + if _, err := repl.ExecuteLineAndWait("!printf DIRECT_OK"); err != nil { + t.Fatalf("bang command: %v", err) + } + if got := stdout.String(); got != "DIRECT_OK\n" { + t.Fatalf("stdout = %q, want a prompt-safe trailing newline", got) + } +} + func TestAgentReadlineBackspaceBindings(t *testing.T) { repl := NewAgentConsole(context.Background(), &cfg.Option{}, AppInfo{}, nil, nil) shell := repl.console.Shell() diff --git a/pkg/tui/controller.go b/pkg/tui/controller.go index 8eb1eca8..3dbeb93e 100644 --- a/pkg/tui/controller.go +++ b/pkg/tui/controller.go @@ -96,21 +96,8 @@ func (c *interactiveRunController) buildRunFunc(prompt string) agentRunFunc { } eval := c.Eval return func(ctx context.Context) (*agent.Result, error) { - logger := eval.Logger - if logger == nil { - logger = telemetry.NopLogger() - } - cfg := evaluator.EvalLoopConfig{ - Evaluator: evaluator.New(evaluator.Config{ - Provider: eval.Provider, - Model: eval.Model, - Logger: logger, - }), - MaxEvalRounds: 3, - Goal: prompt, - Criteria: eval.Criteria, - } - result, _, err := evaluator.RunWithEval(ctx, c.session, cfg) + result, _, err := evaluator.RunWithEval(ctx, c.session, + evaluator.NewLoopConfig(eval.Provider, eval.Model, eval.Logger, prompt, eval.Criteria, 0)) return result, err } } diff --git a/pkg/tui/output.go b/pkg/tui/output.go index dc898980..2befce75 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -14,6 +14,8 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/truncate" "github.com/chainreactors/aiscan/pkg/aop" + xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/aiscan/pkg/util" "golang.org/x/term" ) @@ -370,7 +372,7 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { o.agentStart = time.Now() case aop.TypeTurnStart: - data, err := aop.DecodeData[aop.TurnData](event) + data, err := aop.DecodeData[aop.TurnEndData](event) if err != nil { return } @@ -526,15 +528,14 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { o.live.SetTurnUsage(usage) case aop.TypeTurnEnd: - data, err := aop.DecodeData[aop.TurnData](event) + data, err := aop.DecodeData[aop.TurnEndData](event) if err != nil { return } - ext := aopExt(event) - o.contextTokens = extInt(ext, "context_tokens") + o.contextTokens = data.ContextTokens o.live.FinishTurn(o.contextTokens) o.stopLive() - o.turnEnd(data.Turn, ext) + o.turnEnd(data.Turn) case aop.TypeSessionEnd: data, err := aop.DecodeData[aop.SessionEndData](event) if err != nil { @@ -547,24 +548,27 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { if err != nil { return } - ext := aopExt(event) switch data.State { - case agent.StatusEvalStart: + case xeval.StateStart: + detail, _, _ := xeval.GetDetail(event) o.stopLive() - o.evalStart(extInt(ext, "eval_round")) - case agent.StatusEvalEnd: + o.evalStart(detail.Round) + case xeval.StateEnd: + detail, _, _ := xeval.GetDetail(event) o.stopLive() - o.evalEnd(extInt(ext, "eval_round"), extBool(ext, "eval_pass"), extString(ext, "eval_reason")) - case agent.StatusEvalError: + o.evalEnd(detail.Round, detail.Pass, detail.Reason) + case xeval.StateError: + detail, _, _ := xeval.GetDetail(event) o.stopLive() - o.evalError(extInt(ext, "eval_round"), extString(ext, "eval_error")) - case agent.StatusCompactStart: + o.evalError(detail.Round, detail.Error) + case xcompact.StateStart: o.stopLive() o.compactStart() - case agent.StatusCompactEnd: + case xcompact.StateEnd: + detail, _, _ := xcompact.GetDetail(event) o.stopLive() - o.compactEnd(extInt(ext, "compact_tokens_before"), extInt(ext, "compact_tokens_after"), extInt(ext, "compact_kept_messages")) - case agent.StatusCompactError: + o.compactEnd(detail.TokensBefore, detail.TokensAfter, detail.KeptMessages) + case xcompact.StateError: o.stopLive() o.compactError() } @@ -732,7 +736,7 @@ func (o *AgentOutput) coloredElapsed(started time.Time) string { // Turn / agent end — stats come from events, not accumulated // --------------------------------------------------------------------------- -func (o *AgentOutput) turnEnd(turn int, ext map[string]any) { +func (o *AgentOutput) turnEnd(turn int) { if o.verbosity < 0 { return } @@ -967,52 +971,6 @@ func (o *AgentOutput) renderUserIntent(body string) { fmt.Fprintln(w, o.dim("╰─")) } -// --------------------------------------------------------------------------- -// AOP event helpers -// --------------------------------------------------------------------------- - -// aopExt unwraps the single ext block the emitter nests detail -// under. The TUI doesn't care which agent name produced the event. -func aopExt(event aop.Event) map[string]any { - for _, v := range event.Ext { - if m, ok := v.(map[string]any); ok { - return m - } - } - return nil -} - -// extInt reads an int from an ext map, tolerating the float64 widening a JSON -// roundtrip applies (in-memory bus events carry Go ints). -func extInt(ext map[string]any, key string) int { - switch v := ext[key].(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case json.Number: - n, _ := v.Int64() - return int(n) - } - return 0 -} - -func extString(ext map[string]any, key string) string { - if s, ok := ext[key].(string); ok { - return s - } - return "" -} - -func extBool(ext map[string]any, key string) bool { - if b, ok := ext[key].(bool); ok { - return b - } - return false -} - // marshalToolArgs normalizes a tool.call Args payload (raw JSON string or a // decoded value) into the JSON string the argument summarizers expect. func marshalToolArgs(args any) string { diff --git a/pkg/tui/output_test.go b/pkg/tui/output_test.go index b69d7ab1..57acfc02 100644 --- a/pkg/tui/output_test.go +++ b/pkg/tui/output_test.go @@ -11,8 +11,8 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/aop" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" ) type syncedBuffer struct { @@ -48,26 +48,20 @@ func stripANSI(s string) string { // AOP event builders // --------------------------------------------------------------------------- -func aopTestEvent(typ string, data any, ext map[string]any) aop.Event { +func aopTestEvent(typ string, data any) aop.Event { raw, err := json.Marshal(data) if err != nil { panic(err) } - ev := aop.Event{Type: typ, Data: raw} - if ext != nil { - ev.Ext = map[string]any{"aiscan": ext} - } - return ev + return aop.Event{Type: typ, Data: raw} } func turnStartEvent(turn int) aop.Event { - return aopTestEvent(aop.TypeTurnStart, aop.TurnData{Turn: turn}, nil) + return aopTestEvent(aop.TypeTurnStart, aop.TurnData{Turn: turn}) } func turnEndEvent(turn, contextTokens int) aop.Event { - return aopTestEvent(aop.TypeTurnEnd, aop.TurnData{Turn: turn}, map[string]any{ - "context_tokens": contextTokens, - }) + return aopTestEvent(aop.TypeTurnEnd, aop.TurnEndData{Turn: turn, ContextTokens: contextTokens}) } func textDeltaEvent(messageID, delta string) aop.Event { @@ -75,7 +69,7 @@ func textDeltaEvent(messageID, delta string) aop.Event { MessageID: messageID, PartType: aop.PartText, Delta: delta, - }, nil) + }) } func reasoningDeltaEvent(messageID, delta string) aop.Event { @@ -83,7 +77,7 @@ func reasoningDeltaEvent(messageID, delta string) aop.Event { MessageID: messageID, PartType: aop.PartReasoning, Delta: delta, - }, nil) + }) } func messageEvent(messageID, role string, parts ...aop.MessagePart) aop.Event { @@ -91,7 +85,7 @@ func messageEvent(messageID, role string, parts ...aop.MessagePart) aop.Event { MessageID: messageID, Role: role, Parts: parts, - }, nil) + }) } func toolCallEvent(id, name, args string) aop.Event { @@ -99,7 +93,7 @@ func toolCallEvent(id, name, args string) aop.Event { ToolCallID: id, ToolName: name, Args: args, - }, nil) + }) } func toolResultEvent(id, name, result string, isError bool) aop.Event { @@ -108,7 +102,7 @@ func toolResultEvent(id, name, result string, isError bool) aop.Event { ToolName: name, Content: result, IsError: isError, - }, nil) + }) } func usageEvent(input, outputTok, total int) aop.Event { @@ -116,11 +110,7 @@ func usageEvent(input, outputTok, total int) aop.Event { InputTokens: input, OutputTokens: outputTok, TotalTokens: total, - }, nil) -} - -func statusEvent(state string, ext map[string]any) aop.Event { - return aopTestEvent(aop.TypeStatus, aop.StatusData{State: state}, ext) + }) } func testOutput(stderr io.Writer, verbosity int, debug bool) *AgentOutput { @@ -620,9 +610,9 @@ func TestEvalEndRendering(t *testing.T) { var stderr syncedBuffer o := testOutput(&stderr, 1, false) - o.HandleEvent(statusEvent(agent.StatusEvalEnd, map[string]any{ - "eval_round": 0, "eval_pass": true, "eval_reason": "all checks passed", - })) + passed := aopTestEvent(aop.TypeStatus, aop.StatusData{State: xeval.StateEnd}) + _ = xeval.SetDetail(&passed, xeval.Detail{Round: 0, Pass: true, Reason: "all checks passed"}) + o.HandleEvent(passed) got := stripANSI(stderr.String()) if !strings.Contains(got, "✓") || !strings.Contains(got, "eval") || !strings.Contains(got, "pass") { t.Fatalf("eval pass missing expected markers: %q", got) @@ -632,9 +622,9 @@ func TestEvalEndRendering(t *testing.T) { } stderr.Reset() - o.HandleEvent(statusEvent(agent.StatusEvalEnd, map[string]any{ - "eval_round": 1, "eval_pass": false, "eval_reason": "port 443 not scanned", - })) + failed := aopTestEvent(aop.TypeStatus, aop.StatusData{State: xeval.StateEnd}) + _ = xeval.SetDetail(&failed, xeval.Detail{Round: 1, Pass: false, Reason: "port 443 not scanned"}) + o.HandleEvent(failed) got = stripANSI(stderr.String()) if !strings.Contains(got, "⟳") || !strings.Contains(got, "fail") { t.Fatalf("eval fail missing expected markers: %q", got) diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 4037eac9..dba2d0ce 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -11,6 +11,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/aop" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" @@ -120,6 +121,7 @@ type AgentPool struct { sco SCOStore ptyMu sync.RWMutex ptySubs map[string]chan pty.Frame + ptyAgents map[string]string ptyDrops atomic.Int64 allowedOrigins []string upgrader websocket.Upgrader @@ -130,6 +132,7 @@ func NewAgentPool(hub *Hub, allowedOrigins ...string) *AgentPool { agents: make(map[string]*remoteAgent), hub: hub, ptySubs: make(map[string]chan pty.Frame), + ptyAgents: make(map[string]string), upgrader: buildUpgrader(allowedOrigins), allowedOrigins: allowedOrigins, } @@ -169,6 +172,7 @@ func (p *AgentPool) register(a *remoteAgent) { if old != nil && old != a { _ = old.conn.Close() } + p.rebindPTY(a) } func (p *AgentPool) unregister(a *remoteAgent) { @@ -176,10 +180,14 @@ func (p *AgentPool) unregister(a *remoteAgent) { // Only vacate the slot if it still holds THIS instance. After a reconnect the // slot was already reassigned to the replacement under the same key; the old // instance tearing down must not evict its successor. - if p.agents[a.id] == a { + removed := p.agents[a.id] == a + if removed { delete(p.agents, a.id) } p.mu.Unlock() + if removed { + p.notifyPTY(a.id, pty.Frame{Type: pty.FrameDetached}) + } a.mu.Lock() for _, ch := range a.tasks { close(ch) @@ -286,14 +294,18 @@ func BuildUserMessageEvent(sessionID, messageID, text string, goal webproto.Goal Role: "user", Parts: []aop.MessagePart{{Type: aop.PartText, Text: text}}, }) - return aop.Event{ + event := aop.Event{ Type: aop.TypeMessage, TS: time.Now().UTC().Format(time.RFC3339Nano), SessionID: sessionID, Agent: "aiscan.web", Data: data, - Ext: map[string]any{"aiscan": goal}, } + _ = aop.SetExt(&event, aop.NSAOP, aop.RunControl{NoEcho: goal.NoEcho, MaxTurns: goal.PersistMaxTurns}) + if goal.EvalCriteria != "" { + _ = xeval.Set(&event, xeval.Control{Criteria: goal.EvalCriteria, MaxRounds: goal.EvalMaxRounds}) + } + return event } func (p *AgentPool) dispatchPayload(agentID, taskID, typ, data string, payload json.RawMessage) (<-chan taskResult, error) { @@ -378,11 +390,6 @@ func (p *AgentPool) CancelTask(agentID, taskID string) { // The browser sends transport-neutral PTY frames; the pool assigns a stream_id, // wraps them for the mixed agent connection, and unwraps matching responses. func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *http.Request) { - if p.get(agentID) == nil { - writeError(w, http.StatusNotFound, "agent not connected") - return - } - conn, err := p.upgrader.Upgrade(w, r, nil) if err != nil { return @@ -390,7 +397,7 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h defer conn.Close() terminalID := generateID() - events, unsubscribe := p.subscribePTY(terminalID) + events, unsubscribe := p.subscribePTY(agentID, terminalID) defer unsubscribe() defer p.CloseTerminal(agentID, terminalID) @@ -411,12 +418,18 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h if !ok { return } - _ = write(msg) + if err := write(msg); err != nil { + _ = conn.Close() + return + } case <-done: return } } }() + if p.get(agentID) == nil { + _ = write(pty.Frame{Type: pty.FrameDetached, StreamID: terminalID}) + } for { var frame pty.Frame @@ -430,7 +443,7 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h frame.StreamID = terminalID if err := p.SendAgentMessage(agentID, webproto.NewPTYMessage(frame)); err != nil { _ = write(pty.Frame{Type: pty.FrameError, StreamID: terminalID, Error: err.Error()}) - return + continue } } } @@ -443,21 +456,65 @@ func (p *AgentPool) CloseTerminal(agentID, terminalID string) { _ = p.SendAgentMessage(agentID, webproto.NewPTYMessage(pty.Frame{Type: pty.FrameDetach, StreamID: terminalID})) } -func (p *AgentPool) subscribePTY(terminalID string) (<-chan pty.Frame, func()) { +func (p *AgentPool) subscribePTY(agentID, terminalID string) (<-chan pty.Frame, func()) { ch := make(chan pty.Frame, 256) p.ptyMu.Lock() p.ptySubs[terminalID] = ch + p.ptyAgents[terminalID] = agentID p.ptyMu.Unlock() return ch, func() { p.ptyMu.Lock() if p.ptySubs[terminalID] == ch { delete(p.ptySubs, terminalID) + delete(p.ptyAgents, terminalID) close(ch) } p.ptyMu.Unlock() } } +func (p *AgentPool) notifyPTY(agentID string, frame pty.Frame) { + p.ptyMu.RLock() + defer p.ptyMu.RUnlock() + for terminalID, boundAgentID := range p.ptyAgents { + if boundAgentID != agentID { + continue + } + out := frame + out.StreamID = terminalID + if ch := p.ptySubs[terminalID]; ch != nil { + select { + case ch <- out: + default: + p.ptyDrops.Add(1) + } + } + } +} + +func (p *AgentPool) rebindPTY(agent *remoteAgent) { + if agent == nil { + return + } + p.ptyMu.RLock() + terminalIDs := make([]string, 0) + for terminalID, agentID := range p.ptyAgents { + if agentID == agent.id { + terminalIDs = append(terminalIDs, terminalID) + } + } + p.ptyMu.RUnlock() + for _, terminalID := range terminalIDs { + terminalID := terminalID + go func() { + select { + case agent.sendCh <- webproto.NewPTYMessage(pty.Frame{Type: pty.FrameList, StreamID: terminalID}): + case <-agent.done: + } + }() + } +} + func (p *AgentPool) forwardPTYMessage(msg WSMessage) bool { if msg.Type != webproto.TypePTY { return false @@ -538,18 +595,18 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { } agent := &remoteAgent{ - id: id, - name: info.Name, - commands: info.Commands, - commandsMenu: info.CommandsMenu, - conn: conn, - sendCh: make(chan WSMessage, 32), - controlCh: make(chan WSMessage, 1), - connectAt: time.Now(), - node: info.Node, - runtime: info.Runtime, - status: info.Status, - stats: info.Stats, + id: id, + name: info.Name, + commands: info.Commands, + commandsMenu: info.CommandsMenu, + conn: conn, + sendCh: make(chan WSMessage, 32), + controlCh: make(chan WSMessage, 1), + connectAt: time.Now(), + node: info.Node, + runtime: info.Runtime, + status: info.Status, + stats: info.Stats, tasks: make(map[string]chan taskResult), turns: make(map[string]int), childSessions: make(map[string]map[string]struct{}), @@ -564,17 +621,27 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { // Send connected ack. ack, _ := json.Marshal(map[string]string{"agent_id": agent.id, "name": agent.name}) - _ = conn.WriteJSON(WSMessage{Type: "connected", Payload: ack}) + if err := conn.WriteJSON(WSMessage{Type: "connected", Payload: ack}); err != nil { + return + } // Write goroutine: sendCh → WebSocket. go func() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() + closeBrokenConnection := func() { + // A failed writer must tear down the shared WebSocket so the read + // loop exits, unregisters this agent, and lets the client reconnect. + // Otherwise the pool keeps a zombie "online" agent whose sendCh has + // no consumer; PTY open/list requests then disappear indefinitely. + _ = conn.Close() + } for { // Give control frames priority over task/output traffic. select { case msg := <-agent.controlCh: if err := conn.WriteJSON(msg); err != nil { + closeBrokenConnection() return } continue @@ -583,6 +650,7 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { select { case msg := <-agent.controlCh: if err := conn.WriteJSON(msg); err != nil { + closeBrokenConnection() return } case msg, ok := <-agent.sendCh: @@ -590,10 +658,12 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { return } if err := conn.WriteJSON(msg); err != nil { + closeBrokenConnection() return } case <-ticker.C: if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + closeBrokenConnection() return } case <-agent.done: diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index 14fa7c35..e1ba0c64 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -628,14 +628,77 @@ func TestWSTerminalSingleton(t *testing.T) { defer browserConn.Close() writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameOpen, - Kind: "repl", Name: "main-repl", Singleton: true, Cols: 80, Rows: 24}) + Kind: "shell", Name: "singleton-shell", Singleton: true, Cols: 80, Rows: 24}) open := readAgentPTY(t, agentConn, pty.FrameOpen) - if !open.Singleton || open.Kind != "repl" || open.Name != "main-repl" { + if !open.Singleton || open.Kind != "shell" || open.Name != "singleton-shell" { t.Fatalf("singleton not preserved: %+v", open) } } +func TestWSTerminalRebindsAfterAgentReconnect(t *testing.T) { + srv, pool := setupTestServer(t) + agentConn := dialAgent(t, srv, "generation-agent", []string{"tmux"}) + + time.Sleep(50 * time.Millisecond) + agentID := pool.List()[0].ID + terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" + browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + t.Fatalf("dial terminal: %v", err) + } + defer browserConn.Close() + + if err := agentConn.Close(); err != nil { + t.Fatalf("close agent: %v", err) + } + detached := readBrowserPTY(t, browserConn, pty.FrameDetached) + if detached.StreamID == "" { + t.Fatalf("disconnect notification missing stream id: %+v", detached) + } + + reconnected := dialAgent(t, srv, "generation-agent", []string{"tmux"}) + defer reconnected.Close() + list := readAgentPTY(t, reconnected, pty.FrameList) + if list.StreamID != detached.StreamID { + t.Fatalf("rebound stream = %s, want %s", list.StreamID, detached.StreamID) + } + writeAgentPTY(t, reconnected, pty.Frame{Type: pty.FrameSessions, StreamID: list.StreamID, + Sessions: []pty.Info{{ID: "resident-repl", Kind: "repl", Name: "main-repl", State: pty.StateRunning}}}) + sessions := readBrowserPTY(t, browserConn, pty.FrameSessions) + if len(sessions.Sessions) != 1 || sessions.Sessions[0].ID != "resident-repl" { + t.Fatalf("reconnected sessions not forwarded: %+v", sessions) + } +} + +func TestWSTerminalCanWaitForOfflineAgent(t *testing.T) { + srv, _ := setupTestServer(t) + agentID := protocols.NodeRef{ID: "node-offline-agent", Authority: srv.URL}.URI() + terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" + browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + t.Fatalf("dial offline terminal: %v", err) + } + defer browserConn.Close() + readBrowserPTY(t, browserConn, pty.FrameDetached) + + agentConn := dialAgent(t, srv, "offline-agent", []string{"tmux"}) + defer agentConn.Close() + list := readAgentPTY(t, agentConn, pty.FrameList) + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: list.StreamID, + Sessions: []pty.Info{{ID: "resident-repl", Kind: "repl", Name: "main-repl", State: pty.StateRunning}}}) + sessions := readBrowserPTY(t, browserConn, pty.FrameSessions) + if len(sessions.Sessions) != 1 || sessions.Sessions[0].ID != "resident-repl" { + t.Fatalf("offline subscription did not rebind: %+v", sessions) + } +} + func TestWSTerminalBufferPressure(t *testing.T) { srv, pool := setupTestServer(t) agentConn := dialAgent(t, srv, "pressure-agent", []string{"tmux"}) @@ -823,25 +886,21 @@ func TestE2ETerminalOpenAndType(t *testing.T) { openFirstAgentTerminal(t, page) - // Two WebSocket terminals connect (ReplTerminal + TaskPTYPanel). - // Drain all initial messages from the agent: pty.open (repl), pty.list (tasks) + // The terminal discovers the Runtime-owned REPL through pty.list; the browser + // never creates it. initial := drainAgentMessages(agentConn, time.Second) - replOpen, ok := findPTYFrame(initial, pty.FrameOpen) + listMsg, ok := findPTYFrame(initial, pty.FrameList) if !ok { - t.Fatalf("no pty.open received, got: %v", initial) + t.Fatalf("no pty.list received, got: %v", initial) } - replStreamID := replOpen.StreamID - - // Reply to the pty.open for the REPL terminal - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: replStreamID, + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: listMsg.StreamID, + Sessions: []pty.Info{{ID: "e2e-sess-1", Kind: "repl", Name: "main-repl", State: pty.StateRunning}}}) + attach := readAgentPTY(t, agentConn, pty.FrameAttach) + replStreamID := attach.StreamID + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameAttached, StreamID: attach.StreamID, SessionID: "e2e-sess-1", Kind: "repl"}) - // Reply to pty.list for the task panel (if received) - if listMsg, ok := findPTYFrame(initial, pty.FrameList); ok { - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: listMsg.StreamID}) - } - time.Sleep(300 * time.Millisecond) // Simulate input by dispatching keyboard event directly into xterm's textarea diff --git a/pkg/web/auth.go b/pkg/web/auth.go index ec4abaa3..857e3e32 100644 --- a/pkg/web/auth.go +++ b/pkg/web/auth.go @@ -1,12 +1,35 @@ package web import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" "net/http" "strings" ) -// AccessKeyAuth returns middleware that gates requests behind a Bearer token. -// Requests without a valid token get a 401. An empty key disables auth (dev mode). +const authCookieName = "aiscan_session" + +// authenticate resolves the request credential against the access key. +// Explicit Bearer credentials take precedence: an invalid supplied header +// cannot silently fall back to a browser cookie. An empty key disables auth. +func authenticate(r *http.Request, key string) bool { + if key == "" { + return true + } + if token, ok := bearerToken(r.Header.Get("Authorization")); ok { + return accessKeyMatches(key, token) + } + if cookie, err := r.Cookie(authCookieName); err == nil { + return sessionMatches(key, cookie.Value) + } + return false +} + +// AccessKeyAuth returns middleware that gates requests behind access-key credentials. +// Browser logins exchange the access key for an HttpOnly session cookie so the +// key never needs to live in JavaScript or appear in a URL. Requests without a +// valid credential get a 401. An empty key disables auth (dev mode). func AccessKeyAuth(key string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { if key == "" { @@ -14,16 +37,17 @@ func AccessKeyAuth(key string) func(http.Handler) http.Handler { } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth for health check, static SPA, and IOA (has its own auth) - if r.URL.Path == "/health" || !strings.HasPrefix(r.URL.Path, "/api/") { + switch r.URL.Path { + case "/health", "/api/auth/session", "/api/auth/login", "/api/auth/logout": next.ServeHTTP(w, r) return } - // Accept token from: Authorization: Bearer , or ?access_key= - token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if token == "" { - token = r.URL.Query().Get("access_key") + if !strings.HasPrefix(r.URL.Path, "/api/") { + next.ServeHTTP(w, r) + return } - if strings.TrimSpace(token) != key { + + if !authenticate(r, key) { writeError(w, http.StatusUnauthorized, "invalid or missing access key") return } @@ -31,3 +55,78 @@ func AccessKeyAuth(key string) func(http.Handler) http.Handler { }) } } + +func registerAuthRoutes(mux *http.ServeMux, key string) { + mux.HandleFunc("GET /api/auth/session", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]bool{"authenticated": authenticate(r, key)}) + }) + + mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if !decodeBody(w, r, &req) { + return + } + if !accessKeyMatches(key, strings.TrimSpace(req.Token)) { + writeError(w, http.StatusUnauthorized, "invalid access token") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: sessionValue(key), + Path: "/", + HttpOnly: true, + Secure: requestIsHTTPS(r), + SameSite: http.SameSiteStrictMode, + }) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: requestIsHTTPS(r), + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) +} + +func bearerToken(header string) (string, bool) { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", false + } + return parts[1], true +} + +func accessKeyMatches(key, candidate string) bool { + want := sha256.Sum256([]byte(key)) + got := sha256.Sum256([]byte(candidate)) + return subtle.ConstantTimeCompare(want[:], got[:]) == 1 +} + +func sessionValue(key string) string { + sum := sha256.Sum256([]byte("aiscan-web-session\x00" + key)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func sessionMatches(key, candidate string) bool { + return subtle.ConstantTimeCompare([]byte(sessionValue(key)), []byte(candidate)) == 1 +} + +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https") +} diff --git a/pkg/web/auth_test.go b/pkg/web/auth_test.go new file mode 100644 index 00000000..99e2d3bc --- /dev/null +++ b/pkg/web/auth_test.go @@ -0,0 +1,144 @@ +package web + +import ( + "bytes" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "testing" +) + +func TestAccessKeyAuthBrowserSession(t *testing.T) { + mux := http.NewServeMux() + registerAuthRoutes(mux, "test-token") + mux.HandleFunc("GET /api/protected", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + server := httptest.NewServer(AccessKeyAuth("test-token")(mux)) + defer server.Close() + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + + assertStatus(t, client, http.MethodGet, server.URL+"/api/auth/session", nil, http.StatusOK) + assertStatus(t, client, http.MethodGet, server.URL+"/api/protected", nil, http.StatusUnauthorized) + // URL credentials are deliberately unsupported: they leak through browser + // history, referrers, access logs, and screenshots. + assertStatus(t, client, http.MethodGet, server.URL+"/api/protected?access_key=test-token", nil, http.StatusUnauthorized) + + loginBody := bytes.NewBufferString(`{"token":"test-token"}`) + assertStatus(t, client, http.MethodPost, server.URL+"/api/auth/login", loginBody, http.StatusOK) + assertStatus(t, client, http.MethodGet, server.URL+"/api/protected", nil, http.StatusNoContent) + + assertStatus(t, client, http.MethodPost, server.URL+"/api/auth/logout", nil, http.StatusOK) + assertStatus(t, client, http.MethodGet, server.URL+"/api/protected", nil, http.StatusUnauthorized) +} + +func TestAccessKeyAuthBearerStillSupported(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + handler := AccessKeyAuth("test-token")(next) + + valid := httptest.NewRequest(http.MethodGet, "/api/protected", nil) + valid.Header.Set("Authorization", "Bearer test-token") + validRecorder := httptest.NewRecorder() + handler.ServeHTTP(validRecorder, valid) + if validRecorder.Code != http.StatusNoContent { + t.Fatalf("valid bearer status = %d, want %d", validRecorder.Code, http.StatusNoContent) + } + + invalid := httptest.NewRequest(http.MethodGet, "/api/protected", nil) + invalid.Header.Set("Authorization", "Bearer wrong-token") + invalid.AddCookie(&http.Cookie{Name: authCookieName, Value: sessionValue("test-token")}) + invalidRecorder := httptest.NewRecorder() + handler.ServeHTTP(invalidRecorder, invalid) + if invalidRecorder.Code != http.StatusUnauthorized { + t.Fatalf("invalid bearer with valid cookie status = %d, want %d", invalidRecorder.Code, http.StatusUnauthorized) + } +} + +func TestLoginCookieSecurityAttributes(t *testing.T) { + mux := http.NewServeMux() + registerAuthRoutes(mux, "test-token") + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"token":"test-token"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-Proto", "https") + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, req) + + result := recorder.Result() + defer result.Body.Close() + cookies := result.Cookies() + if len(cookies) != 1 { + t.Fatalf("cookies = %d, want 1", len(cookies)) + } + cookie := cookies[0] + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" { + t.Fatalf("unsafe auth cookie: %#v", cookie) + } + if cookie.Value == "test-token" { + t.Fatal("auth cookie contains the raw access token") + } +} + +func TestAuthenticate(t *testing.T) { + req := func() *http.Request { return httptest.NewRequest(http.MethodGet, "/api/x", nil) } + + if !authenticate(req(), "") { + t.Fatal("empty key must authenticate (dev mode)") + } + + bearer := req() + bearer.Header.Set("Authorization", "Bearer test-token") + if !authenticate(bearer, "test-token") { + t.Fatal("valid bearer rejected") + } + + // An invalid bearer must not fall back to a valid cookie. + mixed := req() + mixed.Header.Set("Authorization", "Bearer wrong-token") + mixed.AddCookie(&http.Cookie{Name: authCookieName, Value: sessionValue("test-token")}) + if authenticate(mixed, "test-token") { + t.Fatal("invalid bearer fell back to cookie") + } + + cookie := req() + cookie.AddCookie(&http.Cookie{Name: authCookieName, Value: sessionValue("test-token")}) + if !authenticate(cookie, "test-token") { + t.Fatal("valid session cookie rejected") + } + + if authenticate(req(), "test-token") { + t.Fatal("credential-less request authenticated") + } +} + +func assertStatus(t *testing.T, client *http.Client, method, url string, body *bytes.Buffer, want int) { + t.Helper() + var requestBody *bytes.Buffer + if body != nil { + requestBody = body + } else { + requestBody = bytes.NewBuffer(nil) + } + req, err := http.NewRequest(method, url, requestBody) + if err != nil { + t.Fatal(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != want { + t.Fatalf("%s %s status = %d, want %d", method, url, res.StatusCode, want) + } +} diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go index 0dd4aedf..a8813145 100644 --- a/pkg/web/eval_forward_test.go +++ b/pkg/web/eval_forward_test.go @@ -4,8 +4,9 @@ import ( "encoding/json" "testing" - "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/aop" + xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" ) type evalSink struct { @@ -33,18 +34,10 @@ func TestForwardAgentEventKeepsEvalOnlyInAOP(t *testing.T) { TS: "2026-07-19T00:00:00Z", SessionID: "agent-session", Agent: "test-agent", - Data: mustJSON(aop.StatusData{ - State: agent.StatusEvalEnd, - }), - Ext: map[string]any{"test-agent": map[string]any{ - "eval_round": 1, - "eval_pass": true, - "eval_reason": "found SQLi", - "compact_tokens_before": 1000, - "compact_tokens_after": 400, - "compact_kept_messages": 8, - }}, + Data: mustJSON(aop.StatusData{State: xeval.StateEnd}), } + _ = xeval.SetDetail(&event, xeval.Detail{Round: 1, Pass: true, Reason: "found SQLi"}) + _ = xcompact.SetDetail(&event, xcompact.Detail{TokensBefore: 1000, TokensAfter: 400, KeptMessages: 8}) payload, _ := json.Marshal(event) pool.forwardAOPEvent(remote, WSMessage{Type: "aop", TaskID: "task-1", Payload: payload}) @@ -54,17 +47,15 @@ func TestForwardAgentEventKeepsEvalOnlyInAOP(t *testing.T) { if len(sink.aopEvents) == 0 { t.Fatal("AOP event was not forwarded") } - ext, ok := sink.aopEvents[0].Ext["test-agent"].(map[string]any) - if !ok { - t.Fatalf("extension = %#v", sink.aopEvents[0].Ext) + evalDetail, ok, err := xeval.GetDetail(sink.aopEvents[0]) + if err != nil || !ok { + t.Fatalf("eval extension = %#v, %v, %v", sink.aopEvents[0].Ext, ok, err) } - if ext["eval_round"] != float64(1) && ext["eval_round"] != 1 { - t.Fatalf("eval_round = %#v", ext["eval_round"]) + if evalDetail.Round != 1 || !evalDetail.Pass || evalDetail.Reason != "found SQLi" { + t.Fatalf("eval detail = %#v", evalDetail) } - if ext["eval_pass"] != true || ext["eval_reason"] != "found SQLi" { - t.Fatalf("eval extension = %#v", ext) - } - if ext["compact_tokens_before"] != float64(1000) && ext["compact_tokens_before"] != 1000 { - t.Fatalf("compact extension = %#v", ext) + compactDetail, ok, err := xcompact.GetDetail(sink.aopEvents[0]) + if err != nil || !ok || compactDetail.TokensBefore != 1000 || compactDetail.KeptMessages != 8 { + t.Fatalf("compact detail = %#v, %v, %v", compactDetail, ok, err) } } diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 52a18eed..4011ec61 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -23,6 +23,7 @@ func NewHandler(service *Service, agents *AgentPool, local *LocalAgents, ioaHand console = ioaConsole[0] } h := &handlerImpl{service: service, agents: agents, ioa: console, accessKey: accessKey} + registerAuthRoutes(mux, accessKey) mux.HandleFunc("POST /api/scans", h.createScan) mux.HandleFunc("GET /api/scans", h.listScans) diff --git a/pkg/web/service.go b/pkg/web/service.go index b84cfce5..f4212c69 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -18,8 +18,9 @@ import ( "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/runner" - "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/aop" + xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/aiscan/pkg/commands" scantool "github.com/chainreactors/aiscan/pkg/tools/scan" "github.com/chainreactors/aiscan/pkg/tui" @@ -1170,7 +1171,7 @@ func isReliableAOPEvent(event aop.Event) bool { return false } switch data.State { - case agent.StatusEvalEnd, agent.StatusCompactEnd, agent.StatusTokenBudgetWarning: + case xeval.StateEnd, xcompact.StateEnd, aop.StatusTokenBudgetWarning: return true } } diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index be03a273..b7145df4 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/aop" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" ) // A saturated subscriber buffer must never swallow a reliable terminal event. @@ -110,13 +111,12 @@ func TestEvalMetadataPersistsOnlyInAOP(t *testing.T) { defer store.Close() svc := NewService(ServiceConfig{Store: store}) - svc.BroadcastAOPEvent("sess-eval", aop.Event{ + event := aop.Event{ Type: "turn.end", TS: time.Now().UTC().Format(time.RFC3339Nano), SessionID: "sess-eval", Agent: "aiscan", Data: json.RawMessage(`{"turn":1}`), - Ext: map[string]any{"aiscan": map[string]any{ - "eval_round": 2, "eval_pass": false, "eval_reason": "needs one more verified finding", - }}, - }) + } + _ = xeval.SetDetail(&event, xeval.Detail{Round: 2, Pass: false, Reason: "needs one more verified finding"}) + svc.BroadcastAOPEvent("sess-eval", event) events, err := store.ListAOPEvents(context.Background(), "sess-eval", 100) if err != nil { t.Fatal(err) @@ -124,9 +124,12 @@ func TestEvalMetadataPersistsOnlyInAOP(t *testing.T) { if len(events) != 1 { t.Fatalf("persisted AOP events = %d, want 1", len(events)) } - ext, _ := events[0].Ext["aiscan"].(map[string]any) - if ext["eval_reason"] != "needs one more verified finding" { - t.Fatalf("persisted extension = %#v", ext) + detail, ok, err := xeval.GetDetail(events[0]) + if err != nil || !ok { + t.Fatalf("persisted extension = %#v, %v, %v", events[0].Ext, ok, err) + } + if detail.Round != 2 || detail.Pass || detail.Reason != "needs one more verified finding" { + t.Fatalf("persisted detail = %#v", detail) } } diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index 959756df..eb0f3f62 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -10,6 +10,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/pkg/webproto" _ "modernc.org/sqlite" ) @@ -227,16 +228,13 @@ func messageEventFromChatMessage(msg *ChatMessage) (aop.Event, error) { if err != nil { return aop.Event{}, err } - ext := map[string]any{} - if msg.AgentID != "" { - ext["agent_id"] = msg.AgentID - } + ext := webproto.WebMessageExt{AgentID: msg.AgentID} if len(msg.Metadata) > 0 { - var metadata any - if err := json.Unmarshal(msg.Metadata, &metadata); err != nil { - metadata = string(msg.Metadata) + if json.Valid(msg.Metadata) { + ext.Metadata = msg.Metadata + } else if raw, err := json.Marshal(string(msg.Metadata)); err == nil { + ext.Metadata = raw } - ext["metadata"] = metadata } event := aop.Event{ Type: aop.TypeMessage, @@ -245,20 +243,12 @@ func messageEventFromChatMessage(msg *ChatMessage) (aop.Event, error) { Agent: agentName, Data: data, } - if len(ext) > 0 { - event.Ext = map[string]any{"aiscan": ext} + if ext.AgentID != "" || len(ext.Metadata) > 0 { + _ = webproto.SetWebExt(&event, ext) } return event, nil } -func aopExtension(event aop.Event, namespace string) map[string]any { - if event.Ext == nil { - return nil - } - ext, _ := event.Ext[namespace].(map[string]any) - return ext -} - func sqliteColumnExists(db *sql.DB, table, column string) (bool, error) { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", quoteSQLiteIdent(table))) if err != nil { @@ -576,11 +566,9 @@ func (s *SQLiteStore) ListMessages(ctx context.Context, sessionID string, limit msg.Role = "assistant" } msg.CreatedAt, _ = time.Parse(time.RFC3339Nano, event.TS) - if ext := aopExtension(event, "aiscan"); ext != nil { - msg.AgentID, _ = ext["agent_id"].(string) - if metadata, ok := ext["metadata"]; ok { - msg.Metadata, _ = json.Marshal(metadata) - } + if ext, ok, err := webproto.GetWebExt(event); err == nil && ok { + msg.AgentID = ext.AgentID + msg.Metadata = ext.Metadata } msgs = append(msgs, msg) if len(msgs) >= limit { diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index 190363bc..06339d6a 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -1,7 +1,6 @@ package webagent import ( - "bytes" "context" "encoding/base64" "encoding/json" @@ -9,22 +8,19 @@ import ( "os" "path/filepath" "strings" - "sync" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/runner" "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/evaluator" + inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" - "github.com/chainreactors/utils/pty" ) -func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { +func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { if option.WebURL != "" { remoteOpt, err := fetchRemoteConfig(option.WebURL) if err != nil { @@ -46,6 +42,7 @@ func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error NoOutput: true, IOA: remoteIOAConfig(option, identityRef), ProviderOptional: true, + REPLMode: runner.REPLPersistent, }) if err != nil { return err @@ -54,7 +51,6 @@ func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error chatHandler := &chatAgentHandler{ rt: rt, - chatMgr: newChatRuntimeManager(rt), serverURL: option.WebURL, } @@ -62,29 +58,22 @@ func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error go func() { defer close(connectionDone) _ = rt.App.WaitEngines(ctx) - logger.Debugf("web agent connection to %s", option.WebURL) - - var extraPTYOpeners map[string]pty.OpenFunc - if mgr := RegistryPTYManager(rt.App.Commands); mgr != nil { - extraPTYOpeners = map[string]pty.OpenFunc{ - "repl": runner.NewRemoteREPLOpener(rt, mgr), - } - } + logger.Debugf("websocket transport connection to %s", option.WebURL) _ = connect(ctx, connectionConfig{ - ServerURL: option.WebURL, - Name: rt.NodeName, - Registry: rt.App.Commands, - AgentBus: rt.Bus, - DataBus: rt.App.DataBus, - SCO: rt.App.SCOSidecar, - Logger: logger, - Chat: chatHandler, - Node: identityRef, - Runtime: DefaultRuntime(), - Status: func() webproto.AgentStatus { return agentStatus(rt) }, - Menu: func() []webproto.CommandSpec { return agentCommandCatalog(rt) }, - ExtraPTYOpeners: extraPTYOpeners, + ServerURL: option.WebURL, + Name: rt.NodeName, + Registry: rt.App.Commands, + AgentBus: rt.Bus, + DataBus: rt.App.DataBus, + SCO: rt.App.SCOSidecar, + Logger: logger, + Chat: chatHandler, + Node: identityRef, + Runtime: DefaultRuntime(), + Status: func() webproto.AgentStatus { return agentStatus(rt) }, + Menu: func() []webproto.CommandSpec { return agentCommandCatalog(rt) }, + PTYRouter: rt.NewPTYRouter, }) }() @@ -100,14 +89,21 @@ func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error return err } if task == "" { - logger.Infof("web agent connected; remote REPL and PTY are available") + logger.Infof("websocket transport connected; remote REPL and PTY are available") <-ctx.Done() <-connectionDone return nil } - loopCfg := rt.Config.WithSystemPrompt(rt.SystemPrompt).WithStream(true) - _, err = agent.NewAgent(loopCfg).Run(ctx, agent.TextInput(task)) + _, err = rt.Execute(ctx, "startup", agent.Inbound{ + Kind: agent.InboundUserMessage, + Event: aop.Event{SessionID: "startup"}, + Message: aop.MessageData{ + MessageID: "startup", + Role: "user", + Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}, + }, + }, nil) <-connectionDone return err @@ -119,66 +115,57 @@ func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error type chatAgentHandler struct { rt *runner.AgentRuntime - chatMgr *chatRuntimeManager serverURL string } -func (h *chatAgentHandler) HandleChat(ctx context.Context, msg webproto.Message, event aop.Event, send func(webproto.Message), router *eventRouter) { - goal := webproto.DecodeGoalExt(event) - webSessionID := event.SessionID - ag, agErr := h.chatMgr.agentFor(webSessionID) - if agErr != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: agErr.Error()}) - return - } - - data, err := aop.DecodeData[aop.MessageData](event) - if err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "decode user message: " + err.Error()}) - return +func (h *chatAgentHandler) HandleChat(ctx context.Context, msg webproto.Message, event aop.Event, send func(webproto.Message), router *eventRouter) func() { + inbound, err := agent.Classify(event) + if err != nil || inbound.Kind != agent.InboundUserMessage { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "invalid inbound user message"}) + return func() {} } - input := agent.InputFromAOPMessage(data) - input.NoEcho = goal.NoEcho prompt := strings.TrimSpace(webproto.UserMessageText(event)) if prompt == "" { send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "empty prompt"}) - return + return func() {} } - // Wait for this session's turn: messages to one web session run FIFO, so a - // message sent while the agent is busy queues here instead of steering - // into the running turn. Canceling the queued message's task wakes it. - release, ok := h.chatMgr.acquireTurn(ctx, webSessionID) - if !ok { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "canceled while queued"}) - return + if isREPLCommand(prompt) { + wait, submitErr := h.rt.SubmitLine(ctx, msg.TaskID, event.SessionID, prompt) + if submitErr != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: submitErr.Error()}) + return func() {} + } + return func() { + out, execErr := wait() + if execErr != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: execErr.Error()}) + return + } + send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Data: fenceTerminalOutput(out)}) + } } - defer release() - - // Route at dequeue time so this run's events reach this message's task. - router.Route(ag.Cfg.SessionID, msg.TaskID) - // Fold in files uploaded to this session since the last turn so the agent - // learns their absolute on-disk paths and can read them. REPL/`!` lines are - // left untouched so a note never corrupts a command. - if !isREPLCommand(prompt) { - if note := h.chatMgr.takePendingUploads(webSessionID); note != "" { - input = agent.TextInput(note + "\n\n" + prompt) - input.NoEcho = goal.NoEcho + wait, err := h.rt.Submit(ctx, msg.TaskID, inbound, func(sessionID string) { + router.Route(sessionID, msg.TaskID) + }) + if err != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) + return func() {} + } + return func() { + if _, err := wait(); err != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) } } - - // The node already launched us in a goroutine with a cancellable context, - // so we run synchronously here. - runChatWithAgent(ctx, msg, prompt, goal, input, ag, h.rt, send, router) } func (h *chatAgentHandler) HandleUpload(msg webproto.Message, send func(webproto.Message)) { - handleFileUpload(msg, send, h.chatMgr) + handleFileUpload(msg, send, h.rt) } func (h *chatAgentHandler) HandleConfigReload(serverURL string, send func(webproto.Message)) { - provider, model, err := reloadAgentConfig(serverURL, h.rt, h.chatMgr) + provider, model, err := reloadAgentConfig(serverURL, h.rt) result := webproto.ConfigReloadResult{OK: err == nil, Model: model} if err != nil { result.Error = err.Error() @@ -192,128 +179,7 @@ func (h *chatAgentHandler) HandleConfigReload(serverURL string, send func(webpro } func (h *chatAgentHandler) CancelChat(taskID string) bool { - return false // cancel is handled by node's context cancellation -} - -// --------------------------------------------------------------------------- -// chatRuntimeManager -// --------------------------------------------------------------------------- - -type chatRuntimeManager struct { - rt *runner.AgentRuntime - mu sync.Mutex - sessions map[string]*agent.Agent - turns map[string]chan struct{} // web sessionID -> single-token turn lock - - uploadMu sync.Mutex - uploads map[string][]string // web sessionID -> notes about files uploaded since the last turn -} - -func newChatRuntimeManager(rt *runner.AgentRuntime) *chatRuntimeManager { - return &chatRuntimeManager{ - rt: rt, - sessions: make(map[string]*agent.Agent), - turns: make(map[string]chan struct{}), - uploads: make(map[string][]string), - } -} - -// acquireTurn takes the session's turn token, blocking (FIFO, in channel -// receiver order) until the previous run releases it. The returned release -// hands the turn to the next waiter. ok=false means ctx ended while queued. -func (m *chatRuntimeManager) acquireTurn(ctx context.Context, sessionID string) (release func(), ok bool) { - if sessionID == "" { - sessionID = "default" - } - m.mu.Lock() - turn, exists := m.turns[sessionID] - if !exists { - turn = make(chan struct{}, 1) - turn <- struct{}{} - m.turns[sessionID] = turn - } - m.mu.Unlock() - select { - case <-turn: - return func() { turn <- struct{}{} }, true - case <-ctx.Done(): - return nil, false - } -} - -// notePendingUpload records that a file was written to the agent's local disk for -// a web session. The hub's SysFileUploaded broadcast only reaches the UI, so the -// LLM never learns the path on its own; the note is folded into the session's next -// natural-language turn (see the "chat" dispatch) so "read the file" resolves to -// the real absolute path instead of a bare filename against the cwd. -func (m *chatRuntimeManager) notePendingUpload(sessionID, note string) { - if m == nil || note == "" { - return - } - if sessionID == "" { - sessionID = "default" - } - m.uploadMu.Lock() - m.uploads[sessionID] = append(m.uploads[sessionID], note) - m.uploadMu.Unlock() -} - -// takePendingUploads drains and joins the pending upload notes for a session, -// returning "" when there are none. Draining is one-shot so each note reaches -// exactly one turn. The empty session ID normalizes to "default" to match agentFor. -func (m *chatRuntimeManager) takePendingUploads(sessionID string) string { - if m == nil { - return "" - } - if sessionID == "" { - sessionID = "default" - } - m.uploadMu.Lock() - notes := m.uploads[sessionID] - delete(m.uploads, sessionID) - m.uploadMu.Unlock() - return strings.Join(notes, "\n") -} - -func (m *chatRuntimeManager) agentFor(sessionID string) (*agent.Agent, error) { - if m == nil || m.rt == nil || m.rt.App == nil { - return nil, fmt.Errorf("agent runtime is not configured") - } - if sessionID == "" { - sessionID = "default" - } - m.mu.Lock() - defer m.mu.Unlock() - if ag := m.sessions[sessionID]; ag != nil { - return ag, nil - } - ag := agent.NewAgent(m.rt.Config. - WithSystemPrompt(m.rt.SystemPrompt). - WithStream(true). - WithInbox(nil)) - m.sessions[sessionID] = ag - return ag, nil -} - -// reloadProvider rebuilds the LLM provider from option and hot-swaps it across -// the runtime template (rt.App + rt.Config) and every live session, all under -// m.mu so a concurrent agentFor never clones a half-updated template. A run -// already in flight finishes on its old provider; the next message uses the new -// one. -func (m *chatRuntimeManager) reloadProvider(option *cfg.Option) (agent.Provider, string, error) { - if m == nil || m.rt == nil { - return nil, "", fmt.Errorf("agent runtime is not configured") - } - m.mu.Lock() - defer m.mu.Unlock() - provider, model, err := m.rt.ReloadProvider(option) - if err != nil { - return nil, "", err - } - for _, ag := range m.sessions { - ag.SetProvider(provider, model) - } - return provider, model, nil + return h.rt.Cancel(taskID) } // --------------------------------------------------------------------------- @@ -324,7 +190,7 @@ func (m *chatRuntimeManager) reloadProvider(option *cfg.Option) (agent.Provider, // true when the swap succeeded, so the caller can re-announce identity. // --------------------------------------------------------------------------- -func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, cr *chatRuntimeManager) (agent.Provider, string, error) { +func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime) (agent.Provider, string, error) { if rt == nil { return nil, "", fmt.Errorf("agent runtime is not configured") } @@ -337,7 +203,7 @@ func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, cr *chatRuntim logger.Warnf("config reload: fetch remote config: %s", err) return nil, "", err } - provider, model, err := cr.reloadProvider(remoteOpt) + provider, model, err := rt.ReloadProvider(remoteOpt) if err != nil { logger.Warnf("config reload: rebuild provider: %s", err) return nil, "", err @@ -346,106 +212,11 @@ func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, cr *chatRuntim return provider, model, nil } -// --------------------------------------------------------------------------- -// Chat execution -// --------------------------------------------------------------------------- - -func runChatWithAgent(ctx context.Context, msg webproto.Message, prompt string, goal webproto.GoalExt, input agent.Input, ag *agent.Agent, rt *runner.AgentRuntime, send func(webproto.Message), router *eventRouter) { - if rt == nil || rt.App == nil { - send(webproto.Message{ - Type: "error", - TaskID: msg.TaskID, - Data: "LLM provider is not configured on this agent; configure aiscan.yaml and restart the agent, or prefix commands with !", - }) - return - } - - if isREPLCommand(prompt) { - out, err := runChatREPLLine(ctx, prompt, rt, ag) - if err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Data: out}) - return - } - - if rt.App.Provider == nil { - send(webproto.Message{ - Type: "error", - TaskID: msg.TaskID, - Data: "LLM provider is not configured on this agent; configure aiscan.yaml and restart the agent, or prefix commands with !", - }) - return - } - - // Goal "达成条件" mode: run the agent under an independent evaluator that - // judges the natural-language criteria each round and re-drives the agent - // with feedback until it passes (or the round budget is spent). - if goal.EvalCriteria != "" { - ag.SetMaxTurns(rt.Config.MaxTurns) // each eval round runs to natural completion - runChatEval(ctx, msg, prompt, goal, ag, rt, send, router) - return - } - - // Goal "固定轮次" mode caps this run at PersistMaxTurns; otherwise restore - // the session default so a prior capped message never leaks its cap forward. - if goal.PersistMaxTurns > 0 { - ag.SetMaxTurns(goal.PersistMaxTurns) - } else { - ag.SetMaxTurns(rt.Config.MaxTurns) - } - - _, err := ag.Run(ctx, input) - if err != nil { - // Pre-loop failures (undecodable input, agent already running) produce - // no session.end, so this frame converges the task. Post-loop errors - // already reached the hub as AOP error + session.end, making this a - // no-op there. - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - } - // Success needs no frame: the root session.end converges the task. -} - -// runChatEval drives the agent through the evaluator loop for a Goal with -// natural-language acceptance criteria, using the agent's own provider/model as -// the independent judge. Per-round progress streams over the agent's own AOP -// emitter like any other agent run. -func runChatEval(ctx context.Context, msg webproto.Message, prompt string, goal webproto.GoalExt, ag *agent.Agent, rt *runner.AgentRuntime, send func(webproto.Message), router *eventRouter) { - maxRounds := goal.EvalMaxRounds - if maxRounds <= 0 { - maxRounds = 3 - } - evalCfg := evaluator.EvalLoopConfig{ - Evaluator: evaluator.New(evaluator.Config{ - Provider: rt.App.Provider, - Model: rt.Config.Model, - Logger: rt.Config.Logger, - }), - MaxEvalRounds: maxRounds, - Goal: prompt, - Criteria: goal.EvalCriteria, - } - // The eval loop performs N ag.Run rounds on one session, each emitting its - // own session.start/end pair. The hub converges chat tasks on the root - // session.end, so the per-round brackets are suppressed; this task keeps - // terminating on the complete/error frame below (same exception class as - // REPL lines, which never start an agent run). - router.SuppressSessionBrackets(ag.Cfg.SessionID) - defer router.UnsuppressSessionBrackets(ag.Cfg.SessionID) - _, _, err := evaluator.RunWithEval(ctx, ag, evalCfg) - if err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - send(webproto.Message{Type: "complete", TaskID: msg.TaskID}) -} - // --------------------------------------------------------------------------- // File upload // --------------------------------------------------------------------------- -func handleFileUpload(msg webproto.Message, send func(webproto.Message), cr *chatRuntimeManager) { +func handleFileUpload(msg webproto.Message, send func(webproto.Message), rt *runner.AgentRuntime) { var payload webproto.FileUploadPayload if len(msg.Payload) > 0 { _ = json.Unmarshal(msg.Payload, &payload) @@ -477,12 +248,20 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message), cr *cha return } - // Surface the absolute on-disk path to the agent's next turn. Without this the - // LLM only ever sees the hub's UI-only "file uploaded" notice and, asked to read - // the file, guesses the bare filename against its cwd — which is not the upload dir. - cr.notePendingUpload(payload.SessionID, fmt.Sprintf( + // Surface the path through the target session's Inbox. Slash/direct commands + // do not consume the Inbox, so the note remains available for the next agent + // turn without a Web-specific pending-upload map. + note := fmt.Sprintf( "[已上传文件] 名称=%q 大小=%d 字节 · agent 本地绝对路径: %s\n(该文件已保存在 agent 磁盘上,需要查看内容时用 read 工具打开上述绝对路径。)", - payload.Filename, len(data), dest)) + payload.Filename, len(data), dest) + if err := rt.PushInbox(payload.SessionID, inboxpkg.NewMessage(inboxpkg.OriginSystem, "user", note)); err != nil { + send(webproto.Message{ + Type: "complete", + TaskID: msg.TaskID, + Payload: webproto.MustJSON(webproto.FileUploadResult{Filename: payload.Filename, Error: err.Error()}), + }) + return + } send(webproto.Message{ Type: "complete", @@ -504,48 +283,6 @@ func isREPLCommand(prompt string) bool { return strings.HasPrefix(prompt, "/") || strings.HasPrefix(prompt, "!") } -func runChatREPLLine(ctx context.Context, line string, rt *runner.AgentRuntime, ag *agent.Agent) (string, error) { - var stdout bytes.Buffer - var stderr bytes.Buffer - option := rt.Option - if option != nil { - copy := *option - copy.NoColor = true - option = © - } - appInfo := tui.AppInfo{ - Provider: rt.App.Provider, - ProviderConfig: rt.App.ProviderConfig, - ProviderFallbacks: rt.App.ProviderFallbacks, - Commands: rt.App.Commands, - Skills: rt.App.Skills, - OnProviderChange: func(provider agent.Provider, providerConfig agent.ProviderConfig) { - rt.App.Provider = provider - rt.App.ProviderConfig = providerConfig - rt.Config.Provider = provider - rt.Config.Model = providerConfig.Model - }, - } - console := tui.NewAgentConsoleWithWriters(ctx, option, appInfo, ag, &stdout, &stderr) - _, err := console.ExecuteLineAndWait(line) - out := trimChatOutput(output.StripANSI(stdout.String())) - errOut := trimChatOutput(output.StripANSI(stderr.String())) - if err != nil { - if errOut != "" { - return "", fmt.Errorf("%s: %w", errOut, err) - } - return "", err - } - combined := out - switch { - case out == "": - combined = errOut - case errOut != "": - combined = trimChatOutput(out + "\n" + errOut) - } - return fenceTerminalOutput(combined), nil -} - // fenceTerminalOutput wraps multi-line REPL/`!` command output in a Markdown // code fence. runChatREPLLine runs the same TUI console the interactive REPL // uses, whose panels (/status, /provider, /nodes ...) are drawn with box-drawing @@ -569,10 +306,6 @@ func fenceTerminalOutput(s string) string { return fence + "\n" + s + "\n" + fence } -func trimChatOutput(value string) string { - return strings.TrimRight(value, " \t\r\n") -} - // --------------------------------------------------------------------------- // Identity and command catalog (agent-specific, needs runner.AgentRuntime) // --------------------------------------------------------------------------- @@ -672,7 +405,7 @@ func remoteIOAConfig(option *cfg.Option, ref protocols.NodeRef) *cfg.IOAConfig { Space: option.Space, RegisterTools: true, AutoRegister: true, - NodeMeta: map[string]any{"client": "aiscan", "transport": "web-agent"}, + NodeMeta: map[string]any{"client": "aiscan", "transport": "websocket"}, Identity: webIdentity{ref: ref}, } } diff --git a/pkg/webagent/aop_tool.go b/pkg/webagent/aop_tool.go index 886be9ff..dd9b4dc2 100644 --- a/pkg/webagent/aop_tool.go +++ b/pkg/webagent/aop_tool.go @@ -6,6 +6,7 @@ import ( "time" "github.com/chainreactors/aiscan/core/tool" + "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -28,21 +29,20 @@ func IsAOPToolCall(msg webproto.Message) bool { // agent always observes one terminal event for every accepted tool.call. func HandleAOPToolCall(ctx context.Context, msg webproto.Message, executor aopToolExecutor, send func(webproto.Message)) { var callEvent aop.Event - if json.Unmarshal(msg.Payload, &callEvent) != nil || !callEvent.Valid() || callEvent.Type != aop.TypeToolCall { + if json.Unmarshal(msg.Payload, &callEvent) != nil { return } - var call aop.ToolCallData - if json.Unmarshal(callEvent.Data, &call) != nil || call.ToolCallID == "" || call.ToolName == "" { + inbound, err := agent.Classify(callEvent) + if err != nil || inbound.Kind != agent.InboundToolCall { return } - if raw, ok := callEvent.Ext["cairn"]; ok { - var extension struct { - WorkDir string `json:"cwd"` - } - encoded, _ := json.Marshal(raw) - if json.Unmarshal(encoded, &extension) == nil && extension.WorkDir != "" { - ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: extension.WorkDir}) - } + handleAOPToolCall(ctx, msg, inbound, executor, send) +} + +func handleAOPToolCall(ctx context.Context, msg webproto.Message, inbound agent.Inbound, executor aopToolExecutor, send func(webproto.Message)) { + callEvent, call := inbound.Event, inbound.ToolCall + if call.WorkDir != "" { + ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: call.WorkDir}) } arguments, err := json.Marshal(call.Args) @@ -60,11 +60,20 @@ func HandleAOPToolCall(ctx context.Context, msg webproto.Message, executor aopTo resultData.Content = execErr.Error() resultData.IsError = true } else { - resultData.Content = map[string]any{ - "content": result.Content, - "details": result.Details, - "terminate": result.Terminate, + resultData.Content = result.Text() + if result.HasImages() { + content := aop.ToolResultContent{Content: result.Text()} + for _, block := range result.Content { + if block.Type == "image" { + content.Images = append(content.Images, aop.ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}) + } + } + resultData.Content = content + } + if result.Details != nil { + resultData.Details = result.Details } + resultData.Terminate = result.Terminate resultData.IsError = result.IsError } data, _ := json.Marshal(resultData) diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go index f0c604c2..240dbcaf 100644 --- a/pkg/webagent/connection.go +++ b/pkg/webagent/connection.go @@ -44,13 +44,16 @@ type connectionConfig struct { Runtime webproto.AgentRuntime Status func() webproto.AgentStatus Menu func() []webproto.CommandSpec // nil = no command menu + // RunnerFileRPC enables runner-only native directory operations. Regular + // aiscan agents neither advertise nor accept these RPCs. + RunnerFileRPC bool - // ExtraPTYOpeners provides additional PTY openers (e.g. a REPL opener from - // the agent runtime) without requiring a core/runner import. - ExtraPTYOpeners map[string]pty.OpenFunc + // PTYRouter creates a connection-scoped router. Agent transports receive it + // from AgentRuntime; tool-only nodes fall back to their registry manager. + PTYRouter func() (*pty.Router, error) } -// chatHandler defines the WebAgent-owned chat callbacks. +// chatHandler defines the Agent Runtime chat callbacks used by WebSocket transport. // Implementations live in webagent or other packages that have access to the // agent runtime and provider. type chatHandler interface { @@ -58,7 +61,10 @@ type chatHandler interface { // the decoded form of msg's payload). The node manages the cancellable // context and the chatCancels map. The EventRouter lets the handler // register agent session ID -> task ID mappings for event routing. - HandleChat(ctx context.Context, msg webproto.Message, event aop.Event, send func(webproto.Message), router *eventRouter) + // HandleChat admits the turn synchronously and returns the work that waits + // for completion. This preserves WebSocket arrival order without blocking + // the connection read loop for the duration of an agent run. + HandleChat(ctx context.Context, msg webproto.Message, event aop.Event, send func(webproto.Message), router *eventRouter) func() // HandleUpload processes a file upload message. HandleUpload(msg webproto.Message, send func(webproto.Message)) @@ -76,7 +82,6 @@ type chatHandler interface { type eventRouter struct { mu *sync.Mutex eventRoute map[string]string // agent sessionID -> task messageID - suppressed map[string]bool // sessionIDs whose session.start/end brackets are not forwarded (eval loops) } // Route registers a mapping from an agent session ID to a WebSocket task ID. @@ -97,36 +102,6 @@ func (r *eventRouter) Unroute(taskID string) { r.mu.Unlock() } -// SuppressSessionBrackets drops session.start/session.end events for the -// session until UnsuppressSessionBrackets. Eval loops run N agent rounds on -// one session, and the hub converges chat tasks on the root session.end, so -// the per-round brackets must not leave this process. -func (r *eventRouter) SuppressSessionBrackets(sessionID string) { - r.mu.Lock() - if r.suppressed == nil { - r.suppressed = map[string]bool{} - } - r.suppressed[sessionID] = true - r.mu.Unlock() -} - -// UnsuppressSessionBrackets lifts a SuppressSessionBrackets drop. -func (r *eventRouter) UnsuppressSessionBrackets(sessionID string) { - r.mu.Lock() - delete(r.suppressed, sessionID) - r.mu.Unlock() -} - -// suppressBrackets reports whether the event is a suppressed session bracket. -func (r *eventRouter) suppressBrackets(e aop.Event) bool { - if e.Type != aop.TypeSessionStart && e.Type != aop.TypeSessionEnd { - return false - } - r.mu.Lock() - defer r.mu.Unlock() - return r.suppressed[e.SessionID] -} - // connect implements the reconnect loop. It calls connectOnce in a loop with // agent.RetryDelay backoff. This is the main entry point for establishing a // persistent WebSocket connection. @@ -187,6 +162,7 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg sendCh := make(chan webproto.Message, 64) done := make(chan struct{}) + writeErr := make(chan error, 1) defer close(done) send := func(m webproto.Message) { @@ -213,16 +189,32 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg // Writer goroutine: sendCh -> WebSocket. go func() { + fail := func(err error) { + select { + case writeErr <- err: + default: + } + // A failed writer must wake the reader so connectOnce returns and the + // outer loop establishes a fresh connection. + _ = conn.Close() + } for { select { case msg, ok := <-sendCh: if !ok { return } - _ = conn.WriteJSON(msg) + if err := conn.WriteJSON(msg); err != nil { + fail(err) + return + } case <-ctx.Done(): - _ = conn.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + if err := conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")); err != nil { + fail(err) + return + } + _ = conn.Close() return case <-done: return @@ -284,9 +276,6 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg statsPayload, _ := json.Marshal(next) send(webproto.Message{Type: "agent.stats", Payload: statsPayload}) } - if router.suppressBrackets(e) { - return - } mu.Lock() if e.Type == aop.TypeSessionStart { if data, err := aop.DecodeData[aop.SessionStartData](e); err == nil && data.ParentSessionID != "" { @@ -324,17 +313,32 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg } // PTY router setup. - ptyRouter := NewPTYRouter(cc.Registry, cc.ExtraPTYOpeners) + var ptyRouter *pty.Router + if cc.PTYRouter != nil { + ptyRouter, err = cc.PTYRouter() + } else { + ptyRouter = NewPTYRouter(cc.Registry) + } + if err != nil { + return err + } defer ptyRouter.Close() - if mgr := RegistryPTYManager(cc.Registry); mgr != nil { - unsub := SubscribePTYSessions(ctx, mgr, ptyRouter, send) - defer unsub() + if cc.PTYRouter == nil { + if mgr := RegistryPTYManager(cc.Registry); mgr != nil { + unsub := SubscribePTYSessions(ctx, mgr, ptyRouter, send) + defer unsub() + } } // Main message dispatch loop. for { var msg webproto.Message if err := conn.ReadJSON(&msg); err != nil { + select { + case writerErr := <-writeErr: + return fmt.Errorf("ws write: %w", writerErr) + default: + } return err } if ctx.Err() != nil { @@ -358,7 +362,15 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg // Inbound AOP carries two executable units on this transport: a // tool.call for the tool-only node surface, and a user message // (the chat surface). Everything else is ignored. - if event, ok := webproto.IsAOPUserMessage(msg); ok { + var event aop.Event + if json.Unmarshal(msg.Payload, &event) != nil { + continue + } + inbound, err := agent.Classify(event) + if err != nil { + continue + } + if inbound.Kind == agent.InboundUserMessage { if cc.Chat == nil { continue } @@ -366,7 +378,8 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg mu.Lock() chatCancels[msg.TaskID] = chatCancel mu.Unlock() - go func(m webproto.Message, ev aop.Event, cCtx context.Context, cCancel context.CancelFunc) { + run := cc.Chat.HandleChat(chatCtx, msg, event, send, router) + go func(m webproto.Message, cCancel context.CancelFunc) { defer cCancel() defer func() { mu.Lock() @@ -379,26 +392,26 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg } mu.Unlock() }() - cc.Chat.HandleChat(cCtx, m, ev, send, router) - }(msg, event, chatCtx, chatCancel) + run() + }(msg, chatCancel) continue } - if !IsAOPToolCall(msg) { + if inbound.Kind != agent.InboundToolCall { continue } taskCtx, cancel := context.WithCancel(ctx) mu.Lock() execTasks[msg.TaskID] = cancel mu.Unlock() - go func(m webproto.Message, tCtx context.Context, tCancel context.CancelFunc) { + go func(m webproto.Message, inbound agent.Inbound, tCtx context.Context, tCancel context.CancelFunc) { defer tCancel() defer func() { mu.Lock() delete(execTasks, m.TaskID) mu.Unlock() }() - HandleAOPToolCall(tCtx, m, cc.Registry, send) - }(msg, taskCtx, cancel) + handleAOPToolCall(tCtx, m, inbound, cc.Registry, send) + }(msg, inbound, taskCtx, cancel) case "exec": taskCtx, cancel := context.WithCancel(ctx) @@ -421,10 +434,20 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg } case "file.read": - go HandleFileRead(msg, send) + go HandleFileRead(msg, cc.Runtime.WorkingDir, send) case "file.write": - go HandleFileWrite(msg, send) + go HandleFileWrite(msg, cc.Runtime.WorkingDir, send) + + case "file.list": + if cc.RunnerFileRPC { + go HandleFileList(msg, cc.Runtime.WorkingDir, send) + } + + case "file.mkdir": + if cc.RunnerFileRPC { + go HandleFileMkdir(msg, cc.Runtime.WorkingDir, send) + } case "config": if cc.Chat != nil { diff --git a/pkg/webagent/event_router_test.go b/pkg/webagent/event_router_test.go deleted file mode 100644 index 1e111e4e..00000000 --- a/pkg/webagent/event_router_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package webagent - -import ( - "encoding/json" - "sync" - "testing" - - "github.com/chainreactors/aiscan/pkg/aop" -) - -func newTestRouter() *eventRouter { - return &eventRouter{ - mu: &sync.Mutex{}, - eventRoute: map[string]string{}, - } -} - -func bracketEvent(typ, sessionID string) aop.Event { - data, _ := json.Marshal(aop.SessionEndData{Stop: "completed"}) - return aop.Event{Type: typ, SessionID: sessionID, Data: data} -} - -func TestSuppressSessionBrackets(t *testing.T) { - router := newTestRouter() - - router.SuppressSessionBrackets("sess-eval") - - if !router.suppressBrackets(bracketEvent(aop.TypeSessionStart, "sess-eval")) { - t.Fatal("session.start should be suppressed") - } - if !router.suppressBrackets(bracketEvent(aop.TypeSessionEnd, "sess-eval")) { - t.Fatal("session.end should be suppressed") - } - - // Non-bracket events and other sessions pass through. - if router.suppressBrackets(bracketEvent(aop.TypeMessage, "sess-eval")) { - t.Fatal("message events must not be suppressed") - } - if router.suppressBrackets(bracketEvent(aop.TypeSessionEnd, "sess-other")) { - t.Fatal("other sessions must not be suppressed") - } - - router.UnsuppressSessionBrackets("sess-eval") - if router.suppressBrackets(bracketEvent(aop.TypeSessionEnd, "sess-eval")) { - t.Fatal("unsuppress should restore forwarding") - } -} diff --git a/pkg/webagent/file.go b/pkg/webagent/file.go index 644fcf73..d21b30fa 100644 --- a/pkg/webagent/file.go +++ b/pkg/webagent/file.go @@ -5,24 +5,29 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "github.com/chainreactors/aiscan/pkg/webproto" ) +func resolveFileRPCPath(baseDir, path string) string { + if filepath.IsAbs(path) || baseDir == "" { + return filepath.Clean(path) + } + return filepath.Clean(filepath.Join(baseDir, path)) +} + // HandleFileRead reads a file from disk and sends its base64-encoded content. -func HandleFileRead(msg webproto.Message, send func(webproto.Message)) { +func HandleFileRead(msg webproto.Message, baseDir string, send func(webproto.Message)) { var payload webproto.FileRPCPayload if len(msg.Payload) > 0 { _ = json.Unmarshal(msg.Payload, &payload) } - payload.Path = strings.TrimSpace(payload.Path) if payload.Path == "" { send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "file path required"}) return } - data, err := os.ReadFile(payload.Path) + data, err := os.ReadFile(resolveFileRPCPath(baseDir, payload.Path)) if err != nil { send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) return @@ -37,12 +42,11 @@ func HandleFileRead(msg webproto.Message, send func(webproto.Message)) { } // HandleFileWrite writes base64-encoded data from a message to a file on disk. -func HandleFileWrite(msg webproto.Message, send func(webproto.Message)) { +func HandleFileWrite(msg webproto.Message, baseDir string, send func(webproto.Message)) { var payload webproto.FileRPCPayload if len(msg.Payload) > 0 { _ = json.Unmarshal(msg.Payload, &payload) } - payload.Path = strings.TrimSpace(payload.Path) if payload.Path == "" { send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "file path required"}) return @@ -53,14 +57,64 @@ func HandleFileWrite(msg webproto.Message, send func(webproto.Message)) { send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "decode file: " + err.Error()}) return } - if err := os.MkdirAll(filepath.Dir(payload.Path), 0o755); err != nil { + resolved := resolveFileRPCPath(baseDir, payload.Path) + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) return } - if err := os.WriteFile(payload.Path, data, 0o644); err != nil { + if err := os.WriteFile(resolved, data, 0o644); err != nil { send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) return } payload.Size = int64(len(data)) send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Payload: webproto.MustJSON(payload)}) } + +// HandleFileList returns a directory listing as structured JSON. It does not +// invoke BashTool or parse terminal output. +func HandleFileList(msg webproto.Message, baseDir string, send func(webproto.Message)) { + var payload webproto.FileRPCPayload + if len(msg.Payload) > 0 { + _ = json.Unmarshal(msg.Payload, &payload) + } + if payload.Path == "" { + payload.Path = "." + } + + entries, err := os.ReadDir(resolveFileRPCPath(baseDir, payload.Path)) + if err != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) + return + } + result := webproto.FileListResult{Path: payload.Path, Entries: make([]webproto.FileEntry, 0, len(entries))} + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) + return + } + result.Entries = append(result.Entries, webproto.FileEntry{ + Name: entry.Name(), + IsDirectory: entry.IsDir(), + Size: info.Size(), + }) + } + send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Payload: webproto.MustJSON(result)}) +} + +// HandleFileMkdir creates a directory using the host filesystem API. +func HandleFileMkdir(msg webproto.Message, baseDir string, send func(webproto.Message)) { + var payload webproto.FileRPCPayload + if len(msg.Payload) > 0 { + _ = json.Unmarshal(msg.Payload, &payload) + } + if payload.Path == "" { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "directory path required"}) + return + } + if err := os.MkdirAll(resolveFileRPCPath(baseDir, payload.Path), 0o755); err != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) + return + } + send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Payload: webproto.MustJSON(payload)}) +} diff --git a/pkg/webagent/file_test.go b/pkg/webagent/file_test.go new file mode 100644 index 00000000..cd3c6e06 --- /dev/null +++ b/pkg/webagent/file_test.go @@ -0,0 +1,110 @@ +package webagent + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func captureFileRPC(t *testing.T, invoke func(func(webproto.Message))) webproto.Message { + t.Helper() + ch := make(chan webproto.Message, 1) + invoke(func(msg webproto.Message) { ch <- msg }) + return <-ch +} + +func TestDefaultAgentRuntimeDoesNotAdvertiseRunnerFileRPCs(t *testing.T) { + for _, capability := range DefaultRuntime().Capabilities { + if capability == "file.list" || capability == "file.mkdir" { + t.Fatalf("regular agent advertised runner-only capability %q", capability) + } + } +} + +func TestHandleFileListReturnsStructuredEntries(t *testing.T) { + base := t.TempDir() + if err := os.WriteFile(filepath.Join(base, "note.txt"), []byte("body"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(base, "nested"), 0o755); err != nil { + t.Fatal(err) + } + + request := webproto.Message{ + Type: "file.list", + TaskID: "list-1", + Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: "."}), + } + response := captureFileRPC(t, func(send func(webproto.Message)) { + HandleFileList(request, base, send) + }) + if response.Type != "complete" { + t.Fatalf("response = %+v", response) + } + var result webproto.FileListResult + if err := json.Unmarshal(response.Payload, &result); err != nil { + t.Fatal(err) + } + if result.Path != "." || len(result.Entries) != 2 { + t.Fatalf("result = %+v", result) + } + byName := map[string]webproto.FileEntry{} + for _, entry := range result.Entries { + byName[entry.Name] = entry + } + if byName["note.txt"].IsDirectory || byName["note.txt"].Size != 4 { + t.Fatalf("file entry = %+v", byName["note.txt"]) + } + if !byName["nested"].IsDirectory { + t.Fatalf("directory entry = %+v", byName["nested"]) + } +} + +func TestNativeFileRPCsResolveRelativeToRuntimeWorkdir(t *testing.T) { + base := t.TempDir() + + mkdirRequest := webproto.Message{ + Type: "file.mkdir", + TaskID: "mkdir-1", + Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: "nested"}), + } + response := captureFileRPC(t, func(send func(webproto.Message)) { + HandleFileMkdir(mkdirRequest, base, send) + }) + if response.Type != "complete" { + t.Fatalf("mkdir response = %+v", response) + } + + writeRequest := webproto.Message{ + Type: "file.write", + TaskID: "write-1", + DataB64: base64.StdEncoding.EncodeToString([]byte("hello")), + Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: filepath.Join("nested", "proof.txt")}), + } + response = captureFileRPC(t, func(send func(webproto.Message)) { + HandleFileWrite(writeRequest, base, send) + }) + if response.Type != "complete" { + t.Fatalf("write response = %+v", response) + } + + readRequest := webproto.Message{ + Type: "file.read", + TaskID: "read-1", + Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: filepath.Join("nested", "proof.txt")}), + } + response = captureFileRPC(t, func(send func(webproto.Message)) { + HandleFileRead(readRequest, base, send) + }) + if response.Type != "complete" { + t.Fatalf("read response = %+v", response) + } + data, err := base64.StdEncoding.DecodeString(response.DataB64) + if err != nil || string(data) != "hello" { + t.Fatalf("read data = %q, err = %v", data, err) + } +} diff --git a/pkg/webagent/identity.go b/pkg/webagent/identity.go index da4a9696..7b49feeb 100644 --- a/pkg/webagent/identity.go +++ b/pkg/webagent/identity.go @@ -21,7 +21,7 @@ func DefaultRuntime() webproto.AgentRuntime { Arch: runtime.GOARCH, PID: os.Getpid(), Capabilities: []string{"repl", "pty", "tmux", "ioa"}, - Meta: map[string]any{"client": "aiscan", "transport": "web-agent"}, + Meta: map[string]any{"client": "aiscan", "transport": "websocket"}, } if host, err := os.Hostname(); err == nil { runtimeInfo.Hostname = host diff --git a/pkg/webagent/pty.go b/pkg/webagent/pty.go index 4fec9271..b13c678b 100644 --- a/pkg/webagent/pty.go +++ b/pkg/webagent/pty.go @@ -11,19 +11,15 @@ import ( "github.com/chainreactors/utils/pty" ) -// NewPTYRouter creates a PTY router with default openers plus any caller-supplied -// extra openers (e.g. a REPL opener from the agent runtime). The caller does not -// need to import core/runner; instead it passes the extra openers map. -func NewPTYRouter(reg *commands.CommandRegistry, extraOpeners map[string]pty.OpenFunc) *pty.Router { +// NewPTYRouter creates the tool-node fallback router. Agent transports receive +// their router directly from AgentRuntime and do not inspect the bash tool. +func NewPTYRouter(reg *commands.CommandRegistry) *pty.Router { mgr := RegistryPTYManager(reg) var baseMgr *pty.Manager if mgr != nil { baseMgr = mgr.Manager } openers := pty.DefaultOpeners(baseMgr, pty.DefaultSessionTimeout, pty.DefaultEnv()) - for k, v := range extraOpeners { - openers[k] = v - } return pty.NewRouter(baseMgr, pty.WithOpeners(openers)) } diff --git a/pkg/webagent/stream.go b/pkg/webagent/stream.go index 0a1fd9c2..85860ad4 100644 --- a/pkg/webagent/stream.go +++ b/pkg/webagent/stream.go @@ -94,7 +94,7 @@ func (t *AgentStatsTracker) Observe(e aop.Event) (webproto.AgentStats, bool) { t.stats.LastEvent = e.Type switch e.Type { case aop.TypeTurnEnd: - if data, err := aop.DecodeData[aop.TurnData](e); err == nil && data.Turn > t.stats.Turns { + if data, err := aop.DecodeData[aop.TurnEndData](e); err == nil && data.Turn > t.stats.Turns { t.stats.Turns = data.Turn } case aop.TypeUsage: diff --git a/pkg/webagent/toolnode.go b/pkg/webagent/toolnode.go index d017f4a7..03d02f44 100644 --- a/pkg/webagent/toolnode.go +++ b/pkg/webagent/toolnode.go @@ -16,8 +16,8 @@ import ( ) // ToolNodeConfig configures a tool-only node: an outbound WebSocket connection -// exposing exec / file.read / file.write / pty plus tool.data / tool.sco -// events, with no LLM provider, agent loop, or IOA dependency. +// exposing exec / native file RPCs / pty plus tool.data / tool.sco events, +// with no LLM provider, agent loop, or IOA dependency. type ToolNodeConfig struct { ServerURL string WSPath string @@ -53,18 +53,20 @@ func RunToolNode(ctx context.Context, cfg ToolNodeConfig) error { logger = telemetry.NopLogger() } runtime := DefaultRuntime() + runtime.Capabilities = append(runtime.Capabilities, "file.read", "file.write", "file.list", "file.mkdir") runtime.Meta = map[string]any{"version": cfg.Version, "mode": "tool"} return connect(ctx, connectionConfig{ - ServerURL: cfg.ServerURL, - WSPath: cfg.WSPath, - Name: name, - Token: cfg.Token, - Registry: cfg.Registry, - DataBus: cfg.DataBus, - SCO: cfg.SCO, - Logger: logger, - Node: protocols.NodeRef{ID: name, Authority: authority}, - Runtime: runtime, + ServerURL: cfg.ServerURL, + WSPath: cfg.WSPath, + Name: name, + Token: cfg.Token, + Registry: cfg.Registry, + DataBus: cfg.DataBus, + SCO: cfg.SCO, + Logger: logger, + Node: protocols.NodeRef{ID: name, Authority: authority}, + Runtime: runtime, + RunnerFileRPC: true, }) } diff --git a/pkg/webagent/toolnode_test.go b/pkg/webagent/toolnode_test.go index beb65119..169827e7 100644 --- a/pkg/webagent/toolnode_test.go +++ b/pkg/webagent/toolnode_test.go @@ -168,6 +168,13 @@ func TestRunToolNodeWireInterop(t *testing.T) { if registered.Runtime.OS == "" { t.Fatalf("register runtime missing OS: %+v", registered.Runtime) } + capabilities := map[string]bool{} + for _, capability := range registered.Runtime.Capabilities { + capabilities[capability] = true + } + if !capabilities["file.list"] || !capabilities["file.mkdir"] { + t.Fatalf("runner runtime missing native file capabilities: %+v", registered.Runtime.Capabilities) + } if len(registered.Tools) != 1 || registered.Tools[0].Function.Name != "bash" { t.Fatalf("register tools = %+v", registered.Tools) } diff --git a/pkg/webagent/upload_test.go b/pkg/webagent/upload_test.go index b1e7c317..33482dfa 100644 --- a/pkg/webagent/upload_test.go +++ b/pkg/webagent/upload_test.go @@ -1,50 +1,46 @@ package webagent import ( + "context" "encoding/base64" "encoding/json" "os" "path/filepath" "strings" + "sync" "testing" + cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/runner" + "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/webproto" ) -// Pending upload notes must drain exactly once per session, stay scoped to their -// own session, and normalize the empty session ID to "default" (matching agentFor) -// so an upload and the chat turn that references it land in the same bucket. -func TestPendingUploadsDrainOncePerSession(t *testing.T) { - m := newChatRuntimeManager(nil) - - m.notePendingUpload("s1", "note-a") - m.notePendingUpload("s1", "note-b") - m.notePendingUpload("s2", "note-c") +type uploadCaptureProvider struct { + mu sync.Mutex + messages []agent.ChatMessage +} - got := m.takePendingUploads("s1") - if got != "note-a\nnote-b" { - t.Fatalf("s1 first drain = %q, want %q", got, "note-a\nnote-b") - } - if again := m.takePendingUploads("s1"); again != "" { - t.Fatalf("s1 second drain = %q, want empty (drain is one-shot)", again) - } - if got := m.takePendingUploads("s2"); got != "note-c" { - t.Fatalf("s2 drain = %q, want %q", got, "note-c") - } +func (p *uploadCaptureProvider) Name() string { return "upload-capture" } - // Empty session ID collapses to "default" on both sides. - m.notePendingUpload("", "note-default") - if got := m.takePendingUploads("default"); got != "note-default" { - t.Fatalf("default drain = %q, want %q", got, "note-default") - } +func (p *uploadCaptureProvider) ChatCompletion(_ context.Context, req *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) { + p.mu.Lock() + p.messages = append([]agent.ChatMessage(nil), req.Messages...) + p.mu.Unlock() + return &agent.ChatCompletionResponse{Choices: []agent.Choice{{Message: agent.NewTextMessage("assistant", "done")}}}, nil } -func TestPendingUploadsNilManagerSafe(t *testing.T) { - var m *chatRuntimeManager - m.notePendingUpload("s1", "note") // must not panic - if got := m.takePendingUploads("s1"); got != "" { - t.Fatalf("nil manager drain = %q, want empty", got) +func (p *uploadCaptureProvider) contains(text string) bool { + p.mu.Lock() + defer p.mu.Unlock() + for _, message := range p.messages { + if message.Content != nil && strings.Contains(*message.Content, text) { + return true + } } + return false } // handleFileUpload must write the bytes to the agent's local disk AND queue a note @@ -52,7 +48,16 @@ func TestPendingUploadsNilManagerSafe(t *testing.T) { // only ever seeing the hub's UI-only "file uploaded" notice and then guessing a // bare filename against its cwd. func TestHandleFileUploadRecordsAbsolutePathForNextTurn(t *testing.T) { - m := newChatRuntimeManager(nil) + rt, err := runner.NewAgentRuntime(context.Background(), &cfg.Option{}, telemetry.NopLogger(), &runner.RuntimeConfig{ + NoOutput: true, + ProviderOptional: true, + }) + if err != nil { + t.Fatal(err) + } + defer rt.Close() + provider := &uploadCaptureProvider{} + rt.SetProvider(provider, agent.ProviderConfig{Provider: provider.Name(), Model: "test"}) const filename = "aiscan_test_upload_probe.txt" const body = "codex public proof\nkey=appImage/probe" @@ -68,7 +73,7 @@ func TestHandleFileUploadRecordsAbsolutePathForNextTurn(t *testing.T) { } var got webproto.Message - handleFileUpload(msg, func(out webproto.Message) { got = out }, m) + handleFileUpload(msg, func(out webproto.Message) { got = out }, rt) // The agent replied with the written path and no error. var res webproto.FileUploadResult @@ -87,12 +92,25 @@ func TestHandleFileUploadRecordsAbsolutePathForNextTurn(t *testing.T) { t.Fatalf("file on disk = %q, err=%v; want %q", data, err, body) } - // The next turn for this session carries the absolute path so `read` resolves. - note := m.takePendingUploads("sess-1") - if !strings.Contains(note, dest) { - t.Fatalf("pending note %q does not carry absolute path %q", note, dest) + event := aop.Event{ + Type: aop.TypeMessage, + TS: "2026-07-22T00:00:00Z", + SessionID: "sess-1", + Agent: "test", + Data: webproto.MustJSON(aop.MessageData{ + MessageID: "m-1", + Role: "user", + Parts: []aop.MessagePart{{Type: aop.PartText, Text: "read the uploaded file"}}, + }), + } + inbound, err := agent.Classify(event) + if err != nil { + t.Fatal(err) + } + if _, err := rt.Execute(context.Background(), "request-1", inbound, nil); err != nil { + t.Fatal(err) } - if !strings.Contains(note, filename) { - t.Fatalf("pending note %q does not name the file %q", note, filename) + if !provider.contains(dest) || !provider.contains(filename) { + t.Fatalf("provider request did not receive upload note for %q", dest) } } diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go index e6014244..8775be07 100644 --- a/pkg/webproto/message.go +++ b/pkg/webproto/message.go @@ -7,6 +7,7 @@ import ( "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/aop" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" ) @@ -133,6 +134,26 @@ type GoalExt struct { NoEcho bool `json:"no_echo,omitempty"` } +// NSWeb is the AOP extension namespace the hub uses to attach its own message +// metadata (originating agent id, persisted metadata) to message events. +const NSWeb = "aiscan.web" + +// WebMessageExt is the hub-owned message extension stored under NSWeb. +type WebMessageExt struct { + AgentID string `json:"agent_id,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// SetWebExt writes the hub message extension onto an event. +func SetWebExt(event *aop.Event, ext WebMessageExt) error { + return aop.SetExt(event, NSWeb, ext) +} + +// GetWebExt reads the hub message extension from an event. +func GetWebExt(event aop.Event) (WebMessageExt, bool, error) { + return aop.Ext[WebMessageExt](event, NSWeb) +} + // IsAOPUserMessage reports whether the message carries an AOP user message // event — the only executable inbound AOP unit — and returns the decoded event. func IsAOPUserMessage(msg Message) (aop.Event, bool) { @@ -150,18 +171,17 @@ func IsAOPUserMessage(msg Message) (aop.Event, bool) { return event, true } -// DecodeGoalExt extracts the aiscan GoalExt block from an inbound AOP event. +// DecodeGoalExt decodes the protocol-owned run and eval namespaces. func DecodeGoalExt(event aop.Event) GoalExt { var goal GoalExt - raw, ok := event.Ext["aiscan"] - if !ok { - return goal + if run, ok, err := aop.Ext[aop.RunControl](event, aop.NSAOP); err == nil && ok { + goal.NoEcho = run.NoEcho + goal.PersistMaxTurns = run.MaxTurns } - data, err := json.Marshal(raw) - if err != nil { - return goal + if control, ok, err := xeval.Get(event); err == nil && ok { + goal.EvalCriteria = control.Criteria + goal.EvalMaxRounds = control.MaxRounds } - _ = json.Unmarshal(data, &goal) return goal } @@ -205,6 +225,21 @@ type FileRPCPayload struct { Size int64 `json:"size,omitempty"` } +// FileEntry is one structured directory entry returned by a file.list RPC. +// Names are transported as JSON strings, so unusual characters never need to +// be inferred from shell output. +type FileEntry struct { + Name string `json:"name"` + IsDirectory bool `json:"isDirectory"` + Size int64 `json:"size"` +} + +// FileListResult is carried in the completion payload for file.list. +type FileListResult struct { + Path string `json:"path"` + Entries []FileEntry `json:"entries"` +} + const TypePTY = "pty" func NewPTYMessage(frame pty.Frame) Message { diff --git a/pkg/webproto/message_test.go b/pkg/webproto/message_test.go index 18a260eb..4f8c0470 100644 --- a/pkg/webproto/message_test.go +++ b/pkg/webproto/message_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/aop" + xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/utils/pty" ) @@ -21,12 +22,9 @@ func TestIsAOPUserMessageDecodesGoalExt(t *testing.T) { Role: "user", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "audit target"}}, }), - Ext: map[string]any{"aiscan": map[string]any{ - "eval_criteria": "find one SQLi", - "eval_max_rounds": 5, - "no_echo": true, - }}, } + _ = aop.SetExt(&event, aop.NSAOP, aop.RunControl{NoEcho: true}) + _ = xeval.Set(&event, xeval.Control{Criteria: "find one SQLi", MaxRounds: 5}) msg := Message{Type: "aop", Payload: MustJSON(event)} decoded, ok := IsAOPUserMessage(msg) diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui index 11c9c384..93b348d4 160000 --- a/web/frontend/cyber-ui +++ b/web/frontend/cyber-ui @@ -1 +1 @@ -Subproject commit 11c9c38492f1fd29a6e76b659c12a30bc93a6ce3 +Subproject commit 93b348d44a78f3519e87bf688446054b1909fd73 diff --git a/web/frontend/e2e/aiscan-web.spec.ts b/web/frontend/e2e/aiscan-web.spec.ts index aea66903..87493c65 100644 --- a/web/frontend/e2e/aiscan-web.spec.ts +++ b/web/frontend/e2e/aiscan-web.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; const API_TOKEN = process.env.ACCESS_KEY || 'test-token'; const LLM_PROVIDER = process.env.LLM_PROVIDER || 'openai'; @@ -10,6 +10,15 @@ function apiHeaders() { return { Authorization: `Bearer ${API_TOKEN}` }; } +async function openAuthenticatedApp(page: Page) { + const login = await page.request.post('/api/auth/login', { + data: { token: API_TOKEN }, + }); + expect(login.ok()).toBeTruthy(); + await page.goto('/'); + await expect(page.locator('button[aria-label="Open settings"]')).toBeVisible(); +} + // --------------------------------------------------------------------------- // 1. Health & Status // --------------------------------------------------------------------------- @@ -50,6 +59,11 @@ test.describe('Auth', () => { const res = await request.get('/api/status', { headers: apiHeaders() }); expect(res.ok()).toBeTruthy(); }); + + test('does not accept tokens from URL query parameters', async ({ request }) => { + const res = await request.get(`/api/status?access_key=${API_TOKEN}`); + expect(res.status()).toBe(401); + }); }); // --------------------------------------------------------------------------- @@ -57,16 +71,16 @@ test.describe('Auth', () => { // --------------------------------------------------------------------------- test.describe('Static Assets', () => { - test('index.html is served with access key script injected', async ({ request }) => { - const res = await request.get(`/?access_key=${API_TOKEN}`); + test('index.html never exposes the access token', async ({ request }) => { + const res = await request.get('/'); expect(res.ok()).toBeTruthy(); const html = await res.text(); - expect(html).toContain('__AISCAN_ACCESS_KEY__'); - expect(html).toContain(API_TOKEN); + expect(html).not.toContain('__AISCAN_ACCESS_KEY__'); + expect(html).not.toContain(API_TOKEN); }); test('JS bundle is served', async ({ request }) => { - const indexRes = await request.get(`/?access_key=${API_TOKEN}`); + const indexRes = await request.get('/'); const html = await indexRes.text(); const jsMatch = html.match(/src="(\/assets\/index-[^"]+\.js)"/); expect(jsMatch).toBeTruthy(); @@ -76,12 +90,36 @@ test.describe('Static Assets', () => { }); // --------------------------------------------------------------------------- -// 4. Page Load & UI Shell +// 4. Login +// --------------------------------------------------------------------------- + +test.describe('Login', () => { + test('validates a token without putting it in URL or localStorage', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Access AIScan' })).toBeVisible(); + + const token = page.getByLabel('Access token'); + await token.fill('wrong-token'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('alert')).toContainText('invalid'); + + await token.fill(API_TOKEN); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.locator('button[aria-label="Open settings"]')).toBeVisible(); + + expect(page.url()).not.toContain(API_TOKEN); + const storedToken = await page.evaluate(() => localStorage.getItem('aiscan-access-key')); + expect(storedToken).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Page Load & UI Shell // --------------------------------------------------------------------------- test.describe('Page Load', () => { test('index page loads and renders AIScan header', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); // Use a specific selector for the brand name in the header const brand = page.locator('header span.font-semibold'); await expect(brand).toBeVisible({ timeout: 10_000 }); @@ -89,12 +127,12 @@ test.describe('Page Load', () => { }); test('header shows model name', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await expect(page.locator('header')).toContainText(/deepseek/i, { timeout: 10_000 }); }); test('LLM health indicator does not show offline or error', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); const header = page.locator('header'); await expect(header).toBeVisible(); // Wait for the async health probe to complete @@ -106,19 +144,19 @@ test.describe('Page Load', () => { }); test('settings button is visible', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); const settingsBtn = page.locator('button[aria-label="Open settings"]'); await expect(settingsBtn).toBeVisible({ timeout: 10_000 }); }); }); // --------------------------------------------------------------------------- -// 5. Config Panel +// 6. Config Panel // --------------------------------------------------------------------------- test.describe('Config Panel', () => { test('opens settings dialog and shows tabs', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await page.locator('button[aria-label="Open settings"]').click(); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5_000 }); @@ -128,7 +166,7 @@ test.describe('Config Panel', () => { }); test('closes settings dialog', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await page.locator('button[aria-label="Open settings"]').click(); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible(); @@ -138,7 +176,7 @@ test.describe('Config Panel', () => { }); test('LLM tab shows Provider and Model fields', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await page.locator('button[aria-label="Open settings"]').click(); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible(); @@ -339,7 +377,7 @@ test.describe('Scans API', () => { test.describe('Chat UI', () => { test('UI renders the main chat area', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await page.waitForLoadState('networkidle'); // The page should have a main content area const main = page.locator('main').first(); @@ -352,7 +390,7 @@ test.describe('Chat UI', () => { }); test('sidebar shows session list or agent nodes', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); // The sidebar should show sessions or agent nodes @@ -363,7 +401,7 @@ test.describe('Chat UI', () => { }); test('can find and interact with chat input', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); @@ -384,19 +422,14 @@ test.describe('Chat UI', () => { test.describe('Theme', () => { test('can toggle between light and dark theme', async ({ page }) => { - await page.goto('/'); + await openAuthenticatedApp(page); await page.waitForLoadState('networkidle'); const initialDark = await page.evaluate(() => document.documentElement.classList.contains('dark') ); - // The theme toggle is one of the last buttons in the header - // Look for it by its sun/moon icon or aria label - const headerButtons = page.locator('header button'); - const count = await headerButtons.count(); - // Theme toggle is typically the last or second-to-last button - const themeBtn = headerButtons.nth(count - 1); + const themeBtn = page.locator('[data-sidebar-theme-toggle] button'); await themeBtn.click(); await page.waitForTimeout(500); diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index a3210632..dcf4fe93 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -38,7 +38,8 @@ "react-syntax-highlighter": "^15.6.1", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "yaml": "^2.8.1" }, "devDependencies": { "@playwright/test": "^1.61.1", @@ -6758,6 +6759,18 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/web/frontend/package.json b/web/frontend/package.json index 644d244e..4a5ba1b3 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -41,7 +41,8 @@ "react-syntax-highlighter": "^15.6.1", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "yaml": "^2.8.1" }, "devDependencies": { "@playwright/test": "^1.61.1", diff --git a/web/frontend/playwright.config.ts b/web/frontend/playwright.config.ts index 0a15ee90..1ca79740 100644 --- a/web/frontend/playwright.config.ts +++ b/web/frontend/playwright.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from '@playwright/test'; const baseURL = process.env.BASE_URL || 'http://127.0.0.1:18080'; -const accessKey = process.env.ACCESS_KEY || 'test-token'; export default defineConfig({ testDir: './e2e', @@ -11,7 +10,7 @@ export default defineConfig({ retries: 0, reporter: [['list'], ['html', { open: 'never' }]], use: { - baseURL: `${baseURL}?access_key=${accessKey}`, + baseURL, headless: true, viewport: { width: 1280, height: 720 }, actionTimeout: 10_000, diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 5db598c8..ba936596 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback, useMemo, lazy, Suspense, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { Box, Menu, Monitor, Network, Settings } from 'lucide-react' -import LanguageToggle from './components/LanguageToggle' +import { Box, LogOut, Menu, Monitor, Network, Settings } from 'lucide-react' import SessionList from './components/SessionList' import ChatPanel from './components/ChatPanel' import ConfigPanel from './components/ConfigPanel' @@ -11,16 +10,13 @@ import AssetMentionPicker from './components/AssetMentionPicker' import LLMHealth from './components/LLMHealth' import QuickConnect from './components/QuickConnect' import BrandLogo from './components/brand/BrandLogo' -// Lazy: the agent terminal drags in @xterm (~its own chunk) but only renders -// when a node's console is opened — keep it out of the first-paint bundle. -const AgentTerminal = lazy(() => import('./components/terminal')) const IOAConsole = lazy(() => import('./components/IOAConsole')) -import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, ThemeToggle, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, useConfirm } from '@cyber/ui' -import { ThemeProvider, useTheme } from '@cyber/theme' -import { activateLLMProfile, getConfigStatus, getStatus, listSCONodes } from './api' +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, useConfirm } from '@cyber/ui' +import { ThemeProvider } from '@cyber/theme' +import { activateLLMProfile, getConfigStatus, getStatus, listSCONodes, logout } from './api' import type { LLMProfileStatus, ServerStatus } from './api' import type { SCONode } from '@cyber/cstx-easm' -import { useChatSession, agentNodeKey } from './hooks/useChatSession' +import { useChatSession } from './hooks/useChatSession' import { usePolling } from './hooks/usePolling' import { isSessionAgentOnline } from './lib/session-agent' import type { IOAConsoleTarget } from './lib/ioa-navigation' @@ -66,10 +62,6 @@ export default function App() { const [sidebarOpen, setSidebarOpen] = useState(getInitialSidebarOpen) // Bumped after a settings save so the header LLM health dot re-probes. const [healthNonce, setHealthNonce] = useState(0) - // Track the terminal target by the node's STABLE key, not its transient agent - // id: the hub mints a fresh id on every reconnect, so keying on id would drop - // the terminal (and never restore it) when a node bounces to reload config. - const [terminalNodeKey, setTerminalNodeKey] = useState(null) const openIOAConsole = useCallback((target?: IOAConsoleTarget) => { setIOAConsoleTarget(target ?? null) @@ -125,8 +117,6 @@ export default function App() { ) }, [scoNodes]) - const terminalAgent = terminalNodeKey ? chat.agents.find((a) => agentNodeKey(a) === terminalNodeKey) ?? null : null - const model = serverStatus?.llm_model || chat.agents.find((a) => a.status?.model)?.status?.model || 'cortex' const handleSwitchLLM = useCallback(async (profileID: string) => { @@ -160,20 +150,20 @@ export default function App() { } function handleOpenTerminal(agentID: string) { - const a = chat.agents.find((x) => x.id === agentID) - setTerminalNodeKey(a ? agentNodeKey(a) : agentID) + setAgentPanelFocusID(agentID) + setAgentPanelOpen(true) chat.selectAgent(agentID) closeSidebarOnMobile() } function handleSelectSession(id: string) { - setTerminalNodeKey(null) + setAgentPanelOpen(false) chat.selectSession(id) closeSidebarOnMobile() } function handleCreateSession(agentID: string) { - setTerminalNodeKey(null) + setAgentPanelOpen(false) chat.createSession(agentID) closeSidebarOnMobile() } @@ -233,8 +223,9 @@ export default function App() { setConfigOpen(true)}> - - + { void logout() }}> + +
@@ -246,7 +237,7 @@ export default function App() { sessions={chat.sessions} activeSessionID={chat.activeSessionID} selectedAgentID={chat.selectedAgentID} - terminalAgentID={terminalAgent?.id ?? null} + terminalAgentID={agentPanelOpen ? agentPanelFocusID : null} onSelectAgent={chat.selectAgent} onSelectSession={handleSelectSession} onCreateSession={handleCreateSession} @@ -254,38 +245,28 @@ export default function App() { onOpenTerminal={handleOpenTerminal} /> - {terminalAgent ? ( -
-
- }> - - -
-
- ) : ( - ({ id: a.id, name: a.name }))} - onCreateSession={handleCreateSession} - onOpenTerminal={handleOpenTerminal} - onOpenIOA={openIOAConsole} - mentionables={mentionables} - renderMentionPopup={renderMentionPopup} - injectText={composerSeed} - onSend={chat.sendMessage} - onPause={chat.cancelMessage} - onClearError={chat.clearError} - /> - )} + ({ id: a.id, name: a.name }))} + onCreateSession={handleCreateSession} + onOpenTerminal={handleOpenTerminal} + onOpenIOA={openIOAConsole} + mentionables={mentionables} + renderMentionPopup={renderMentionPopup} + injectText={composerSeed} + onSend={chat.sendMessage} + onPause={chat.cancelMessage} + onClearError={chat.clearError} + /> @@ -327,11 +308,6 @@ export default function App() { ) } -function ConnectedThemeToggle() { - const { isDark, toggle } = useTheme() - return -} - function LLMProfileSwitcher({ profiles, activeProfileID, diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index fd2ab82a..a59a64cd 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -132,6 +132,41 @@ export interface ServerStatus { ioa_url?: string; } +export const AUTH_REQUIRED_EVENT = 'aiscan:auth-required' + +export class APIError extends Error { + constructor(message: string, public readonly status: number) { + super(message) + this.name = 'APIError' + } +} + +export async function getAuthSession(): Promise { + const res = await fetch('/api/auth/session', { cache: 'no-store' }) + if (!res.ok) return false + const body = await res.json() as { authenticated?: boolean } + return body.authenticated === true +} + +export async function login(token: string): Promise { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + if (!res.ok) { + throw new APIError(await errorMessage(res, 'Login failed'), res.status) + } +} + +export async function logout(): Promise { + try { + await fetch('/api/auth/logout', { method: 'POST' }) + } finally { + notifyAuthRequired() + } +} + export interface AgentInfo { id: string; name: string; @@ -518,27 +553,48 @@ export async function deleteScan(id: string): Promise { await apiJSON(`/api/scans/${encodeURIComponent(id)}`, 'Failed to delete scan', { method: 'DELETE' }); } +// subscribeSSE is the module-private EventSource primitive: one place that +// wires named handlers, extracts the data string, and manages lifecycle. +// Handlers receive the raw data string (possibly empty) and decide on parsing. +function subscribeSSE( + url: string, + handlers: Record void>, + opts?: { onOpen?: () => void; onError?: () => void }, +): EventSource { + const es = new EventSource(url) + if (opts?.onOpen) es.addEventListener('open', () => opts.onOpen!()) + if (opts?.onError) es.addEventListener('error', () => opts.onError!()) + for (const [type, handler] of Object.entries(handlers)) { + es.addEventListener(type, (e: Event) => { + const data = 'data' in e ? (e as MessageEvent).data : undefined + if (typeof data !== 'string') return + handler(data, e) + }) + } + return es +} + export function subscribeScanEvents( id: string, onEvent: (event: ScanEvent) => void, ): () => void { - const es = new EventSource(authURL(`/api/scans/${encodeURIComponent(id)}/events`)); - const handler = (type: RawScanEventType) => (e: Event) => { - const data = 'data' in e ? (e as MessageEvent).data : undefined; - if (typeof data !== 'string' || data === '') { + let es: EventSource | null = null + const close = () => es?.close() + const handler = (type: RawScanEventType) => (data: string) => { + if (data === '') { if (type === 'error') { void getScan(id) .then((job) => { if (job.status === 'completed') { onEvent({ type: 'complete', scan_id: id, status: job.status }); - es.close(); + close(); } else if (job.status === 'failed' || job.status === 'canceled') { onEvent({ type: 'error', scan_id: id, error: job.error || `Scan ${job.status}`, }); - es.close(); + close(); } }) .catch(() => {}); @@ -562,17 +618,19 @@ export function subscribeScanEvents( onEvent(event); if (event.type === 'complete' || event.type === 'error') { - es.close(); + close(); } }; - es.addEventListener('progress', handler('progress')); - es.addEventListener('status', handler('status')); - es.addEventListener('stats', handler('stats')); - es.addEventListener('complete', handler('complete')); - es.addEventListener('error', handler('error')); - es.addEventListener('output', handler('output')); + es = subscribeSSE(`/api/scans/${encodeURIComponent(id)}/events`, { + progress: handler('progress'), + status: handler('status'), + stats: handler('stats'), + complete: handler('complete'), + error: handler('error'), + output: handler('output'), + }); - return () => es.close(); + return () => es?.close(); } // --- Chat session types --- @@ -703,12 +761,8 @@ export interface FileUploadResult { export async function uploadChatFile(sessionID: string, file: File): Promise { const form = new FormData() form.append('file', file) - const headers: Record = {} - const key = getAccessKey() - if (key) headers['Authorization'] = `Bearer ${key}` - const resp = await fetch(`/api/chat/sessions/${encodeURIComponent(sessionID)}/upload`, { + const resp = await authenticatedFetch(`/api/chat/sessions/${encodeURIComponent(sessionID)}/upload`, { method: 'POST', - headers, body: form, }) if (!resp.ok) { @@ -726,10 +780,7 @@ export async function listChatMessages(sessionID: string): Promise { - const headers: Record = {} - const key = getAccessKey() - if (key) headers['Authorization'] = `Bearer ${key}` - const res = await fetch(`/api/scans/${encodeURIComponent(scanID)}/report?lang=${encodeURIComponent(lang)}`, { headers }) + const res = await authenticatedFetch(`/api/scans/${encodeURIComponent(scanID)}/report?lang=${encodeURIComponent(lang)}`) if (!res.ok) return '' return res.text() } @@ -741,54 +792,48 @@ export function subscribeChatEvents( onAOP?: (event: AOPEvent) => void, onOpen?: () => void, ): () => void { - const url = authURL(`/api/chat/sessions/${encodeURIComponent(sessionID)}/events`) - const es = new EventSource(url) - - es.addEventListener('open', () => onOpen?.()) - const eventTypes: ChatEventType[] = [ 'message', 'scan_started', 'scan_progress', 'scan_complete', 'scan_error', 'agent_joined', 'session_cleared', 'error', ] + const handlers: Record void> = {} for (const type of eventTypes) { - es.addEventListener(type, (e: Event) => { - const data = 'data' in e ? (e as MessageEvent).data : undefined - if (typeof data !== 'string' || data === '') return + handlers[type] = (data: string) => { + if (data === '') return try { const parsed = JSON.parse(data) onEvent({ ...parsed, type }) } catch { onEvent({ type, session_id: sessionID, data } as ChatEvent) } - }) + } } - - es.addEventListener('aop', (e: Event) => { - const data = 'data' in e ? (e as MessageEvent).data : undefined - if (typeof data !== 'string' || data === '') return + handlers['aop'] = (data: string) => { + if (data === '') return try { const parsed = JSON.parse(data) as AOPEvent if (parsed.session_id && parsed.agent && parsed.type && parsed.ts && parsed.data) onAOP?.(parsed) } catch { // Ignore malformed protocol frames; platform events continue normally. } - }) + } - es.addEventListener('error', () => { - // EventSource reconnects automatically. Reconcile platform-domain state - // from REST; AOP itself is replayed from durable storage by the SSE endpoint. - onReconnect?.() - }) + // EventSource reconnects automatically. Reconcile platform-domain state + // from REST; AOP itself is replayed from durable storage by the SSE endpoint. + const es = subscribeSSE( + `/api/chat/sessions/${encodeURIComponent(sessionID)}/events`, + handlers, + { onOpen, onError: onReconnect }, + ) return () => es.close() } export function agentTerminalWebSocketURL(agentID: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const base = `${protocol}//${window.location.host}/api/agents/${encodeURIComponent(agentID)}/terminal/ws`; - return authURL(base); + return `${protocol}//${window.location.host}/api/agents/${encodeURIComponent(agentID)}/terminal/ws`; } // ── SCO Nodes ── @@ -823,7 +868,7 @@ export async function importSCOData( form.append('file', file); form.append('artifact', artifact); form.append('scan_id', scanId); - const resp = await fetch('/api/sco/import', { method: 'POST', body: form }); + const resp = await authenticatedFetch('/api/sco/import', { method: 'POST', body: form }); if (!resp.ok) { const err = await resp.json().catch(() => ({ error: resp.statusText })); throw new Error(err.error || `Import failed: ${resp.status}`); @@ -831,31 +876,24 @@ export async function importSCOData( return resp.json(); } -function getAccessKey(): string { - return (window as any).__AISCAN_ACCESS_KEY__ || '' -} - -// For SSE/WebSocket, append access_key as query param since EventSource/WebSocket can't set headers. -function authURL(path: string): string { - const key = getAccessKey() - if (!key) return path - const sep = path.includes('?') ? '&' : '?' - return `${path}${sep}access_key=${encodeURIComponent(key)}` -} - async function apiJSON(path: string, fallbackMessage: string, init?: RequestInit): Promise { - const key = getAccessKey() - const headers = new Headers(init?.headers) - if (key) { - headers.set('Authorization', `Bearer ${key}`) - } - const res = await fetch(path, { ...init, headers }); + const res = await authenticatedFetch(path, init) if (!res.ok) { - throw new Error(await errorMessage(res, fallbackMessage)); + throw new APIError(await errorMessage(res, fallbackMessage), res.status) } return res.json(); } +async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const res = await fetch(input, init) + if (res.status === 401) notifyAuthRequired() + return res +} + +function notifyAuthRequired() { + window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT)) +} + async function errorMessage(res: Response, fallback: string) { try { const body = await res.json(); diff --git a/web/frontend/src/components/AgentPanel.tsx b/web/frontend/src/components/AgentPanel.tsx index db8ee688..967b3d1e 100644 --- a/web/frontend/src/components/AgentPanel.tsx +++ b/web/frontend/src/components/AgentPanel.tsx @@ -1,11 +1,26 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' -import { Monitor, Search } from 'lucide-react' +import { LoaderCircle, Monitor, Search } from 'lucide-react' import type { AgentInfo } from '../api' -// Lazy — same @xterm chunk App splits; a static import here would pull it back -// into the first-paint bundle. -const AgentTerminal = lazy(() => import('./terminal')) +const terminalChunkReloadKey = 'aiscan-terminal-chunk-reload' +const loadAgentTerminal = () => import('./terminal') +async function loadAgentTerminalWithRecovery() { + try { + const module = await loadAgentTerminal() + window.sessionStorage.removeItem(terminalChunkReloadKey) + return module + } catch (error) { + if (!window.sessionStorage.getItem(terminalChunkReloadKey)) { + window.sessionStorage.setItem(terminalChunkReloadKey, '1') + window.location.reload() + return new Promise>>(() => {}) + } + window.sessionStorage.removeItem(terminalChunkReloadKey) + throw error + } +} +const AgentTerminal = lazy(loadAgentTerminalWithRecovery) import { Badge, EmptyState, @@ -57,7 +72,12 @@ export default function AgentPanel({ open, agents: rosterAgents, focusAgentID, o )}
{selected && ( - }> + + )} diff --git a/web/frontend/src/components/AuthGate.tsx b/web/frontend/src/components/AuthGate.tsx new file mode 100644 index 00000000..c9f10f87 --- /dev/null +++ b/web/frontend/src/components/AuthGate.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState, type FormEvent, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { Eye, EyeOff, KeyRound, LoaderCircle } from 'lucide-react' +import { Button, Input, TooltipProvider } from '@cyber/ui' +import { APIError, AUTH_REQUIRED_EVENT, getAuthSession, login } from '../api' +import BrandLogo from './brand/BrandLogo' +import LanguageToggle from './LanguageToggle' + +type AuthState = 'checking' | 'authenticated' | 'unauthenticated' + +export default function AuthGate({ children }: { children: ReactNode }) { + const [state, setState] = useState('checking') + + useEffect(() => { + let active = true + const requireAuth = () => setState('unauthenticated') + window.addEventListener(AUTH_REQUIRED_EVENT, requireAuth) + + void getAuthSession() + .then((authenticated) => { if (active) setState(authenticated ? 'authenticated' : 'unauthenticated') }) + .catch(() => { if (active) setState('unauthenticated') }) + + return () => { + active = false + window.removeEventListener(AUTH_REQUIRED_EVENT, requireAuth) + } + }, []) + + if (state === 'checking') return + if (state === 'unauthenticated') { + return setState('authenticated')} /> + } + return children +} + +function AuthLoading() { + const { t } = useTranslation('app') + return ( +
+
+
+
+ ) +} + +function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) { + const { t } = useTranslation('app') + const [token, setToken] = useState('') + const [showToken, setShowToken] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + const value = token.trim() + if (!value) { + setError(t('loginTokenRequired')) + return + } + + setSubmitting(true) + setError('') + try { + await login(value) + onAuthenticated() + } catch (err) { + setError(err instanceof APIError && err.status === 401 ? t('loginInvalid') : t('loginUnavailable')) + } finally { + setSubmitting(false) + } + } + + return ( + +
+
- + {handoff ? ( + + ) : ( + + )}
diff --git a/web/frontend/src/components/SessionList.tsx b/web/frontend/src/components/SessionList.tsx index 642d6b2c..dac0c525 100644 --- a/web/frontend/src/components/SessionList.tsx +++ b/web/frontend/src/components/SessionList.tsx @@ -8,9 +8,10 @@ import { } from 'lucide-react' import { Button, Callout, Tooltip, TooltipTrigger, TooltipContent, - Popover, PopoverTrigger, PopoverContent, EmptyState, StatusDot, + Popover, PopoverTrigger, PopoverContent, EmptyState, StatusDot, ThemeToggle, } from '@cyber/ui' -import { cn } from '@cyber/theme' +import { cn, useTheme } from '@cyber/theme' +import LanguageToggle from './LanguageToggle' import { launchLocalAgent, listLocalAgents, stopLocalAgent } from '../api' import type { AgentInfo, ChatSession, LocalAgentView } from '../api' import { agentActivity } from '../lib/agentActivity' @@ -197,11 +198,31 @@ export default function SessionList({ ))} )} + + ) } +function SidebarPreferences({ expanded }: { expanded: boolean }) { + const { isDark, toggle } = useTheme() + + return ( +
+ +
+ +
+
+ ) +} + function AgentGroup({ agent, sessions, isSelected, activeSessionID, terminalActive, onSelectAgent, onSelectSession, onCreateSession, onDeleteSession, onOpenTerminal, diff --git a/web/frontend/src/components/chat/SubagentRunCard.tsx b/web/frontend/src/components/chat/SubagentRunCard.tsx new file mode 100644 index 00000000..96f7f7eb --- /dev/null +++ b/web/frontend/src/components/chat/SubagentRunCard.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' +import { CheckCircle2, GitBranch, Loader2, OctagonX, XCircle } from 'lucide-react' +import { DisclosureCard } from '@cyber/ui' +import { cn } from '@cyber/theme' +import type { ViewerTimelineItem } from '@/viewer' + +type SubagentRun = Extract + +export default function SubagentRunCard({ run, children }: { run: SubagentRun; children: ReactNode }) { + const running = run.status === 'starting' || run.status === 'running' + const failed = run.status === 'failed' || run.status === 'canceled' + const StatusIcon = running ? Loader2 : failed ? (run.status === 'canceled' ? OctagonX : XCircle) : CheckCircle2 + + return ( + + + {run.name} + {run.mode && ( + + {run.mode} + + )} + + + {run.status} + + + )} + collapsedPreview={( +

+ {run.prompt} +

+ )} + > +
+
+
Task
+

{run.prompt}

+
+ {run.sessionID && ( +
session {run.sessionID}
+ )} + {run.items.length > 0 &&
{children}
} +
+
+ ) +} diff --git a/web/frontend/src/components/terminal/AgentTerminal.tsx b/web/frontend/src/components/terminal/AgentTerminal.tsx index 4762c4a0..0cb730ef 100644 --- a/web/frontend/src/components/terminal/AgentTerminal.tsx +++ b/web/frontend/src/components/terminal/AgentTerminal.tsx @@ -44,6 +44,7 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { const cleanupRef = useRef<(() => void) | null>(null) const termRef = useRef(null) const fitRef = useRef(null) + const desiredSessionIDRef = useRef('') const [terminalReadySeq, setTerminalReadySeq] = useState(0) const replSession = useMemo(() => { @@ -78,10 +79,6 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { }, []) function connectWebSocket(term: XTerm, fit: FitAddon) { - // Switching agents reuses this same xterm instance (the panel does not - // remount AgentTerminal per agent), so wipe the previous agent's screen - // buffer before attaching to the new one — otherwise the prior REPL output - // stays visible under the newly selected agent's session. term.reset() setStatus('connecting') setSessions([]) @@ -91,43 +88,75 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { sessionsRef.current = [] seenActivityRef.current = {} activityReadyRef.current = false + desiredSessionIDRef.current = '' const ws = new WebSocket(agentTerminalWebSocketURL(agent.id)) wsRef.current = ws - const send = (message: Record) => { + const size = () => ({ cols: term.cols, rows: term.rows }) + const fitTerminal = () => { + try { fit.fit() } catch {} + } + const sendTo = (message: Record) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message)) } - const size = () => ({ cols: term.cols, rows: term.rows }) + const requestDesiredSession = (knownSessions: PTYSession[] = sessionsRef.current) => { + if (ws.readyState !== WebSocket.OPEN) return + const desiredID = desiredSessionIDRef.current + const desired = desiredID + ? knownSessions.find((s) => s.id === desiredID && (!s.state || s.state === 'running')) + : null + fitTerminal() + term.reset() + if (desired?.id) { + sendTo({ type: 'attach', session_id: desired.id, ...size() }) + return + } + desiredSessionIDRef.current = '' + const repl = knownSessions.find((s) => s.state === 'running' && s.kind === 'repl' && (s.name === REPL_NAME || !s.name)) + || knownSessions.find((s) => s.state === 'running' && s.kind === 'repl') + if (repl?.id) { + sendTo({ type: 'attach', session_id: repl.id, ...size() }) + } + } const dataDisposable = term.onData((data) => { if (!activeRef.current) return - send({ type: 'input', session_id: activeRef.current, data: encodeTerminalData(data) }) + sendTo({ type: 'input', session_id: activeRef.current, data: encodeTerminalData(data) }) }) const resizeDisposable = term.onResize(({ cols, rows }) => { if (!activeRef.current) return - send({ type: 'resize', session_id: activeRef.current, cols, rows }) + sendTo({ type: 'resize', session_id: activeRef.current, cols, rows }) }) ws.onopen = () => { setStatus('connected') - send({ type: 'open', kind: 'repl', name: REPL_NAME, singleton: true, ...size() }) - send({ type: 'list' }) + sendTo({ type: 'list' }) } ws.onmessage = (event) => { const msg = parsePTYFrame(event.data) if (!msg) return switch (msg.type) { - case 'sessions': - applySessions(sessionsFromFrame(msg)) + case 'sessions': { + const next = sessionsFromFrame(msg) + applySessions(next) + if (!activeRef.current) requestDesiredSession(next) break + } case 'opened': case 'attached': { const session = sessionFromFrame(msg) const id = msg.session_id || session?.id || '' if (session) rememberSession(session) - if (id) { activeRef.current = id; setActiveID(id); markSessionRead(id, session) } + if (id) { + activeRef.current = id + desiredSessionIDRef.current = id + setActiveID(id) + markSessionRead(id, session) + } setStatus('connected') - send({ type: 'list' }) + fitTerminal() + if (id) sendTo({ type: 'resize', session_id: id, ...size() }) + sendTo({ type: 'list' }) term.focus() break } @@ -146,38 +175,34 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { if (session) rememberSession(session) if (id === activeRef.current) { markSessionRead(id, current) - if (current?.kind === 'repl') { - setStatus('connected') - term.reset() - send({ type: 'open', kind: 'repl', name: REPL_NAME, singleton: true, ...size() }) - send({ type: 'list' }) - break - } - setStatus('closed') - term.write('\r\n[session closed]\r\n') + activeRef.current = '' + setActiveID('') + desiredSessionIDRef.current = '' + requestDesiredSession() } - send({ type: 'list' }) + sendTo({ type: 'list' }) break } case 'detached': activeRef.current = '' setActiveID('') + setStatus('connecting') break case 'error': + if (/no such session/i.test(msg.error || '')) { + desiredSessionIDRef.current = '' + requestDesiredSession() + break + } setStatus('error') term.write(`\r\n[pty error] ${msg.error || 'unknown error'}\r\n`) break } } ws.onerror = () => setStatus('error') - ws.onclose = () => setStatus((c) => (c === 'error' ? c : 'closed')) + ws.onclose = () => setStatus((current) => current === 'error' ? current : 'closed') return () => { - // Detach every handler first: this socket and the next agent's share one - // terminal (and shared `status` state), so a late frame — or the close/ - // error handshake firing asynchronously after the next socket has already - // connected — must not paint stale output or clobber the live connection's - // status with 'closed'/'error'. ws.onmessage = null ws.onclose = null ws.onerror = null @@ -256,6 +281,7 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { function attachSession(session: PTYSession) { if (!session.id) return + desiredSessionIDRef.current = session.id termRef.current?.reset() activeRef.current = session.id setActiveID(session.id) @@ -265,11 +291,12 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { function attachRepl() { if (replSession) { attachSession(replSession); return } - termRef.current?.reset() - send({ type: 'open', kind: 'repl', name: REPL_NAME, singleton: true, ...terminalSize() }) + desiredSessionIDRef.current = '' + send({ type: 'list' }) } function openShell() { + desiredSessionIDRef.current = '' termRef.current?.reset() activeRef.current = '' setActiveID('') diff --git a/web/frontend/src/i18n/locales/en/agent.ts b/web/frontend/src/i18n/locales/en/agent.ts index 548fd124..44e72b36 100644 --- a/web/frontend/src/i18n/locales/en/agent.ts +++ b/web/frontend/src/i18n/locales/en/agent.ts @@ -8,6 +8,7 @@ export default { failedToLoadAgents: 'Failed to load agents', filterAgents: 'Filter agents…', noMatchingAgents: 'No matching agents.', + openingTerminal: 'Opening terminal…', busy: 'busy', idle: 'idle', justNow: 'just now', diff --git a/web/frontend/src/i18n/locales/en/app.ts b/web/frontend/src/i18n/locales/en/app.ts index ce8ff6ba..077cf861 100644 --- a/web/frontend/src/i18n/locales/en/app.ts +++ b/web/frontend/src/i18n/locales/en/app.ts @@ -36,4 +36,18 @@ export default { llmHealthSettings: 'Click to open settings', // Mobile session drawer (opened from the header menu button) openSessions: 'Chat history', + authChecking: 'Checking session…', + loginTitle: 'Access AIScan', + loginDescription: 'Enter the access token generated at startup or configured on the command line.', + loginTokenLabel: 'Access token', + loginTokenPlaceholder: 'Enter token', + loginTokenRequired: 'Enter an access token', + loginShowToken: 'Show token', + loginHideToken: 'Hide token', + loginSecurityHint: 'The token is used only for login and kept by the server in an HttpOnly session.', + loginSubmit: 'Sign in', + loginSubmitting: 'Verifying…', + loginInvalid: 'The token is invalid. Check it and try again.', + loginUnavailable: 'The server could not be reached. Try again shortly.', + logout: 'Sign out', } diff --git a/web/frontend/src/i18n/locales/zh/agent.ts b/web/frontend/src/i18n/locales/zh/agent.ts index d1984f48..ee7728f9 100644 --- a/web/frontend/src/i18n/locales/zh/agent.ts +++ b/web/frontend/src/i18n/locales/zh/agent.ts @@ -8,6 +8,7 @@ export default { failedToLoadAgents: '加载 Agent 失败', filterAgents: '筛选 Agent…', noMatchingAgents: '无匹配 Agent。', + openingTerminal: '正在打开终端…', busy: '忙碌', idle: '空闲', justNow: '刚刚', diff --git a/web/frontend/src/i18n/locales/zh/app.ts b/web/frontend/src/i18n/locales/zh/app.ts index 4bfa463c..4bb10a3c 100644 --- a/web/frontend/src/i18n/locales/zh/app.ts +++ b/web/frontend/src/i18n/locales/zh/app.ts @@ -36,4 +36,18 @@ export default { llmHealthSettings: '点击打开设置', // 手机端会话抽屉(顶栏汉堡打开) openSessions: '对话历史', + authChecking: '正在验证登录状态…', + loginTitle: '访问 AIScan', + loginDescription: '请输入服务启动时生成或通过命令行配置的访问 Token。', + loginTokenLabel: '访问 Token', + loginTokenPlaceholder: '输入 Token', + loginTokenRequired: '请输入访问 Token', + loginShowToken: '显示 Token', + loginHideToken: '隐藏 Token', + loginSecurityHint: 'Token 仅用于本次登录,并由服务器保存在 HttpOnly 会话中。', + loginSubmit: '登录', + loginSubmitting: '正在验证…', + loginInvalid: 'Token 无效,请检查后重试。', + loginUnavailable: '暂时无法连接服务器,请稍后重试。', + logout: '退出登录', } diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx index f34ea3d2..5d4d41fb 100644 --- a/web/frontend/src/main.tsx +++ b/web/frontend/src/main.tsx @@ -6,6 +6,7 @@ import './i18n' import { useTranslation } from 'react-i18next' import { registerChatExtensions } from './lib/chat-extensions' import ErrorBoundary from './components/ErrorBoundary' +import AuthGate from './components/AuthGate' import { ConfirmProvider } from '@cyber/ui' import './index.css' @@ -40,7 +41,9 @@ root.render( - + + + , diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts index 4e28a69d..143a8484 100644 --- a/web/frontend/vite.config.ts +++ b/web/frontend/vite.config.ts @@ -4,27 +4,6 @@ import path from 'path' const backendURL = 'http://127.0.0.1:8080' -// The production Go server injects window.__AISCAN_ACCESS_KEY__ into index.html. -// Vite serves its own index during development, so mirror that existing inline -// script from the local backend; otherwise every proxied API call is anonymous. -const devAccessKeyPlugin = { - name: 'aiscan-dev-access-key', - apply: 'serve' as const, - async transformIndexHtml(html: string) { - try { - const response = await fetch(backendURL) - if (!response.ok) return html - const backendHTML = await response.text() - const injection = backendHTML.match( - /`) + }) + mux.HandleFunc("/admin", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Server", "nginx/1.25.4") + fmt.Fprint(w, "AISCAN_ADMIN_ENDPOINT") + }) + mux.HandleFunc("/app.js", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + fmt.Fprint(w, `fetch('/api/status')`) + }) + mux.HandleFunc("/api/status", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"ok"}`) + }) + mux.HandleFunc("/poc", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Server", "nginx/1.25.4") + fmt.Fprint(w, "AISCAN_REGRESSION_MARKER") + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +func newRedisAuthFixture(t *testing.T, password string) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("start redis fixture: %v", err) + } + var wg sync.WaitGroup + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := listener.Accept() + if err != nil { + return + } + wg.Add(1) + go func() { + defer wg.Done() + handleRedisConnection(conn, password) + }() + } + }() + t.Cleanup(func() { + _ = listener.Close() + <-done + wg.Wait() + }) + return listener.Addr().String() +} + +func handleRedisConnection(conn net.Conn, password string) { + defer conn.Close() + reader := bufio.NewReader(conn) + authed := false + for { + command, err := readRESPCommand(reader) + if err != nil { + return + } + if len(command) == 0 { + return + } + switch strings.ToUpper(command[0]) { + case "AUTH": + if len(command) == 2 && command[1] == password { + authed = true + _, _ = fmt.Fprint(conn, "+OK\r\n") + } else { + _, _ = fmt.Fprint(conn, "-ERR invalid password\r\n") + } + case "PING": + if authed { + _, _ = fmt.Fprint(conn, "+PONG\r\n") + } else { + _, _ = fmt.Fprint(conn, "-NOAUTH Authentication required.\r\n") + } + case "QUIT": + _, _ = fmt.Fprint(conn, "+OK\r\n") + return + default: + _, _ = fmt.Fprint(conn, "-ERR unsupported command\r\n") + } + } +} + +func readRESPCommand(reader *bufio.Reader) ([]string, error) { + header, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + header = strings.TrimSpace(header) + if !strings.HasPrefix(header, "*") { + return strings.Fields(header), nil + } + count, err := strconv.Atoi(strings.TrimPrefix(header, "*")) + if err != nil || count <= 0 { + return nil, fmt.Errorf("invalid RESP array %q", header) + } + command := make([]string, 0, count) + for i := 0; i < count; i++ { + lengthLine, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + length, err := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(lengthLine), "$")) + if err != nil || length < 0 { + return nil, fmt.Errorf("invalid RESP bulk length %q", lengthLine) + } + value := make([]byte, length+2) + if _, err := io.ReadFull(reader, value); err != nil { + return nil, err + } + command = append(command, string(value[:length])) + } + return command, nil +} diff --git a/pkg/tools/functional_testkit_test.go b/pkg/tools/functional_testkit_test.go new file mode 100644 index 00000000..e36121a4 --- /dev/null +++ b/pkg/tools/functional_testkit_test.go @@ -0,0 +1,148 @@ +package tools + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/commands" +) + +type functionalResult struct { + Stdout string + Stderr string + Events []output.ToolDataEvent +} + +type functionalCase struct { + Name string + Tool string + Args []string + Stdin string + Timeout time.Duration + Check func(*testing.T, functionalResult) +} + +type functionalRecorder struct { + mu sync.Mutex + events []output.ToolDataEvent +} + +func newFunctionalRecorder(bus *eventbus.Bus[output.ToolDataEvent]) *functionalRecorder { + recorder := &functionalRecorder{} + bus.Subscribe(func(event output.ToolDataEvent) { + recorder.mu.Lock() + recorder.events = append(recorder.events, event) + recorder.mu.Unlock() + }) + return recorder +} + +func (r *functionalRecorder) mark() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.events) +} + +func (r *functionalRecorder) since(mark int) []output.ToolDataEvent { + r.mu.Lock() + defer r.mu.Unlock() + return append([]output.ToolDataEvent(nil), r.events[mark:]...) +} + +func runFunctionalCases(t *testing.T, registry *commands.CommandRegistry, recorder *functionalRecorder, cases []functionalCase) { + t.Helper() + for _, testCase := range cases { + t.Run(testCase.Name, func(t *testing.T) { + if !registry.Has(testCase.Tool) { + t.Fatalf("tool %q is not registered", testCase.Tool) + } + timeout := testCase.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + var stdout, stderr bytes.Buffer + parent := &commands.Execution{ + ID: "functional-" + testCase.Name, + Stdin: strings.NewReader(testCase.Stdin), + Stdout: &stdout, + Stderr: &stderr, + StartedAt: time.Now(), + } + mark := recorder.mark() + _, err := registry.Run(ctx, append([]string{testCase.Tool}, testCase.Args...), parent) + if err != nil { + t.Fatalf("%s %s: %v\nstdout:\n%s\nstderr:\n%s", testCase.Tool, strings.Join(testCase.Args, " "), err, stdout.String(), stderr.String()) + } + if err := ctx.Err(); err != nil { + t.Fatalf("%s exceeded %s: %v", testCase.Tool, timeout, err) + } + result := functionalResult{Stdout: stdout.String(), Stderr: stderr.String(), Events: recorder.since(mark)} + if testCase.Check != nil { + testCase.Check(t, result) + } + }) + } +} + +func requireFunctionalCoverage(t *testing.T, registry *commands.CommandRegistry, cases []functionalCase, coveredElsewhere ...string) { + t.Helper() + covered := make(map[string]bool, len(cases)+len(coveredElsewhere)) + for _, testCase := range cases { + covered[testCase.Tool] = true + } + for _, name := range coveredElsewhere { + covered[name] = true + } + for _, name := range registry.GroupNames("scanner") { + if !covered[name] { + t.Fatalf("scanner %q has no functional regression case", name) + } + } +} + +func requireOutputContains(t *testing.T, result functionalResult, values ...string) { + t.Helper() + combined := result.Stdout + "\n" + result.Stderr + for _, value := range values { + if !strings.Contains(combined, value) { + t.Fatalf("output missing %q\nstdout:\n%s\nstderr:\n%s", value, result.Stdout, result.Stderr) + } + } +} + +func requireEvent(t *testing.T, result functionalResult, tool, kind string, match func(any) bool) output.ToolDataEvent { + t.Helper() + for _, event := range result.Events { + if event.Tool == tool && event.Kind == kind && (match == nil || match(event.Data)) { + return event + } + } + t.Fatalf("missing event tool=%s kind=%s in %s", tool, kind, formatFunctionalEvents(result.Events)) + return output.ToolDataEvent{} +} + +func formatFunctionalEvents(events []output.ToolDataEvent) string { + var b strings.Builder + for _, event := range events { + fmt.Fprintf(&b, "{%s %s %s %T} ", event.Tool, event.Kind, event.Target, event.Data) + } + return b.String() +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/pkg/tools/passive/passive.go b/pkg/tools/passive/passive.go index a4479775..dc503f28 100644 --- a/pkg/tools/passive/passive.go +++ b/pkg/tools/passive/passive.go @@ -24,13 +24,20 @@ const queryTimeout = 600 * time.Second // Command dispatches passive recon to uncover by -s . type Command struct { - engine *engine.UncoverEngine + engine QueryEngine logger telemetry.Logger sources map[string]bool } +// QueryEngine is the passive command's minimal dependency, allowing callers +// to supply deterministic or alternate recon backends. +type QueryEngine interface { + Sources() []string + QueryRaw(context.Context, string, string) ([]sources.Result, error) +} + // New creates a passive command. Engine may be nil (not configured). -func New(eng *engine.UncoverEngine) *Command { +func New(eng QueryEngine) *Command { c := &Command{ engine: eng, logger: telemetry.NopLogger(), diff --git a/pkg/tools/passive/register.go b/pkg/tools/passive/register.go index 481b1e89..ecdc26a7 100644 --- a/pkg/tools/passive/register.go +++ b/pkg/tools/passive/register.go @@ -17,12 +17,12 @@ func init() { commands.RegisterFactory(commands.Factory{ Group: "scanner", Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - var unc *engine.UncoverEngine - if es, ok := deps.EngineSet.(*engine.Set); ok && es != nil { - unc = es.Uncover + var backend QueryEngine + if es, ok := deps.EngineSet.(*engine.Set); ok && es != nil && es.Uncover != nil { + backend = es.Uncover } logger := deps.GetLogger() - impl := New(unc).WithLogger(logger) + impl := New(backend).WithLogger(logger) reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run}, "scanner") }, }) diff --git a/pkg/tools/playwright/browser_test.go b/pkg/tools/playwright/browser_test.go index 81050add..61a54fe8 100644 --- a/pkg/tools/playwright/browser_test.go +++ b/pkg/tools/playwright/browser_test.go @@ -198,16 +198,19 @@ func TestExecute_UnknownSubcommand(t *testing.T) { } func TestResolvePath_Absolute(t *testing.T) { - got := resolvePath("/work", "/abs/path.png") - if got != "/abs/path.png" { - t.Fatalf("expected /abs/path.png, got %q", got) + absolute := filepath.Join(t.TempDir(), "abs", "path.png") + got := resolvePath(t.TempDir(), absolute) + if got != absolute { + t.Fatalf("expected %q, got %q", absolute, got) } } func TestResolvePath_Relative(t *testing.T) { - got := resolvePath("/work", "file.png") - if got != "/work/file.png" { - t.Fatalf("expected /work/file.png, got %q", got) + workDir := t.TempDir() + want := filepath.Join(workDir, "file.png") + got := resolvePath(workDir, "file.png") + if got != want { + t.Fatalf("expected %q, got %q", want, got) } } diff --git a/pkg/tools/proton/register_test.go b/pkg/tools/proton/register_test.go index cecc3f58..78e8a98b 100644 --- a/pkg/tools/proton/register_test.go +++ b/pkg/tools/proton/register_test.go @@ -1,6 +1,7 @@ package proton import ( + "slices" "testing" "github.com/chainreactors/aiscan/pkg/commands" @@ -13,7 +14,7 @@ func TestFactoryBuildsProtonWithScannerGroup(t *testing.T) { if !registry.Has("proton") { t.Fatal("scanner group did not register proton") } - if got := registry.GroupNames("scanner"); len(got) != 1 || got[0] != "proton" { - t.Fatalf("scanner group = %#v, want [proton]", got) + if got := registry.GroupNames("scanner"); !slices.Contains(got, "proton") { + t.Fatalf("scanner group = %#v, want proton", got) } } diff --git a/pkg/tools/scan/command_test.go b/pkg/tools/scan/command_test.go index 4ef41172..548b6c51 100644 --- a/pkg/tools/scan/command_test.go +++ b/pkg/tools/scan/command_test.go @@ -76,8 +76,12 @@ func TestScanProfilesAssembleCapabilities(t *testing.T) { t.Fatalf("quick profile missing %s", name) } } - if quick.CrawlDepth != 2 { - t.Fatalf("quick crawl depth = %d, want 2", quick.CrawlDepth) + wantQuickDepth := 2 + if quick.Enabled("katana_crawl") { + wantQuickDepth = 1 + } + if quick.CrawlDepth != wantQuickDepth { + t.Fatalf("quick crawl depth = %d, want %d", quick.CrawlDepth, wantQuickDepth) } for _, name := range []string{capSprayPlugins, capSprayBrute} { if quick.Enabled(name) { diff --git a/pkg/tools/zombie/zombie.go b/pkg/tools/zombie/zombie.go index ba78a769..5975a5a0 100644 --- a/pkg/tools/zombie/zombie.go +++ b/pkg/tools/zombie/zombie.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "os" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" @@ -51,6 +52,7 @@ func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any defer telemetry.RecoverAsError("zombie", &err) args := execution.Args args = c.resolveRelativePaths(args) + args = ensureOutputDrain(args) var buf bytes.Buffer if toolargs.BoolFlagEnabled(args, "--debug") { restoreDebug := telemetry.ActivateDebug(c.Logger) @@ -70,6 +72,16 @@ func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any return nil, nil } +// zombie core only starts its result consumer when a file output is present, +// while workers always publish to the result channel. Supply the system sink +// for normal stdout-only runs so successful and failed attempts cannot deadlock. +func ensureOutputDrain(args []string) []string { + if toolargs.HasFlag(args, "-f") || toolargs.HasFlag(args, "--file") { + return args + } + return append(append([]string(nil), args...), "--file", os.DevNull) +} + var zombieFileFlags = map[string]bool{ "-I": true, "--IP": true, "-U": true, "--USER": true, "-P": true, "--PWD": true, "-A": true, "--AUTH": true, diff --git a/pkg/tools/zombie/zombie_test.go b/pkg/tools/zombie/zombie_test.go index 5ee5b5cc..568d23c2 100644 --- a/pkg/tools/zombie/zombie_test.go +++ b/pkg/tools/zombie/zombie_test.go @@ -3,6 +3,7 @@ package zombie import ( "bytes" "context" + "os" "path/filepath" "reflect" "strings" @@ -70,3 +71,20 @@ func TestResolveRelativePathsOnlyRewritesZombieFileFlags(t *testing.T) { t.Fatalf("resolveRelativePaths() = %#v, want %#v", got, want) } } + +func TestEnsureOutputDrain(t *testing.T) { + args := []string{"-i", "127.0.0.1:6379", "-s", "redis"} + got := ensureOutputDrain(args) + want := append(append([]string(nil), args...), "--file", os.DevNull) + if !reflect.DeepEqual(got, want) { + t.Fatalf("ensureOutputDrain() = %#v, want %#v", got, want) + } + if len(args) != 4 { + t.Fatalf("ensureOutputDrain mutated input: %#v", args) + } + + explicit := []string{"-i", "127.0.0.1:6379", "--file", "results.json"} + if got := ensureOutputDrain(explicit); !reflect.DeepEqual(got, explicit) { + t.Fatalf("explicit output changed: %#v", got) + } +} From 0830f8e82391e9698ec4efbfd39f352e0887c770 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 09:38:30 +0800 Subject: [PATCH 104/348] test(scanners): add public functional regressions --- .github/workflows/ci.yml | 22 ++++ .github/workflows/scanner-regression.yml | 38 ++++++ cmd/aiscan/cli.go | 26 ++-- cmd/aiscan/cli_test.go | 53 ++++++++ pkg/tools/functional_integration_full_test.go | 47 +++++++ pkg/tools/functional_integration_test.go | 115 ++++++++++++++++++ pkg/tools/functional_norace_test.go | 5 + pkg/tools/functional_race_test.go | 5 + pkg/tools/functional_regression_test.go | 40 ++++-- pkg/tools/functional_testkit_test.go | 16 ++- pkg/tools/spray/spray.go | 11 +- pkg/tools/spray/spray_test.go | 16 ++- 12 files changed, 370 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/scanner-regression.yml create mode 100644 pkg/tools/functional_integration_full_test.go create mode 100644 pkg/tools/functional_integration_test.go create mode 100644 pkg/tools/functional_norace_test.go create mode 100644 pkg/tools/functional_race_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c608b74..45c1416e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,28 @@ jobs: -run 'AgentTmux' \ ./pkg/agent/ + scanner-functional: + runs-on: ubuntu-22.04 + needs: tidy + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run scanner functional regressions + run: | + go test -tags "full re2_cgo re2_static" -count=1 -timeout 5m -v \ + -run 'Test(ScannerFunctionalRegression|FullScannerFunctionalRegression)$' \ + ./pkg/tools + # ── Generated templates tests (depends on tidy) ─────────────── generated-test: diff --git a/.github/workflows/scanner-regression.yml b/.github/workflows/scanner-regression.yml new file mode 100644 index 00000000..883d80f2 --- /dev/null +++ b/.github/workflows/scanner-regression.yml @@ -0,0 +1,38 @@ +name: scanner-regression + +on: + schedule: + - cron: '30 17 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: scanner-regression-${{ github.ref }} + cancel-in-progress: true + +jobs: + public-functional: + runs-on: ubuntu-22.04 + timeout-minutes: 10 + env: + AISCAN_INTEGRATION: '1' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run bounded public scanner regressions + run: | + go test -tags "full integration re2_cgo re2_static" -count=1 -timeout 8m -v \ + -run 'Test(ScannerPublicIntegration|FullScannerPublicIntegration)$' \ + ./pkg/tools diff --git a/cmd/aiscan/cli.go b/cmd/aiscan/cli.go index 0d5a5d0c..f6ba2807 100644 --- a/cmd/aiscan/cli.go +++ b/cmd/aiscan/cli.go @@ -40,6 +40,7 @@ type webCommand struct { type cliOptions struct { cfg.MiscOptions `group:"Miscellaneous Options"` + Timeout int `long:"timeout" description:"Overall timeout in seconds"` Agent agentCommand `command:"agent" description:"Run the natural-language agent"` Serve serveCommand `command:"serve" description:"Run the standalone agent server"` Web webCommand `command:"web" description:"Start the web UI server (includes embedded agent server)"` @@ -224,6 +225,9 @@ func parseCLI(args []string) (parsedCLI, error) { mode := selectedMode(parser) option := buildOption(&cli, parser) + if cli.Timeout > 0 { + option.Timeout = cli.Timeout + } if mode == cfg.RunModeNoCommand { return parsedCLI{Option: option, Mode: cfg.RunModeNoCommand}, nil @@ -283,7 +287,10 @@ func parseScannerCLI(scannerName string, rootArgs, scannerRest []string) (parsed if cli.Version { return parsedCLI{Option: option, Mode: cfg.RunModeNoCommand}, nil } - option.Timeout = 3600 + option.Timeout = cli.Timeout + if option.Timeout <= 0 { + option.Timeout = 3600 + } scannerArgs := append([]string(nil), scannerRest...) if scannerName == "scan" { @@ -510,14 +517,15 @@ var scannerKnownFlags = []knownFlag{ } var rootOnlyFlagValueArity = map[string]int{ - "--input": 1, - "-i": 1, - "--view": 1, - "-F": 1, - "--output": 1, - "-o": 1, - "--file": 1, - "-f": 1, + "--input": 1, + "-i": 1, + "--view": 1, + "-F": 1, + "--output": 1, + "-o": 1, + "--file": 1, + "-f": 1, + "--timeout": 1, } var rootFlagValueArity = buildRootFlagValueArity() diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go index 711bcccb..2bdd298e 100644 --- a/cmd/aiscan/cli_test.go +++ b/cmd/aiscan/cli_test.go @@ -91,6 +91,59 @@ func TestParseCLIScannerDebugEnablesGlobalDebugAndPreservesArg(t *testing.T) { } } +func TestParseCLIScannerExtractsRootTimeout(t *testing.T) { + for _, args := range [][]string{ + {"--timeout", "45", "gogo", "-i", "127.0.0.1", "-p", "80"}, + {"--timeout=45", "gogo", "-i", "127.0.0.1", "-p", "80"}, + } { + t.Run(strings.Join(args[:1], "_"), func(t *testing.T) { + parsed, err := parseCLI(args) + if err != nil { + t.Fatalf("parseCLI() error = %v", err) + } + if parsed.Option.Timeout != 45 { + t.Fatalf("timeout = %d, want 45", parsed.Option.Timeout) + } + wantArgs := []string{"gogo", "-i", "127.0.0.1", "-p", "80"} + if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) { + t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs) + } + }) + } +} + +func TestParseCLIScannerKeepsToolTimeoutAfterCommand(t *testing.T) { + parsed, err := parseCLI([]string{"gogo", "-i", "127.0.0.1", "--timeout", "5"}) + if err != nil { + t.Fatalf("parseCLI() error = %v", err) + } + if parsed.Option.Timeout != 3600 { + t.Fatalf("overall timeout = %d, want default 3600", parsed.Option.Timeout) + } + wantArgs := []string{"gogo", "-i", "127.0.0.1", "--timeout", "5"} + if !reflect.DeepEqual(parsed.ScannerArgs, wantArgs) { + t.Fatalf("scanner args = %#v, want %#v", parsed.ScannerArgs, wantArgs) + } +} + +func TestParseCLIRootTimeoutAppliesToAgent(t *testing.T) { + parsed, err := parseCLI([]string{"--timeout", "45", "agent", "-p", "test"}) + if err != nil { + t.Fatalf("parseCLI() error = %v", err) + } + if parsed.Option.Timeout != 45 { + t.Fatalf("timeout = %d, want 45", parsed.Option.Timeout) + } + + parsed, err = parseCLI([]string{"agent", "--timeout", "30", "-p", "test"}) + if err != nil { + t.Fatalf("parseCLI() subcommand timeout error = %v", err) + } + if parsed.Option.Timeout != 30 { + t.Fatalf("subcommand timeout = %d, want 30", parsed.Option.Timeout) + } +} + func TestDirectScannerModeSuppressesInitInfoByDefault(t *testing.T) { if raceEnabled { t.Skip("scanner pipeline has known races under -race; this test checks log output") diff --git a/pkg/tools/functional_integration_full_test.go b/pkg/tools/functional_integration_full_test.go new file mode 100644 index 00000000..6cd0e694 --- /dev/null +++ b/pkg/tools/functional_integration_full_test.go @@ -0,0 +1,47 @@ +//go:build full && integration + +package tools + +import ( + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/aiscan/pkg/telemetry" + _ "github.com/chainreactors/aiscan/pkg/tools/katana" + "github.com/chainreactors/aiscan/pkg/tools/scan/engine" +) + +func TestFullScannerPublicIntegration(t *testing.T) { + if os.Getenv("AISCAN_INTEGRATION") != "1" { + t.Skip("set AISCAN_INTEGRATION=1 to run public network regression tests") + } + + bus := eventbus.New[output.ToolDataEvent]() + recorder := newFunctionalRecorder(bus) + registry := commands.NewRegistry() + commands.BuildGroup("scanner", &commands.Deps{ + WorkDir: t.TempDir(), EngineSet: &engine.Set{}, DataBus: bus, Logger: telemetry.NopLogger(), + }, registry) + + runFunctionalCases(t, registry, recorder, []functionalCase{{ + Name: "katana/redhaze-depth-one", Tool: "katana", + Args: []string{ + "-u", "https://redhaze.top", "-d", "1", "-j", "-c", "1", "-p", "1", + "-rl", "2", "-mdp", "8", "-timeout", "10", + }, + Timeout: 45 * time.Second, + Check: func(t *testing.T, result functionalResult) { + requireOutputContains(t, result, "https://redhaze.top") + requireEvent(t, result, "katana", output.ToolDataWeb, func(data any) bool { + encoded, err := json.Marshal(data) + return err == nil && strings.Contains(string(encoded), "redhaze.top") + }) + }, + }}) +} diff --git a/pkg/tools/functional_integration_test.go b/pkg/tools/functional_integration_test.go new file mode 100644 index 00000000..19556641 --- /dev/null +++ b/pkg/tools/functional_integration_test.go @@ -0,0 +1,115 @@ +//go:build integration + +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/resources" + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/aiscan/pkg/telemetry" + _ "github.com/chainreactors/aiscan/pkg/tools/gogo" + _ "github.com/chainreactors/aiscan/pkg/tools/neutron" + "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + _ "github.com/chainreactors/aiscan/pkg/tools/spray" + "github.com/chainreactors/utils/parsers" +) + +func TestScannerPublicIntegration(t *testing.T) { + if os.Getenv("AISCAN_INTEGRATION") != "1" { + t.Skip("set AISCAN_INTEGRATION=1 to run public network regression tests") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + engineSet, err := engine.InitWithOptions(ctx, resources.Options{}, telemetry.NopLogger()) + if err != nil { + t.Fatalf("initialize scanner engines: %v", err) + } + defer engineSet.Close() + + bus := eventbus.New[output.ToolDataEvent]() + recorder := newFunctionalRecorder(bus) + registry := commands.NewRegistry() + commands.BuildGroup("scanner", &commands.Deps{ + WorkDir: t.TempDir(), EngineSet: engineSet, Resources: engineSet.Resources, + DataBus: bus, Logger: telemetry.NopLogger(), + }, registry) + templateFile := filepath.Join(t.TempDir(), "redhaze-marker.yaml") + writeTestFile(t, templateFile, `id: redhaze-public-marker +info: + name: RedHaze public regression marker + severity: info + tags: regression +http: + - method: GET + path: + - '{{BaseURL}}' + matchers: + - type: word + words: + - 'RedHaze Group' +`) + + cases := []functionalCase{ + { + Name: "gogo/redhaze-http-https-fingerprint", Tool: "gogo", + Args: []string{"-i", "redhaze.top", "-p", "80,443", "-v", "-o", "jl", "-t", "2"}, + Timeout: 45 * time.Second, + Check: func(t *testing.T, result functionalResult) { + requireOutputContains(t, result, `"port":"80"`, `"port":"443"`, "nginx") + requireEvent(t, result, "gogo", output.ToolDataService, func(data any) bool { + item, ok := data.(*parsers.GOGOResult) + return ok && item != nil && item.Port == "443" && item.Protocol == "https" + }) + }, + }, + { + Name: "spray/redhaze-explicit-https", Tool: "spray", + Args: []string{"-u", "https://redhaze.top", "-j", "--limit", "1", "--timeout", "10"}, + Timeout: 30 * time.Second, + Check: func(t *testing.T, result functionalResult) { + requireOutputContains(t, result, `"url":"https://redhaze.top`, `"status":301`, "nginx") + if strings.Contains(result.Stdout, `"url":"http://redhaze.top`) { + t.Fatalf("spray downgraded explicit HTTPS target:\n%s", result.Stdout) + } + }, + }, + { + Name: "neutron/redhaze-benign-template", Tool: "neutron", + Args: []string{ + "-i", "https://id.redhaze.top/home", "-t", templateFile, + "--tags", "regression", "-s", "info", "--concurrency", "1", + "--rate-limit", "1", "--timeout", "20", "-j", + }, + Timeout: 30 * time.Second, + Check: func(t *testing.T, result functionalResult) { + requireOutputContains(t, result, `"matched":true`, `"template":"redhaze-public-marker"`) + requireEvent(t, result, "neutron", output.ToolDataVuln, nil) + }, + }, + { + Name: "scan/redhaze-limited-pipeline", Tool: "scan", + Args: []string{ + "-i", "redhaze.top", "--ports", "80,443", "--mode", "quick", + "--verify=off", "--timeout", "8", "--no-color", + }, + Timeout: 90 * time.Second, + Check: func(t *testing.T, result functionalResult) { + requireOutputContains(t, result, "[summary] completed", "443", "nginx") + requireEvent(t, result, "gogo", output.ToolDataService, func(data any) bool { + item, ok := data.(*parsers.GOGOResult) + return ok && item != nil && item.Port == "443" && item.Protocol == "https" + }) + }, + }, + } + runFunctionalCases(t, registry, recorder, cases) +} diff --git a/pkg/tools/functional_norace_test.go b/pkg/tools/functional_norace_test.go new file mode 100644 index 00000000..7deb8dd4 --- /dev/null +++ b/pkg/tools/functional_norace_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package tools + +const functionalRaceEnabled = false diff --git a/pkg/tools/functional_race_test.go b/pkg/tools/functional_race_test.go new file mode 100644 index 00000000..424133b0 --- /dev/null +++ b/pkg/tools/functional_race_test.go @@ -0,0 +1,5 @@ +//go:build race + +package tools + +const functionalRaceEnabled = true diff --git a/pkg/tools/functional_regression_test.go b/pkg/tools/functional_regression_test.go index 33f44ff8..2a9fbc0d 100644 --- a/pkg/tools/functional_regression_test.go +++ b/pkg/tools/functional_regression_test.go @@ -32,6 +32,7 @@ import ( func TestScannerFunctionalRegression(t *testing.T) { httpServer := newScannerHTTPFixture(t) + tlsServer := newScannerTLSFixture(t) httpURL, err := url.Parse(httpServer.URL) if err != nil { t.Fatal(err) @@ -96,7 +97,8 @@ http: cases := []functionalCase{ { Name: "gogo/http-fingerprint-jsonl", Tool: "gogo", - Args: []string{"-i", host, "-p", port, "-v", "-o", "jl", "-t", "20"}, + Args: []string{"-i", host, "-p", port, "-v", "-o", "jl", "-t", "20"}, + SkipUnderRace: true, Check: func(t *testing.T, result functionalResult) { requireOutputContains(t, result, `"port":"`+port+`"`, "nginx") requireEvent(t, result, "gogo", output.ToolDataService, func(data any) bool { @@ -111,7 +113,8 @@ http: }, { Name: "gogo/target-file", Tool: "gogo", - Args: []string{"-l", targetsFile, "-p", port, "-o", "jl", "-t", "20"}, + Args: []string{"-l", targetsFile, "-p", port, "-o", "jl", "-t", "20"}, + SkipUnderRace: true, Check: func(t *testing.T, result functionalResult) { requireOutputContains(t, result, `"port":"`+port+`"`) }, @@ -131,6 +134,17 @@ http: }) }, }, + { + Name: "spray/explicit-https", Tool: "spray", + Args: []string{"-u", tlsServer.URL, "-j", "--limit", "1"}, + Check: func(t *testing.T, result functionalResult) { + requireOutputContains(t, result, `"url":"`+tlsServer.URL+`"`, `"status":200`) + requireEvent(t, result, "spray", output.ToolDataWeb, func(data any) bool { + item, ok := data.(*parsers.SprayResult) + return ok && item != nil && item.Status == http.StatusOK && strings.HasPrefix(item.UrlString, "https://") + }) + }, + }, { Name: "spray/crawl", Tool: "spray", Args: []string{"-u", httpServer.URL, "--crawl", "--limit", "10"}, @@ -182,8 +196,9 @@ http: }, { Name: "scan/quick-pipeline", Tool: "scan", - Args: []string{"-i", host, "--ports", port, "--mode", "quick", "--verify=off", "--timeout", "2", "--no-color"}, - Timeout: 30 * time.Second, + Args: []string{"-i", host, "--ports", port, "--mode", "quick", "--verify=off", "--timeout", "2", "--no-color"}, + Timeout: 30 * time.Second, + SkipUnderRace: true, Check: func(t *testing.T, result functionalResult) { requireOutputContains(t, result, "[summary] completed", port) requireEvent(t, result, "gogo", output.ToolDataService, nil) @@ -198,6 +213,19 @@ http: func newScannerHTTPFixture(t *testing.T) *httptest.Server { t.Helper() + server := httptest.NewServer(newScannerHTTPHandler()) + t.Cleanup(server.Close) + return server +} + +func newScannerTLSFixture(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewTLSServer(newScannerHTTPHandler()) + t.Cleanup(server.Close) + return server +} + +func newScannerHTTPHandler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Server", "nginx/1.25.4") @@ -220,9 +248,7 @@ func newScannerHTTPFixture(t *testing.T) *httptest.Server { w.Header().Set("Server", "nginx/1.25.4") fmt.Fprint(w, "AISCAN_REGRESSION_MARKER") }) - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - return server + return mux } func newRedisAuthFixture(t *testing.T, password string) string { diff --git a/pkg/tools/functional_testkit_test.go b/pkg/tools/functional_testkit_test.go index e36121a4..d32e0348 100644 --- a/pkg/tools/functional_testkit_test.go +++ b/pkg/tools/functional_testkit_test.go @@ -22,12 +22,13 @@ type functionalResult struct { } type functionalCase struct { - Name string - Tool string - Args []string - Stdin string - Timeout time.Duration - Check func(*testing.T, functionalResult) + Name string + Tool string + Args []string + Stdin string + Timeout time.Duration + SkipUnderRace bool + Check func(*testing.T, functionalResult) } type functionalRecorder struct { @@ -61,6 +62,9 @@ func runFunctionalCases(t *testing.T, registry *commands.CommandRegistry, record t.Helper() for _, testCase := range cases { t.Run(testCase.Name, func(t *testing.T) { + if functionalRaceEnabled && testCase.SkipUnderRace { + t.Skip("upstream scanner has a known internal race") + } if !registry.Has(testCase.Tool) { t.Fatalf("tool %q is not registered", testCase.Tool) } diff --git a/pkg/tools/spray/spray.go b/pkg/tools/spray/spray.go index b1b93c30..cf28ee5f 100644 --- a/pkg/tools/spray/spray.go +++ b/pkg/tools/spray/spray.go @@ -163,7 +163,16 @@ func withDefaultNoStat(args []string) []string { } func withDefaultScannerFlags(args []string) []string { - return withDefaultNoStat(withDefaultNoBar(args)) + return withDefaultClient(withDefaultNoStat(withDefaultNoBar(args))) +} + +func withDefaultClient(args []string) []string { + for _, arg := range args { + if arg == "-C" || strings.HasPrefix(arg, "-C=") || arg == "--client" || strings.HasPrefix(arg, "--client=") { + return args + } + } + return append(args, "--client", "req") } func withDefaultBoolFlag(args []string, flag string) []string { diff --git a/pkg/tools/spray/spray_test.go b/pkg/tools/spray/spray_test.go index 17126772..92091af2 100644 --- a/pkg/tools/spray/spray_test.go +++ b/pkg/tools/spray/spray_test.go @@ -50,12 +50,26 @@ func TestWithDefaultNoStatKeepsExplicitFlag(t *testing.T) { func TestWithDefaultScannerFlagsAppendsFlags(t *testing.T) { got := withDefaultScannerFlags([]string{"-u", "http://127.0.0.1"}) - want := []string{"-u", "http://127.0.0.1", "--no-bar", "--no-stat"} + want := []string{"-u", "http://127.0.0.1", "--no-bar", "--no-stat", "--client", "req"} if !reflect.DeepEqual(got, want) { t.Fatalf("withDefaultScannerFlags() = %#v, want %#v", got, want) } } +func TestWithDefaultClientKeepsExplicitClient(t *testing.T) { + for _, args := range [][]string{ + {"-u", "https://example.test", "--client", "standard"}, + {"-u", "https://example.test", "--client=fast"}, + {"-u", "https://example.test", "-C", "standard"}, + {"-u", "https://example.test", "-C=req"}, + } { + got := withDefaultClient(args) + if !reflect.DeepEqual(got, args) { + t.Fatalf("withDefaultClient(%#v) = %#v", args, got) + } + } +} + func TestWriteResultSupportsTextAndJSON(t *testing.T) { result := &parsers.SprayResult{ UrlString: "http://example.test", From e09c2c54a17dd957fa9edb817b958664e366c222 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 10:11:22 +0800 Subject: [PATCH 105/348] fix(ci): make clean checkouts compile --- .gitignore | 3 ++- go.mod | 2 +- go.sum | 6 ------ web/static/.gitkeep | 1 + 4 files changed, 4 insertions(+), 8 deletions(-) create mode 100644 web/static/.gitkeep diff --git a/.gitignore b/.gitignore index 913bd604..cb364dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,8 @@ community.yaml /*_report.md # frontend build output (generated by npm run build) -web/static/ +web/static/* +!web/static/.gitkeep web/static.oldroot* web/static.rootold* web/static.* diff --git a/go.mod b/go.mod index 84913055..23da8b1b 100644 --- a/go.mod +++ b/go.mod @@ -236,7 +236,7 @@ require ( github.com/sahilm/fuzzy v0.1.1 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/satori/go.uuid v1.2.0 // indirect github.com/sijms/go-ora/v2 v2.9.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect diff --git a/go.sum b/go.sum index 448303ca..c8534106 100644 --- a/go.sum +++ b/go.sum @@ -209,8 +209,6 @@ github.com/chainreactors/spray v1.3.3-0.20260704194611-7ce7b850d447 h1:4RawLZEJD github.com/chainreactors/spray v1.3.3-0.20260704194611-7ce7b850d447/go.mod h1:QT+vmYNPBmiemn+MJ5oNDNFSM/w0LNxtM5VhpI7RNNA= github.com/chainreactors/tui/console v0.0.0-20260712082522-2ba36ad7841f h1:QfP7iGquLIy8pkh4A+rvYCLa207FMCLhuMWQfMkyBS4= github.com/chainreactors/tui/console v0.0.0-20260712082522-2ba36ad7841f/go.mod h1:lVNsVwhAj7AqSiw53pbktHmDRp0KoZI7n1VMVaAn+GI= -github.com/chainreactors/tui/readline v0.0.0-20260712082522-2ba36ad7841f h1:nCio/m3v3ZFd8Mo6CPLrag24FJMdzynlJKB9+TrmLi4= -github.com/chainreactors/tui/readline v0.0.0-20260712082522-2ba36ad7841f/go.mod h1:nEHRbLD/s2GWdAGbNVjz/KDF0ac7WZ3tPMgWmW8sZWA= github.com/chainreactors/tui/readline v0.0.0-20260723062039-ed89e758c21b h1:OeflBONN55oQ++CFJDE47pW5GfyXJpiQClFzD1aYK+o= github.com/chainreactors/tui/readline v0.0.0-20260723062039-ed89e758c21b/go.mod h1:nEHRbLD/s2GWdAGbNVjz/KDF0ac7WZ3tPMgWmW8sZWA= github.com/chainreactors/utils v0.0.0-20240716182459-e85f2b01ee16/go.mod h1:LajXuvESQwP+qCMAvlcoSXppQCjuLlBrnQpu9XQ1HtU= @@ -222,10 +220,6 @@ github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 h1:r github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M= github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863 h1:u9cXebLoVtKwN0KkpGFfrjANUYo+93MijB//X9qONeY= github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863/go.mod h1:S9lkpQ1I4wcBq0YEBde/UPmR061IPok3bLl7aPz6Vkk= -github.com/chainreactors/utils/pty v0.0.0-20260720064434-8bb63d351632 h1:/Oo4JDpO5hxRPmEnj6o3dEjAykcNrmqyyfvSHSFpaXU= -github.com/chainreactors/utils/pty v0.0.0-20260720064434-8bb63d351632/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4= -github.com/chainreactors/utils/pty v0.0.0-20260722063955-84fd9fbf150a h1:189konWQqDt1Zxy4CVbTdKlzXHpRZd2RbNs8EawY6C8= -github.com/chainreactors/utils/pty v0.0.0-20260722063955-84fd9fbf150a/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4= github.com/chainreactors/utils/pty v0.0.0-20260722180147-5b1816060721 h1:gxkedbTvFEFTtel7XJEPMVh1iznfD+91woPkGBXZMNk= github.com/chainreactors/utils/pty v0.0.0-20260722180147-5b1816060721/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4= github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 h1:lvnDYEkatmZFHP5i321qQXK9L4vKRfso/uUfr5tOeC8= diff --git a/web/static/.gitkeep b/web/static/.gitkeep new file mode 100644 index 00000000..3e262833 --- /dev/null +++ b/web/static/.gitkeep @@ -0,0 +1 @@ +Generated frontend assets replace this placeholder during release builds. From 41a0f8476c32954fd2c7f800fa653dbb5459bf7f Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 10:24:01 +0800 Subject: [PATCH 106/348] fix(lint): clean up converged runtime paths --- core/config/options.go | 8 +++++++- core/runner/runner.go | 13 ------------- core/runner/runtime_session.go | 10 ---------- core/runner/subagent_handoff_test.go | 4 ++-- pkg/agent/loop.go | 4 ---- pkg/commands/execution.go | 4 ++-- pkg/tui/console.go | 2 -- pkg/tui/controller.go | 2 +- pkg/web/auth.go | 2 ++ pkg/web/service.go | 1 + pkg/webagent/agent.go | 4 ---- pkg/webagent/exec.go | 2 +- pkg/webagent/toolnode_test.go | 2 -- 13 files changed, 16 insertions(+), 42 deletions(-) diff --git a/core/config/options.go b/core/config/options.go index 4f1a9046..fefa656a 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -219,7 +219,13 @@ func ResolvePrompt(value string) (string, error) { } info, err := os.Stat(prompt) - if err != nil || !info.Mode().IsRegular() { + if os.IsNotExist(err) { + return prompt, nil + } + if err != nil { + return "", fmt.Errorf("stat prompt file %s: %w", prompt, err) + } + if !info.Mode().IsRegular() { return prompt, nil } diff --git a/core/runner/runner.go b/core/runner/runner.go index 24ff7cec..8063d5ff 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -13,7 +13,6 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" coretool "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/evaluator" inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" tmuxpkg "github.com/chainreactors/aiscan/pkg/agent/tmux" "github.com/chainreactors/aiscan/pkg/aop" @@ -599,18 +598,6 @@ func scannerCommandSupportsDebug(name string) bool { } } -// --------------------------------------------------------------------------- -// Evaluation -// --------------------------------------------------------------------------- - -func buildEvalConfig(option *cfg.Option, rt *AgentRuntime, logger telemetry.Logger, task string) evaluator.EvalLoopConfig { - model := option.Model - if option.EvalModel != "" { - model = option.EvalModel - } - return evaluator.NewLoopConfig(rt.app.Provider, model, logger, task, option.EvalCriteria, option.EvalMaxRetries) -} - // --------------------------------------------------------------------------- // IOA inbox subscription // --------------------------------------------------------------------------- diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index b925d2e1..ac67be05 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -784,16 +784,6 @@ func runtimeUsageData(usage agent.Usage) *aop.UsageData { } } -func inputText(input agent.Input) string { - var parts []string - for _, part := range input.Parts { - if part.Text != "" { - parts = append(parts, part.Text) - } - } - return strings.Join(parts, "\n") -} - func (rt *AgentRuntime) consoleAppInfo() tui.AppInfo { rt.mu.RLock() defer rt.mu.RUnlock() diff --git a/core/runner/subagent_handoff_test.go b/core/runner/subagent_handoff_test.go index 12abf4e7..65b20fd3 100644 --- a/core/runner/subagent_handoff_test.go +++ b/core/runner/subagent_handoff_test.go @@ -55,9 +55,9 @@ func waitHandoffBodies(t *testing.T, client *handoffClient, count int) (int, []p } time.Sleep(10 * time.Millisecond) } - spaceCalls, bodies := client.snapshot() + _, bodies := client.snapshot() t.Fatalf("messages = %d, want %d", len(bodies), count) - return spaceCalls, bodies + return 0, bodies } func handoffEvent(t *testing.T, typ, sessionID, agentName string, data any) aop.Event { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index feda6bc1..0a1f1332 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -662,10 +662,6 @@ func sanitizeMessages(msgs []ChatMessage) []ChatMessage { return out } -func isToolResultMessage(msg ChatMessage) bool { - return msg.ToolCallID != "" && (msg.Role == "tool" || msg.Role == "user") -} - func messageContent(msg ChatMessage) string { if msg.Content == nil { return "" diff --git a/pkg/commands/execution.go b/pkg/commands/execution.go index ae2c6952..67ee0ea1 100644 --- a/pkg/commands/execution.go +++ b/pkg/commands/execution.go @@ -91,8 +91,8 @@ func (e *Execution) refresh() { } } -// Wait waits for the PTY session. Cancelling the wait also kills the session, -// matching the previous foreground Bash execution behaviour. +// Wait waits for the PTY session. Canceling the wait also kills the session, +// matching the previous foreground Bash execution behavior. func (e *Execution) Wait(ctx context.Context) error { e.mu.RLock() id := e.ID diff --git a/pkg/tui/console.go b/pkg/tui/console.go index c7fa0165..6ecede85 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -139,8 +139,6 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf c.Shell().OnReadlineDone = func() { bridge.SetReady(false) } - stdout = bridge - stderr = bridge repl.stdout = bridge repl.stderr = bridge } diff --git a/pkg/tui/controller.go b/pkg/tui/controller.go index 3a4567ef..c6862736 100644 --- a/pkg/tui/controller.go +++ b/pkg/tui/controller.go @@ -231,7 +231,7 @@ func (c *interactiveRunController) Stop() bool { } cancel := c.cancel c.stopping = true - // Cancelling the current run also drops queued input. + // Canceling the current run also drops queued input. c.pending = nil c.mu.Unlock() diff --git a/pkg/web/auth.go b/pkg/web/auth.go index 857e3e32..e5c57b72 100644 --- a/pkg/web/auth.go +++ b/pkg/web/auth.go @@ -74,6 +74,7 @@ func registerAuthRoutes(mux *http.ServeMux, key string) { return } + //nolint:gosec // Local HTTP deployments cannot use Secure cookies. http.SetCookie(w, &http.Cookie{ Name: authCookieName, Value: sessionValue(key), @@ -87,6 +88,7 @@ func registerAuthRoutes(mux *http.ServeMux, key string) { }) mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) { + //nolint:gosec // Match the transport attributes used by the login cookie. http.SetCookie(w, &http.Cookie{ Name: authCookieName, Value: "", diff --git a/pkg/web/service.go b/pkg/web/service.go index b6714141..aeff029d 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -1282,6 +1282,7 @@ func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content stri _ = s.store.UpdateSession(ctx, session) } + //nolint:gosec // Agent dispatch must continue after the HTTP request returns. go s.dispatchUserMessage(sessionID, msg, opts) return msg, nil diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index b684ff2e..18d9f4cd 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -356,10 +356,6 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) { // REPL helpers // --------------------------------------------------------------------------- -func isREPLCommand(prompt string) bool { - return strings.HasPrefix(prompt, "/") || strings.HasPrefix(prompt, "!") -} - // fenceTerminalOutput wraps multi-line REPL/`!` command output in a Markdown // code fence. runChatREPLLine runs the same TUI console the interactive REPL // uses, whose panels (/status, /provider, /nodes ...) are drawn with box-drawing diff --git a/pkg/webagent/exec.go b/pkg/webagent/exec.go index af0f9fee..b3cdf52e 100644 --- a/pkg/webagent/exec.go +++ b/pkg/webagent/exec.go @@ -88,7 +88,7 @@ func HandleExec(ctx context.Context, msg webproto.Message, baseDir string, send case errors.Is(runCtx.Err(), context.Canceled): result.ExitCode = -1 result.State = "killed" - result.KillCause = "cancelled" + result.KillCause = "canceled" case errors.As(err, &exitErr): result.ExitCode = exitErr.ExitCode() default: diff --git a/pkg/webagent/toolnode_test.go b/pkg/webagent/toolnode_test.go index 59be2599..7d988167 100644 --- a/pkg/webagent/toolnode_test.go +++ b/pkg/webagent/toolnode_test.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "strings" - "sync" "testing" "time" @@ -29,7 +28,6 @@ var testUpgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { re type hubScript struct { t *testing.T - mu sync.Mutex registered chan webproto.RegisterPayload toolResult chan webproto.CommandResultPayload progress chan string From 8247e440c3227aa80465d75bf66e2b2f6071d994 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 10:44:55 +0800 Subject: [PATCH 107/348] fix(release): restore portable build profiles --- .github/workflows/go-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index afa43d91..f2ab0514 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -93,13 +93,13 @@ jobs: profile: standard main: ./cmd/aiscan binary: aiscan - tags: "forceposix emptytemplates noembed osusergo netgo cstx_native" + tags: "forceposix emptytemplates noembed osusergo netgo" generate: true - id: aiscan-full profile: full main: ./cmd/aiscan binary: aiscan-full - tags: "forceposix emptytemplates noembed osusergo netgo full cstx_native katana_slim" + tags: "forceposix emptytemplates noembed osusergo netgo full sqlite" generate: true env: From 745fecae7472e7b4f4de511a2908cd96a174a635 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 10:59:16 +0800 Subject: [PATCH 108/348] fix(web): make terminal reconnect state atomic --- pkg/web/agents.go | 14 ++++++++++---- pkg/web/agents_test.go | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 60c40c59..9217bec9 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -463,7 +463,7 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h defer conn.Close() terminalID := generateID() - events, unsubscribe := p.subscribePTY(agentID, terminalID) + events, online, unsubscribe := p.subscribePTY(agentID, terminalID) defer unsubscribe() defer p.CloseTerminal(agentID, terminalID) @@ -493,7 +493,7 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h } } }() - if p.get(agentID) == nil { + if !online { _ = write(pty.Frame{Type: pty.FrameDetached, StreamID: terminalID}) } @@ -522,13 +522,19 @@ func (p *AgentPool) CloseTerminal(agentID, terminalID string) { _ = p.SendAgentMessage(agentID, webproto.NewPTYMessage(pty.Frame{Type: pty.FrameDetach, StreamID: terminalID})) } -func (p *AgentPool) subscribePTY(agentID, terminalID string) (<-chan pty.Frame, func()) { +func (p *AgentPool) subscribePTY(agentID, terminalID string) (<-chan pty.Frame, bool, func()) { ch := make(chan pty.Frame, 256) + // Snapshot connectivity while registering the subscription under the pool + // lock. An unregister cannot otherwise be distinguished from an initially + // offline agent and can produce duplicate detached frames. + p.mu.RLock() p.ptyMu.Lock() p.ptySubs[terminalID] = ch p.ptyAgents[terminalID] = agentID + online := p.agents[agentID] != nil p.ptyMu.Unlock() - return ch, func() { + p.mu.RUnlock() + return ch, online, func() { p.ptyMu.Lock() if p.ptySubs[terminalID] == ch { delete(p.ptySubs, terminalID) diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index b716abc6..ce9128d3 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -684,7 +684,7 @@ func TestWSTerminalRebindsAfterAgentReconnect(t *testing.T) { srv, pool := setupTestServer(t) agentConn := dialAgent(t, srv, "generation-agent", []string{"tmux"}) - time.Sleep(50 * time.Millisecond) + waitAgents(t, pool, 1) agentID := pool.List()[0].ID terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) From 4f4c8a76305638a0ffeba6e1aba663e1de663917 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 11:32:50 +0800 Subject: [PATCH 109/348] fix(release): stamp and verify binary version --- .github/workflows/go-release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index f2ab0514..09e529c8 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -155,6 +155,7 @@ jobs: TAGS="${{ matrix.tags }}" BINARY="${{ matrix.binary }}" MAIN="${{ matrix.main }}" + VERSION="${GORELEASER_CURRENT_TAG#v}" OUTDIR="dist/build" mkdir -p "${OUTDIR}" @@ -164,10 +165,15 @@ jobs: out="${OUTDIR}/${BINARY}_${goos}_${goarch}${suffix}" echo " compiling ${goos}/${goarch} → ${out}" CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ - go build -trimpath -tags "$TAGS" -ldflags "-s -w" -buildvcs=false \ + go build -trimpath -tags "$TAGS" \ + -ldflags "-s -w -X github.com/chainreactors/aiscan/core/config.Version=${VERSION}" \ + -buildvcs=false \ -o "$out" "$MAIN" done + version_output="$("${OUTDIR}/${BINARY}_linux_amd64" --version)" + test "$version_output" = "aiscan v${VERSION}" + echo "=== Binaries ===" ls -lh "${OUTDIR}/" From 47c9dfb4854d23c733c391f7bb88ac98c5acb1bf Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:35:22 +0800 Subject: [PATCH 110/348] refactor(runner): remove unused run replay mechanisms --- core/runner/runtime_semantics_test.go | 22 ++++-- core/runner/runtime_session.go | 102 +------------------------- core/runner/subagent_handoff.go | 10 --- core/runner/subagent_handoff_test.go | 9 ++- 4 files changed, 23 insertions(+), 120 deletions(-) diff --git a/core/runner/runtime_semantics_test.go b/core/runner/runtime_semantics_test.go index 2b407599..e2b616f7 100644 --- a/core/runner/runtime_semantics_test.go +++ b/core/runner/runtime_semantics_test.go @@ -72,18 +72,21 @@ func TestSessionRunHasOneReliableTurnLifecycle(t *testing.T) { t.Fatal(err) } - var replay []aop.Event - for event := range run.Events(context.Background()) { - replay = append(replay, event) + var turnEvents []aop.Event + for _, event := range all { + if event.TurnID != "turn-1" { + continue + } + turnEvents = append(turnEvents, event) if event.SessionID != "session-1" || event.TurnID != "turn-1" { t.Fatalf("run event identity = %+v", event) } } - if len(replay) < 2 || replay[0].Type != aop.TypeTurnStart || replay[len(replay)-1].Type != aop.TypeTurnEnd { - t.Fatalf("run replay = %+v", replay) + if len(turnEvents) < 2 || turnEvents[0].Type != aop.TypeTurnStart || turnEvents[len(turnEvents)-1].Type != aop.TypeTurnEnd { + t.Fatalf("turn events = %+v", turnEvents) } starts, ends := 0, 0 - for _, event := range replay { + for _, event := range turnEvents { if event.Type == aop.TypeTurnStart { starts++ } @@ -183,6 +186,8 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { provider := &runtimeSemanticProvider{started: make(chan struct{}), release: make(chan struct{})} rt := newBareRuntime(t, nil, provider) + var events []aop.Event + rt.Subscribe(func(event aop.Event) { events = append(events, event) }) session, err := rt.OpenSession(context.Background(), SessionOptions{ID: "session-1"}) if err != nil { t.Fatal(err) @@ -207,7 +212,10 @@ func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { t.Fatalf("provider calls = %d, want 2 inside one Run", provider.callCount()) } starts, ends := 0, 0 - for event := range run.Events(context.Background()) { + for _, event := range events { + if event.TurnID != "turn-1" { + continue + } if event.Type == aop.TypeTurnStart { starts++ } diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index ac67be05..1e29502d 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -70,7 +70,6 @@ type Session struct { type Run struct { turnID string - log *runEventLog done chan struct{} mu sync.Mutex result RunResult @@ -84,15 +83,6 @@ func (r *Run) TurnID() string { return r.turnID } -func (r *Run) Events(ctx context.Context) <-chan aop.Event { - if r == nil || r.log == nil { - ch := make(chan aop.Event) - close(ch) - return ch - } - return r.log.events(ctx) -} - func (r *Run) Wait() (RunResult, error) { if r == nil { return RunResult{}, fmt.Errorf("run is nil") @@ -122,78 +112,6 @@ type commandOutcome struct { err error } -type runEventLog struct { - mu sync.Mutex - eventsLog []aop.Event - notify chan struct{} - closed bool -} - -func newRunEventLog() *runEventLog { - return &runEventLog{notify: make(chan struct{})} -} - -func (l *runEventLog) append(event aop.Event) { - l.mu.Lock() - if l.closed { - l.mu.Unlock() - return - } - l.eventsLog = append(l.eventsLog, event) - close(l.notify) - l.notify = make(chan struct{}) - l.mu.Unlock() -} - -func (l *runEventLog) close() { - l.mu.Lock() - if !l.closed { - l.closed = true - close(l.notify) - } - l.mu.Unlock() -} - -func (l *runEventLog) events(ctx context.Context) <-chan aop.Event { - if ctx == nil { - ctx = context.Background() - } - out := make(chan aop.Event) - go func() { - defer close(out) - index := 0 - for { - l.mu.Lock() - var event aop.Event - hasEvent := index < len(l.eventsLog) - if hasEvent { - event = l.eventsLog[index] - index++ - } - closed := l.closed - notify := l.notify - l.mu.Unlock() - if hasEvent { - select { - case out <- event: - case <-ctx.Done(): - return - } - continue - } - if closed { - return - } - select { - case <-notify: - case <-ctx.Done(): - return - } - } - }() - return out -} - type sessionEmitter struct { bus *eventbus.Bus[aop.Event] mu sync.Mutex @@ -225,17 +143,6 @@ type turnEmitter struct { turnID string agentName string emitter *sessionEmitter - log *runEventLog -} - -func (e *turnEmitter) observe(event aop.Event) { - if event.SessionID != e.sessionID || event.TurnID != e.turnID { - return - } - e.log.append(event) - if event.Type == aop.TypeTurnEnd { - e.log.close() - } } func (e *turnEmitter) start() { @@ -556,14 +463,11 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro } s.runtime.turnIDs[turnID] = struct{}{} s.runtime.mu.Unlock() - log := newRunEventLog() - run := &Run{turnID: turnID, log: log, done: make(chan struct{})} - emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents, log: log} - unsubscribe := s.runtime.Subscribe(emitter.observe) + run := &Run{turnID: turnID, done: make(chan struct{})} + emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents} op := &sessionOperation{ execute: func(runCtx context.Context) { defer s.runtime.releaseTurnID(turnID) - defer unsubscribe() s.inbox.setActive(true) emitter.start() result, runErr := s.executeRun(runCtx, turnID, input) @@ -586,7 +490,6 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro }, reject: func(err error) { defer s.runtime.releaseTurnID(turnID) - defer unsubscribe() result := RunResult{Stop: agent.StopReasonCanceled} if !errors.Is(err, context.Canceled) { result.Stop = agent.StopReasonError @@ -597,7 +500,6 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro }, } if err := s.admit(ctx, op); err != nil { - unsubscribe() s.runtime.releaseTurnID(turnID) return nil, err } diff --git a/core/runner/subagent_handoff.go b/core/runner/subagent_handoff.go index 6343921e..6650676d 100644 --- a/core/runner/subagent_handoff.go +++ b/core/runner/subagent_handoff.go @@ -16,16 +16,6 @@ import ( "github.com/chainreactors/ioa/protocols" ) -// subscribeIOAHandoff records the two subagent lifecycle boundaries as native -// IOA handoff messages by observing the agent AOP bus: a child session.start -// carrying a delegation extension is the delegation, and the matching child -// session.end is the return. The return references the delegation message so -// other IOA implementations can reconstruct the thread without aiscan-specific -// APIs. -func subscribeIOAHandoff(bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) { - _ = subscribeIOAHandoffContext(context.Background(), bus, client, spaceName, logger) -} - func subscribeIOAHandoffContext(ctx context.Context, bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) func() { if bus == nil || client == nil || spaceName == "" { return func() {} diff --git a/core/runner/subagent_handoff_test.go b/core/runner/subagent_handoff_test.go index 65b20fd3..77adc5e5 100644 --- a/core/runner/subagent_handoff_test.go +++ b/core/runner/subagent_handoff_test.go @@ -72,7 +72,8 @@ func handoffEvent(t *testing.T, typ, sessionID, agentName string, data any) aop. func TestIOAHandoffFromAOPBus(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ Model: "test-model", @@ -136,7 +137,8 @@ func TestIOAHandoffFromAOPBus(t *testing.T) { func TestIOAHandoffFailedRun(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ ParentSessionID: "parent-session", @@ -165,7 +167,8 @@ func TestIOAHandoffFailedRun(t *testing.T) { func TestIOAHandoffIgnoresNonDelegationSessions(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() bus.Emit(handoffEvent(t, aop.TypeSessionStart, "root-session", "aiscan", aop.SessionStartData{Model: "test-model"})) bus.Emit(handoffEvent(t, aop.TypeTurnEnd, "root-session", "aiscan", aop.TurnEndData{Stop: "completed"})) From 37333fb59eb6be9007eb8fe6c820f757409e98f8 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:38:37 +0800 Subject: [PATCH 111/348] refactor(web): narrow chat events to domain events --- pkg/web/agents.go | 10 +++---- pkg/web/eval_forward_test.go | 4 +-- pkg/web/service.go | 33 +++++++++++------------- pkg/web/sse.go | 2 +- pkg/web/sse_test.go | 20 +++++++------- pkg/web/types.go | 16 +++++------- web/frontend/src/api.ts | 18 +++++-------- web/frontend/src/hooks/useChatSession.ts | 10 +++---- 8 files changed, 51 insertions(+), 62 deletions(-) diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 9217bec9..3090d552 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -98,7 +98,7 @@ func (a *remoteAgent) commandSpecs() []webproto.CommandSpec { // SessionLookup resolves a task ID to its owning chat session. type SessionLookup interface { TaskSession(taskID string) (sessionID string, ok bool) - BroadcastChatEvent(sessionID string, event ChatEvent) + BroadcastDomainEvent(sessionID string, event DomainEvent) BroadcastAOPEvent(sessionID string, event aop.Event) } @@ -809,8 +809,8 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) { Type: "progress", Data: mustJSON(map[string]string{"scan_id": msg.TaskID, "data": data}), }) - p.forwardToSession(a, msg.TaskID, ChatEvent{ - Type: ChatEventScanProgress, + p.forwardToSession(a, msg.TaskID, DomainEvent{ + Type: DomainEventScanProgress, ScanID: msg.TaskID, Data: data, }) @@ -972,7 +972,7 @@ func (p *AgentPool) recordScanResultStats(a *remoteAgent, payload json.RawMessag a.mu.Unlock() } -func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event ChatEvent) { +func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event DomainEvent) { if p.sessions == nil || taskID == "" { return } @@ -986,7 +986,7 @@ func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event ChatEv if event.AgentName == "" { event.AgentName = a.name } - p.sessions.BroadcastChatEvent(sid, event) + p.sessions.BroadcastDomainEvent(sid, event) } func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) { diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go index a9bfac84..78ca8d11 100644 --- a/pkg/web/eval_forward_test.go +++ b/pkg/web/eval_forward_test.go @@ -11,12 +11,12 @@ import ( type evalSink struct { sid string - chatEvents []ChatEvent + chatEvents []DomainEvent aopEvents []aop.Event } func (s *evalSink) TaskSession(string) (string, bool) { return s.sid, true } -func (s *evalSink) BroadcastChatEvent(_ string, event ChatEvent) { +func (s *evalSink) BroadcastDomainEvent(_ string, event DomainEvent) { s.chatEvents = append(s.chatEvents, event) } func (s *evalSink) BroadcastAOPEvent(_ string, event aop.Event) { diff --git a/pkg/web/service.go b/pkg/web/service.go index aeff029d..c4d51f14 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -1131,15 +1131,15 @@ func (s *Service) GetAOPEvents(ctx context.Context, sessionID string) ([]aop.Eve return s.store.ListAOPEvents(ctx, sessionID, 10000) } -func (s *Service) BroadcastChatEvent(sessionID string, event ChatEvent) { +func (s *Service) BroadcastDomainEvent(sessionID string, event DomainEvent) { event.SessionID = sessionID if !event.Transient { - s.persistRuntimeChatEvent(sessionID, event) + s.persistRuntimeDomainEvent(sessionID, event) } s.hub.Broadcast(sessionTopic(sessionID), HubEvent{ Type: event.Type, Data: mustJSON(event), - Reliable: isTerminalChatEvent(event.Type), + Reliable: isTerminalDomainEvent(event.Type), }) } @@ -1199,13 +1199,13 @@ func isReliableAOPEvent(event aop.Event) bool { return false } -// isTerminalChatEvent classifies terminal platform events. Agent run lifecycle +// isTerminalDomainEvent classifies terminal platform events. Agent run lifecycle // (including hub-originated failures) is carried exclusively by AOP. -func isTerminalChatEvent(t string) bool { - return t == ChatEventScanComplete +func isTerminalDomainEvent(t string) bool { + return t == DomainEventScanComplete } -func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) { +func (s *Service) persistRuntimeDomainEvent(sessionID string, event DomainEvent) { if s == nil || s.store == nil || sessionID == "" { return } @@ -1221,12 +1221,9 @@ func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) { metadata := map[string]any{ "event_type": event.Type, } - if event.Turn > 0 { - metadata["turn"] = event.Turn - } switch event.Type { - case ChatEventScanComplete: + case DomainEventScanComplete: // Persist a lightweight marker so the inline scan card survives a reload / // session switch. The heavy Result payload is NOT stored here — it stays // reloadable via the session_scans link (getScan), and the client fills the @@ -1356,7 +1353,7 @@ func (s *Service) handleClearCommand(sessionID string, opts webproto.GoalExt) { _ = s.store.ClearMessages(context.Background(), sessionID) // Transient: a live-only signal to connected clients — the cleared state is // already durable in the store, so a reconnecting client re-derives it on load. - s.BroadcastChatEvent(sessionID, ChatEvent{Type: ChatEventSessionCleared, Transient: true}) + s.BroadcastDomainEvent(sessionID, DomainEvent{Type: DomainEventSessionCleared, Transient: true}) if s.sessionAgent(sessionID) != nil { s.handleAgentCommand(sessionID, "/clear") } @@ -1477,8 +1474,8 @@ func (s *Service) handleScanCommand(sessionID, args string) { s.registerSessionTask(job.ID, sessionID, "") - s.BroadcastChatEvent(sessionID, ChatEvent{ - Type: ChatEventScanStarted, + s.BroadcastDomainEvent(sessionID, DomainEvent{ + Type: DomainEventScanStarted, ScanID: job.ID, Data: fmt.Sprintf("Scan started: %s (%s)", target, mode), }) @@ -1534,8 +1531,8 @@ func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts web taskID := generateID() s.registerSessionTask(taskID, sessionID, agent.id) - s.BroadcastChatEvent(sessionID, ChatEvent{ - Type: ChatEventAgentJoined, + s.BroadcastDomainEvent(sessionID, DomainEvent{ + Type: DomainEventAgentJoined, AgentID: agent.id, AgentName: agent.name, }) @@ -1643,8 +1640,8 @@ func (s *Service) broadcastScanComplete(scanID string, result *output.Result) { if s.finishSessionTask(scanID) { return } - s.BroadcastChatEvent(sid, ChatEvent{ - Type: ChatEventScanComplete, + s.BroadcastDomainEvent(sid, DomainEvent{ + Type: DomainEventScanComplete, ScanID: scanID, Result: result, }) diff --git a/pkg/web/sse.go b/pkg/web/sse.go index 5ba83ca1..49737eb2 100644 --- a/pkg/web/sse.go +++ b/pkg/web/sse.go @@ -18,7 +18,7 @@ type HubEvent struct { Data json.RawMessage // Reliable marks a terminal event that Broadcast must not drop under // backpressure: on a full buffer it evicts the oldest queued event to seat - // one, rather than shedding it like a token delta. See isTerminalChatEvent + // one, rather than shedding it like a token delta. See isTerminalDomainEvent // for which events qualify and why a lost one strands the UI. Reliable bool } diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index 790c7f27..c14efd4e 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -56,15 +56,15 @@ func TestHubBroadcastReliableSurvivesBackpressure(t *testing.T) { } } -// isTerminalChatEvent is the only test of the reliability classification: the +// isTerminalDomainEvent is the only test of the reliability classification: the // run-ending platform signal must qualify, or the stuck-cursor bug returns. // Agent lifecycle terminals are AOP events and covered by isReliableAOPEvent. -func TestIsTerminalChatEvent(t *testing.T) { - if !isTerminalChatEvent(ChatEventScanComplete) { - t.Errorf("%q should be terminal (reliable)", ChatEventScanComplete) +func TestIsTerminalDomainEvent(t *testing.T) { + if !isTerminalDomainEvent(DomainEventScanComplete) { + t.Errorf("%q should be terminal (reliable)", DomainEventScanComplete) } - for _, ty := range []string{ChatEventScanStarted, ChatEventScanProgress, ChatEventAgentJoined} { - if isTerminalChatEvent(ty) { + for _, ty := range []string{DomainEventScanStarted, DomainEventScanProgress, DomainEventAgentJoined} { + if isTerminalDomainEvent(ty) { t.Errorf("%q should not be terminal", ty) } } @@ -143,8 +143,8 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) { // A completed scan must leave a durable marker so its inline card survives a // timeline rebuild (reload / session switch). The heavy Result is intentionally // not stored — only the scan_id, which the client re-hydrates via scan_ids. - svc.BroadcastChatEvent("sess-scan", ChatEvent{ - Type: ChatEventScanComplete, + svc.BroadcastDomainEvent("sess-scan", DomainEvent{ + Type: DomainEventScanComplete, ScanID: "scan-123", }) @@ -159,12 +159,12 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) { if err := json.Unmarshal(msgs[0].Metadata, &metadata); err != nil { t.Fatalf("metadata json: %v", err) } - if metadata["event_type"] != ChatEventScanComplete || metadata["scan_id"] != "scan-123" { + if metadata["event_type"] != DomainEventScanComplete || metadata["scan_id"] != "scan-123" { t.Fatalf("scan marker metadata = %#v", metadata) } // A marker with no scan id is meaningless — it must not create a phantom row. - svc.BroadcastChatEvent("sess-scan-empty", ChatEvent{Type: ChatEventScanComplete}) + svc.BroadcastDomainEvent("sess-scan-empty", DomainEvent{Type: DomainEventScanComplete}) empty, _ := store.ListMessages(context.Background(), "sess-scan-empty", 100) if len(empty) != 0 { t.Fatalf("empty-scanID persisted messages = %d, want 0", len(empty)) diff --git a/pkg/web/types.go b/pkg/web/types.go index 8658aa9c..5643c3c5 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -194,11 +194,11 @@ type ChatMessage struct { } const ( - ChatEventScanStarted = "scan_started" - ChatEventScanProgress = "scan_progress" - ChatEventScanComplete = "scan_complete" - ChatEventAgentJoined = "agent_joined" - ChatEventSessionCleared = "session_cleared" + DomainEventScanStarted = "scan_started" + DomainEventScanProgress = "scan_progress" + DomainEventScanComplete = "scan_complete" + DomainEventAgentJoined = "agent_joined" + DomainEventSessionCleared = "session_cleared" ) // System message codes. A backend-generated system message carries a stable @@ -214,15 +214,11 @@ const ( SysAgentNotConnected = "agent_not_connected" ) -type ChatEvent struct { +type DomainEvent struct { Type string `json:"type"` SessionID string `json:"session_id"` - MessageID string `json:"message_id,omitempty"` - Role string `json:"role,omitempty"` AgentID string `json:"agent_id,omitempty"` AgentName string `json:"agent_name,omitempty"` - Turn int `json:"turn,omitempty"` - Content string `json:"content,omitempty"` ScanID string `json:"scan_id,omitempty"` Result *output.Result `json:"result,omitempty"` Data string `json:"data,omitempty"` diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 6e7821c4..9f2136a4 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -662,19 +662,15 @@ export interface ChatMessage { created_at: string } -export type ChatEventType = +export type DomainEventType = | 'scan_started' | 'scan_progress' | 'scan_complete' | 'agent_joined' | 'session_cleared' -export interface ChatEvent { - type: ChatEventType +export interface DomainEvent { + type: DomainEventType session_id: string - message_id?: string - role?: ChatMessage['role'] agent_id?: string agent_name?: string - turn?: number - content?: string scan_id?: string result?: ScanResult data?: string @@ -784,14 +780,14 @@ export async function fetchScanReport(scanID: string, lang: string): Promise void, + onEvent: (event: DomainEvent) => void, onReconnect?: () => void, onAOP?: (event: AOPEvent) => void, onOpen?: () => void, ): () => void { - const eventTypes: ChatEventType[] = [ + const eventTypes: DomainEventType[] = [ 'scan_started', 'scan_progress', 'scan_complete', 'agent_joined', 'session_cleared', ] @@ -804,7 +800,7 @@ export function subscribeChatEvents( const parsed = JSON.parse(data) onEvent({ ...parsed, type }) } catch { - onEvent({ type, session_id: sessionID, data } as ChatEvent) + onEvent({ type, session_id: sessionID, data } as DomainEvent) } } } diff --git a/web/frontend/src/hooks/useChatSession.ts b/web/frontend/src/hooks/useChatSession.ts index db228f63..640dbba8 100644 --- a/web/frontend/src/hooks/useChatSession.ts +++ b/web/frontend/src/hooks/useChatSession.ts @@ -10,10 +10,10 @@ import { listChatMessages, listChatSessions, sendChatMessage, - subscribeChatEvents, + subscribeDomainEvents, getScan, } from '../api' -import type { AgentInfo, AOPEvent, ChatEvent, ChatMessage, ChatSession, ScanResult } from '../api' +import type { AgentInfo, AOPEvent, DomainEvent, ChatMessage, ChatSession, ScanResult } from '../api' import { isRootPath, parseRoute, @@ -242,7 +242,7 @@ export function useChatSession() { setPendingResponse(false) } - function handleChatEvent(event: ChatEvent) { + function handleDomainEvent(event: DomainEvent) { const now = Date.now() switch (event.type) { @@ -460,9 +460,9 @@ export function useChatSession() { } catch {} if (activation !== activationRef.current) return - unsubRef.current = subscribeChatEvents( + unsubRef.current = subscribeDomainEvents( id, - handleChatEvent, + handleDomainEvent, () => reconcileAfterReconnect(id), handleAOPEvent, () => { From 7e1469f421bd02982ae100391c76427f9b0fe7c2 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:47:34 +0800 Subject: [PATCH 112/348] refactor(runner): centralize runtime control state --- core/runner/runner.go | 6 +- core/runner/runtime_protocol.go | 103 ++++++++++++ core/runner/runtime_protocol_test.go | 64 +++++++ core/runner/runtime_session.go | 156 +++++++++++++++--- core/runner/runtime_session_isolation_test.go | 2 +- core/runner/stdio.go | 131 +-------------- core/runner/stdio_concurrency_test.go | 2 - pkg/web/service.go | 29 +++- pkg/webagent/agent.go | 121 +------------- pkg/webagent/agent_test.go | 22 --- pkg/webagent/connection.go | 44 +---- pkg/webagent/connection_lifecycle_test.go | 17 +- pkg/webproto/message.go | 1 + 13 files changed, 351 insertions(+), 347 deletions(-) create mode 100644 core/runner/runtime_protocol.go create mode 100644 core/runner/runtime_protocol_test.go diff --git a/core/runner/runner.go b/core/runner/runner.go index 8063d5ff..5d1dda03 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -45,10 +45,11 @@ type AgentRuntime struct { cancel context.CancelFunc mu sync.RWMutex sessions map[string]*sessionState - turnIDs map[string]struct{} + runs map[string]*Run requestSeq uint64 closeOnce sync.Once wg sync.WaitGroup + operations sync.WaitGroup ptyManager *tmuxpkg.Manager replMode REPLMode maxPending int @@ -88,7 +89,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L ctx: runtimeCtx, cancel: runtimeCancel, sessions: make(map[string]*sessionState), - turnIDs: make(map[string]struct{}), + runs: make(map[string]*Run), } if rc != nil { rt.replMode = rc.REPLMode @@ -317,6 +318,7 @@ func (rt *AgentRuntime) Close() { _ = rt.CloseSession(context.Background(), id, SessionCloseRuntime) } rt.wg.Wait() + rt.operations.Wait() if rt.cleanup != nil { rt.cleanup() } diff --git a/core/runner/runtime_protocol.go b/core/runner/runtime_protocol.go new file mode 100644 index 00000000..97111dde --- /dev/null +++ b/core/runner/runtime_protocol.go @@ -0,0 +1,103 @@ +package runner + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +// HandleProtocol handles the transport-neutral Agent Runtime control frames. +// The caller owns framing and I/O; AgentRuntime owns all Session and Run state. +func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { + if rt == nil || send == nil { + return false + } + sendError := func(turnID, taskID string, err error) { + payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) + send(webproto.Message{Type: webproto.TypeError, TurnID: turnID, TaskID: taskID, Payload: payload}) + } + + switch msg.Type { + case webproto.TypeSessionOpen: + var payload webproto.SessionOpenPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", "", err) + return true + } + session, err := rt.EnsureSession(SessionOptions{ + ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, + }) + if err != nil { + sendError("", "", err) + return true + } + encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: session.ID()}) + send(webproto.Message{Type: webproto.TypeSessionOpened, Payload: encoded}) + return true + + case webproto.TypeSessionClose: + var payload webproto.SessionLifecyclePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", "", err) + return true + } + reason := SessionCloseReason(payload.Reason) + if err := rt.CloseSession(ctx, payload.SessionID, reason); err != nil { + sendError("", "", err) + return true + } + encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: string(reason)}) + send(webproto.Message{Type: webproto.TypeSessionClosed, Payload: encoded}) + return true + + case webproto.TypeRun: + if strings.TrimSpace(msg.TurnID) == "" { + sendError("", "", fmt.Errorf("run turn_id is required")) + return true + } + var payload webproto.RunPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(msg.TurnID, "", err) + return true + } + _, err := rt.RunSession(ctx, payload.SessionID, RunInput{ + TurnID: msg.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, + EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, Continue: payload.Continue, + }) + if err != nil { + sendError(msg.TurnID, "", err) + } + return true + + case webproto.TypeRunCancel: + if err := rt.CancelRun(msg.TurnID); err != nil { + sendError(msg.TurnID, "", err) + } + return true + + case webproto.TypeCommand: + var payload webproto.CommandPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", msg.TaskID, err) + return true + } + rt.operations.Add(1) + go func() { + defer rt.operations.Done() + result, err := rt.CommandSession(ctx, payload.SessionID, payload.Line) + if err != nil { + sendError("", msg.TaskID, err) + return + } + encoded, _ := json.Marshal(webproto.CommandResultPayload{ + SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata, + }) + send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) + }() + return true + } + return false +} diff --git a/core/runner/runtime_protocol_test.go b/core/runner/runtime_protocol_test.go new file mode 100644 index 00000000..096af634 --- /dev/null +++ b/core/runner/runtime_protocol_test.go @@ -0,0 +1,64 @@ +package runner + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func TestProtocolErrorsKeepDistinctCorrelationIDs(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + + var runError webproto.Message + if !rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeRun, TurnID: "turn-1", Payload: json.RawMessage(`{`), + }, func(message webproto.Message) { runError = message }) { + t.Fatal("run frame was not handled") + } + if runError.Type != webproto.TypeError || runError.TurnID != "turn-1" || runError.TaskID != "" { + t.Fatalf("run error correlation = %+v", runError) + } + + var commandError webproto.Message + if !rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeCommand, TaskID: "command-1", Payload: json.RawMessage(`{`), + }, func(message webproto.Message) { commandError = message }) { + t.Fatal("command frame was not handled") + } + if commandError.Type != webproto.TypeError || commandError.TaskID != "command-1" || commandError.TurnID != "" { + t.Fatalf("command error correlation = %+v", commandError) + } +} + +func TestProtocolRequiresTurnID(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + var response webproto.Message + rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeRun, Payload: webproto.MustJSON(webproto.RunPayload{SessionID: "session-1"}), + }, func(message webproto.Message) { response = message }) + var payload webproto.ErrorPayload + _ = json.Unmarshal(response.Payload, &payload) + if response.Type != webproto.TypeError || !strings.Contains(payload.Message, "turn_id is required") { + t.Fatalf("response = %+v payload=%+v", response, payload) + } +} + +func TestProtocolSessionOpenIsIdempotent(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + request := webproto.Message{ + Type: webproto.TypeSessionOpen, + Payload: webproto.MustJSON(webproto.SessionOpenPayload{SessionID: "session-1"}), + } + for i := 0; i < 2; i++ { + var response webproto.Message + if !rt.HandleProtocol(context.Background(), request, func(message webproto.Message) { response = message }) { + t.Fatal("session.open was not handled") + } + if response.Type != webproto.TypeSessionOpened { + t.Fatalf("open %d response = %+v", i, response) + } + } +} diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index 1e29502d..ed0d95f2 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -71,6 +71,7 @@ type Session struct { type Run struct { turnID string done chan struct{} + cancel context.CancelFunc mu sync.Mutex result RunResult err error @@ -272,17 +273,19 @@ func (m *sessionMailbox) RegisterProducer(name string) *inboxpkg.ProducerHandle func (m *sessionMailbox) ActiveProducers() int { return m.base.ActiveProducers() } type sessionState struct { - runtime *AgentRuntime - id string - agentName string - agent *agent.Agent - inbox *sessionMailbox - scheduler *agent.LoopScheduler - commands *commandSession - ctx context.Context - cancel context.CancelFunc - ops chan *sessionOperation - done chan struct{} + runtime *AgentRuntime + id string + agentName string + parentSessionID string + parentToolCallID string + agent *agent.Agent + inbox *sessionMailbox + scheduler *agent.LoopScheduler + commands *commandSession + ctx context.Context + cancel context.CancelFunc + ops chan *sessionOperation + done chan struct{} mu sync.Mutex pending int @@ -338,7 +341,9 @@ func (rt *AgentRuntime) OpenSession(ctx context.Context, options SessionOptions) ag.LoadMessages(rt.resumeMessages) } state := &sessionState{ - runtime: rt, id: id, agentName: agentName, agent: ag, inbox: mailbox, + runtime: rt, id: id, agentName: agentName, + parentSessionID: options.ParentSessionID, parentToolCallID: options.ParentToolCallID, + agent: ag, inbox: mailbox, scheduler: scheduler, ctx: sessionCtx, cancel: cancel, ops: make(chan *sessionOperation, rt.pendingLimit()), done: make(chan struct{}), } @@ -363,6 +368,50 @@ func (rt *AgentRuntime) OpenSession(ctx context.Context, options SessionOptions) return public, nil } +// EnsureSession returns an existing Runtime-owned Session or opens it with the +// Runtime lifetime. It is idempotent so a transport reconnect can safely +// announce the same logical Session again. +func (rt *AgentRuntime) EnsureSession(options SessionOptions) (*Session, error) { + if rt == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + id := strings.TrimSpace(options.ID) + if id != "" { + rt.mu.RLock() + state := rt.sessions[id] + rt.mu.RUnlock() + if state != nil { + return ensuredSession(state, options) + } + } + session, err := rt.OpenSession(rt.ctx, options) + if err == nil || id == "" { + return session, err + } + // Concurrent reconnects may both observe the Session as absent. The strict + // OpenSession call admits one; the loser re-reads and validates that Session. + rt.mu.RLock() + state := rt.sessions[id] + rt.mu.RUnlock() + if state == nil { + return nil, err + } + return ensuredSession(state, options) +} + +func ensuredSession(state *sessionState, options SessionOptions) (*Session, error) { + if options.ParentSessionID != "" && options.ParentSessionID != state.parentSessionID { + return nil, fmt.Errorf("session %q parent_session_id conflicts with open session", state.id) + } + if options.ParentToolCallID != "" && options.ParentToolCallID != state.parentToolCallID { + return nil, fmt.Errorf("session %q parent_tool_call_id conflicts with open session", state.id) + } + if options.AgentName != "" && options.AgentName != state.agentName { + return nil, fmt.Errorf("session %q agent name conflicts with open session", state.id) + } + return &Session{state: state}, nil +} + func (rt *AgentRuntime) CloseSession(ctx context.Context, sessionID string, reason SessionCloseReason) error { if rt == nil { return fmt.Errorf("agent runtime is not configured") @@ -404,6 +453,58 @@ func (rt *AgentRuntime) Subscribe(fn func(aop.Event)) func() { return rt.bus.Subscribe(fn) } +func (rt *AgentRuntime) session(sessionID string) (*Session, error) { + if rt == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + rt.mu.RLock() + state := rt.sessions[strings.TrimSpace(sessionID)] + rt.mu.RUnlock() + if state == nil { + return nil, fmt.Errorf("session %q is not open", sessionID) + } + return &Session{state: state}, nil +} + +func (rt *AgentRuntime) RunSession(ctx context.Context, sessionID string, input RunInput) (*Run, error) { + session, err := rt.session(sessionID) + if err != nil { + return nil, err + } + return session.Run(ctx, input) +} + +func (rt *AgentRuntime) CommandSession(ctx context.Context, sessionID, line string) (CommandResult, error) { + session, err := rt.session(sessionID) + if err != nil { + return CommandResult{}, err + } + return session.Command(ctx, line) +} + +func (rt *AgentRuntime) CancelRun(turnID string) error { + if rt == nil { + return fmt.Errorf("agent runtime is not configured") + } + turnID = strings.TrimSpace(turnID) + rt.mu.RLock() + run := rt.runs[turnID] + rt.mu.RUnlock() + if run == nil { + return fmt.Errorf("turn %q is not active", turnID) + } + run.cancel() + return nil +} + +// WaitOperations waits for all Runs and asynchronous control operations that +// were admitted before the call. Transports use it to drain before shutdown. +func (rt *AgentRuntime) WaitOperations() { + if rt != nil { + rt.operations.Wait() + } +} + func (s *Session) Run(ctx context.Context, input RunInput) (*Run, error) { if s == nil || s.state == nil { return nil, fmt.Errorf("session is not configured") @@ -456,18 +557,24 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro if turnID == "" { turnID = s.runtime.nextRuntimeID("turn") } + if ctx == nil { + ctx = context.Background() + } + runCtx, runCancel := context.WithCancel(ctx) + run := &Run{turnID: turnID, done: make(chan struct{}), cancel: runCancel} s.runtime.mu.Lock() - if _, exists := s.runtime.turnIDs[turnID]; exists { + if _, exists := s.runtime.runs[turnID]; exists { s.runtime.mu.Unlock() + runCancel() return nil, fmt.Errorf("turn %q already exists", turnID) } - s.runtime.turnIDs[turnID] = struct{}{} + s.runtime.runs[turnID] = run + s.runtime.operations.Add(1) s.runtime.mu.Unlock() - run := &Run{turnID: turnID, done: make(chan struct{})} emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents} op := &sessionOperation{ execute: func(runCtx context.Context) { - defer s.runtime.releaseTurnID(turnID) + defer s.runtime.releaseRun(run) s.inbox.setActive(true) emitter.start() result, runErr := s.executeRun(runCtx, turnID, input) @@ -489,7 +596,7 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro run.finish(runResult, runErr) }, reject: func(err error) { - defer s.runtime.releaseTurnID(turnID) + defer s.runtime.releaseRun(run) result := RunResult{Stop: agent.StopReasonCanceled} if !errors.Is(err, context.Canceled) { result.Stop = agent.StopReasonError @@ -499,8 +606,8 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro run.finish(result, err) }, } - if err := s.admit(ctx, op); err != nil { - s.runtime.releaseTurnID(turnID) + if err := s.admit(runCtx, op); err != nil { + s.runtime.releaseRun(run) return nil, err } return run, nil @@ -664,10 +771,17 @@ func (rt *AgentRuntime) nextRuntimeID(prefix string) string { return id } -func (rt *AgentRuntime) releaseTurnID(turnID string) { +func (rt *AgentRuntime) releaseRun(run *Run) { + if run == nil { + return + } + run.cancel() rt.mu.Lock() - delete(rt.turnIDs, turnID) + if rt.runs[run.turnID] == run { + delete(rt.runs, run.turnID) + } rt.mu.Unlock() + rt.operations.Done() } func (rt *AgentRuntime) providerSnapshot() (agent.Provider, string, telemetry.Logger) { diff --git a/core/runner/runtime_session_isolation_test.go b/core/runner/runtime_session_isolation_test.go index 7d82d66b..14b328d1 100644 --- a/core/runner/runtime_session_isolation_test.go +++ b/core/runner/runtime_session_isolation_test.go @@ -26,7 +26,7 @@ func newBareRuntime(t *testing.T, reg *commands.CommandRegistry, provider agent. kernelBus.Subscribe(events.emit) rt := &AgentRuntime{ app: &App{Commands: reg}, option: &cfg.Option{}, ctx: ctx, cancel: cancel, - sessions: make(map[string]*sessionState), turnIDs: make(map[string]struct{}), + sessions: make(map[string]*sessionState), runs: make(map[string]*Run), bus: publicBus, kernelBus: kernelBus, sessionEvents: events, config: agent.Config{Provider: provider, Tools: reg, Bus: kernelBus, Logger: telemetry.NopLogger()}, } diff --git a/core/runner/stdio.go b/core/runner/stdio.go index 4fc34aba..aa7c4516 100644 --- a/core/runner/stdio.go +++ b/core/runner/stdio.go @@ -49,17 +49,12 @@ type stdioHost struct { enc *json.Encoder encErr error - rt *AgentRuntime - mu sync.Mutex - sessions map[string]*Session - runs map[string]context.CancelFunc - wg sync.WaitGroup + rt *AgentRuntime } func newStdioHost(ctx context.Context, option *cfg.Option, logger telemetry.Logger, output io.Writer) *stdioHost { return &stdioHost{ ctx: ctx, option: option, logger: logger, enc: json.NewEncoder(output), - sessions: make(map[string]*Session), runs: make(map[string]context.CancelFunc), } } @@ -102,11 +97,6 @@ func (h *stdioHost) emitError(turnID string, err error) { _ = h.emit(webproto.Message{Type: webproto.TypeError, TurnID: turnID, Payload: payload}) } -func (h *stdioHost) emitTaskError(taskID string, err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - _ = h.emit(webproto.Message{Type: webproto.TypeError, TaskID: taskID, Payload: payload}) -} - func (h *stdioHost) err() error { h.encMu.Lock() defer h.encMu.Unlock() @@ -122,120 +112,13 @@ func (h *stdioHost) accept(line string) { h.emitError("", fmt.Errorf("decode frame: %w", err)) return } - switch message.Type { - case webproto.TypeSessionOpen: - var payload webproto.SessionOpenPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError("", err) - return - } - session, err := h.rt.OpenSession(h.ctx, SessionOptions{ - ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, - }) - if err != nil { - h.emitError("", err) - return - } - h.mu.Lock() - h.sessions[session.ID()] = session - h.mu.Unlock() - opened, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: session.ID()}) - _ = h.emit(webproto.Message{Type: webproto.TypeSessionOpened, Payload: opened}) - - case webproto.TypeSessionClose: - var payload webproto.SessionLifecyclePayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError("", err) - return - } - reason := SessionCloseReason(payload.Reason) - if err := h.rt.CloseSession(h.ctx, payload.SessionID, reason); err != nil { - h.emitError("", err) - return - } - h.mu.Lock() - delete(h.sessions, payload.SessionID) - h.mu.Unlock() - closed, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: string(reason)}) - _ = h.emit(webproto.Message{Type: webproto.TypeSessionClosed, Payload: closed}) - - case webproto.TypeRun: - var payload webproto.RunPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError(message.TurnID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - h.emitError(message.TurnID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - runCtx, cancel := context.WithCancel(h.ctx) - run, err := session.Run(runCtx, RunInput{ - TurnID: message.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, - EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, - }) - if err != nil { - cancel() - h.emitError(message.TurnID, err) - return - } - turnID := run.TurnID() - h.mu.Lock() - h.runs[turnID] = cancel - h.mu.Unlock() - h.wg.Add(1) - go func() { - defer h.wg.Done() - defer cancel() - _, _ = run.Wait() - h.mu.Lock() - delete(h.runs, turnID) - h.mu.Unlock() - }() - - case webproto.TypeRunCancel: - h.mu.Lock() - cancel := h.runs[message.TurnID] - h.mu.Unlock() - if cancel == nil { - h.emitError(message.TurnID, fmt.Errorf("turn %q is not active", message.TurnID)) - return - } - cancel() - - case webproto.TypeCommand: - var payload webproto.CommandPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitTaskError(message.TaskID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - h.emitTaskError(message.TaskID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - h.wg.Add(1) - go func() { - defer h.wg.Done() - result, err := session.Command(h.ctx, payload.Line) - if err != nil { - h.emitTaskError(message.TaskID, err) - return - } - encoded, _ := json.Marshal(webproto.CommandResultPayload{ - SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata, - }) - _ = h.emit(webproto.Message{Type: webproto.TypeCommandResult, TaskID: message.TaskID, Payload: encoded}) - }() - - default: + if h.rt == nil || !h.rt.HandleProtocol(h.ctx, message, func(response webproto.Message) { _ = h.emit(response) }) { h.emitError(message.TurnID, fmt.Errorf("unsupported frame type %q", message.Type)) } } -func (h *stdioHost) drain() { h.wg.Wait() } +func (h *stdioHost) drain() { + if h.rt != nil { + h.rt.WaitOperations() + } +} diff --git a/core/runner/stdio_concurrency_test.go b/core/runner/stdio_concurrency_test.go index df453250..d8729d6c 100644 --- a/core/runner/stdio_concurrency_test.go +++ b/core/runner/stdio_concurrency_test.go @@ -68,8 +68,6 @@ func newStdioTestSession(t *testing.T, h *stdioHost, output *bytes.Buffer, id st if h.rt == nil || h.rt.ctx == nil { initialized := newRuntimeStdioHost(t, output, prov) h.rt = initialized.rt - h.sessions = initialized.sessions - h.runs = initialized.runs } h.accept(openSessionLine(t, id)) } diff --git a/pkg/web/service.go b/pkg/web/service.go index c4d51f14..ef1fb211 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -1330,8 +1330,17 @@ func (s *Service) dispatchUserMessage(sessionID string, msg *ChatMessage, opts w case "exit", "quit": s.closeRemoteSession(sessionID) return - case "continue", "followup": - // These are Runs: the adapter normalizes their prompt semantics. + case "continue": + s.handleAgentRun(sessionID, webproto.RunPayload{ + SessionID: sessionID, Continue: true, NoEcho: true, + MaxTurns: opts.PersistMaxTurns, EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, + }) + return + case "followup": + followup := *msg + followup.Content = strings.TrimSpace(args) + s.handleChatMessage(sessionID, &followup, opts) + return default: if !strings.HasPrefix(content, "/skill:") { s.handleAgentCommand(sessionID, content) @@ -1521,6 +1530,16 @@ func (s *Service) sessionAgent(sessionID string) *remoteAgent { } func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts webproto.GoalExt) { + run := webproto.RunPayload{ + SessionID: sessionID, + Parts: []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(msg.Content)}}, + NoEcho: true, MaxTurns: opts.PersistMaxTurns, + EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, + } + s.handleAgentRun(sessionID, run) +} + +func (s *Service) handleAgentRun(sessionID string, run webproto.RunPayload) { agent := s.sessionAgent(sessionID) if agent == nil { s.broadcastSystemMessage(sessionID, SysAgentNotConnected, @@ -1537,12 +1556,6 @@ func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts web AgentName: agent.name, }) - run := webproto.RunPayload{ - SessionID: sessionID, - Parts: []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(msg.Content)}}, - NoEcho: true, MaxTurns: opts.PersistMaxTurns, - EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, - } resultCh, err := s.agents.DispatchRun(agent.id, taskID, run) if err != nil { s.finishSessionTask(taskID) diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index 18d9f4cd..b3923e73 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "sync" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/runner" @@ -60,7 +59,6 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg chatHandler := &chatAgentHandler{ rt: rt, serverURL: option.WebURL, - sessions: make(map[string]*runner.Session), app: application, option: option, logger: logger, @@ -107,12 +105,11 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg return nil } - startup, err := rt.OpenSession(ctx, runner.SessionOptions{ID: "startup"}) + _, err = rt.EnsureSession(runner.SessionOptions{ID: "startup"}) if err != nil { return err } - chatHandler.sessions["startup"] = startup - run, err := startup.Run(ctx, runner.RunInput{TurnID: "startup", Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}}) + run, err := rt.RunSession(ctx, "startup", runner.RunInput{TurnID: "startup", Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}}) if err == nil { _, err = run.Wait() } @@ -129,123 +126,13 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg type chatAgentHandler struct { rt *runner.AgentRuntime serverURL string - mu sync.Mutex - sessions map[string]*runner.Session app *runner.App option *cfg.Option logger telemetry.Logger } -func (h *chatAgentHandler) HandleSessionOpen(ctx context.Context, msg webproto.Message, send func(webproto.Message)) { - var payload webproto.SessionOpenPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendProtocolError(send, "", "", err) - return - } - session, err := h.rt.OpenSession(ctx, runner.SessionOptions{ - ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, - }) - if err != nil { - sendProtocolError(send, "", "", err) - return - } - sessionID := session.ID() - h.mu.Lock() - h.sessions[sessionID] = session - h.mu.Unlock() - encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: sessionID}) - send(webproto.Message{Type: webproto.TypeSessionOpened, Payload: encoded}) -} - -func (h *chatAgentHandler) HandleSessionClose(ctx context.Context, msg webproto.Message, send func(webproto.Message)) { - var payload webproto.SessionLifecyclePayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendProtocolError(send, "", "", err) - return - } - reason := runner.SessionCloseReason(payload.Reason) - if err := h.rt.CloseSession(ctx, payload.SessionID, reason); err != nil { - sendProtocolError(send, "", "", err) - return - } - h.mu.Lock() - delete(h.sessions, payload.SessionID) - h.mu.Unlock() - encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: payload.Reason}) - send(webproto.Message{Type: webproto.TypeSessionClosed, Payload: encoded}) -} - -func (h *chatAgentHandler) HandleRun(ctx context.Context, msg webproto.Message, send func(webproto.Message)) func() { - var payload webproto.RunPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - return func() { sendProtocolError(send, msg.TurnID, "", err) } - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - return func() { - sendProtocolError(send, msg.TurnID, "", fmt.Errorf("session %q is not open", payload.SessionID)) - } - } - input := runner.RunInput{ - TurnID: msg.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, - EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, - } - prompt := strings.TrimSpace(partsText(payload.Parts)) - if prompt == "/continue" { - input.Continue = true - input.Parts = nil - } else if strings.HasPrefix(prompt, "/followup ") { - input.Parts = []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(strings.TrimPrefix(prompt, "/followup "))}} - } - run, err := session.Run(ctx, input) - if err != nil { - return func() { sendProtocolError(send, msg.TurnID, "", err) } - } - return func() { _, _ = run.Wait() } -} - -func (h *chatAgentHandler) HandleCommand(ctx context.Context, msg webproto.Message, send func(webproto.Message)) { - var payload webproto.CommandPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendProtocolError(send, "", msg.TaskID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - sendProtocolError(send, "", msg.TaskID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - result, err := session.Command(ctx, payload.Line) - if err != nil { - sendProtocolError(send, "", msg.TaskID, err) - return - } - for i := range result.Parts { - if result.Parts[i].Type == aop.PartText { - result.Parts[i].Text = fenceTerminalOutput(result.Parts[i].Text) - } - } - encoded, _ := json.Marshal(webproto.CommandResultPayload{SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata}) - send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) -} - -func sendProtocolError(send func(webproto.Message), turnID, taskID string, err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - send(webproto.Message{Type: webproto.TypeError, TurnID: turnID, TaskID: taskID, Payload: payload}) -} - -func partsText(parts []aop.MessagePart) string { - var values []string - for _, part := range parts { - if part.Type == aop.PartText && part.Text != "" { - values = append(values, part.Text) - } - } - return strings.Join(values, "\n") +func (h *chatAgentHandler) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { + return h.rt != nil && h.rt.HandleProtocol(ctx, msg, send) } func (h *chatAgentHandler) HandleUpload(msg webproto.Message, send func(webproto.Message)) { diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go index 1e25ce05..160510da 100644 --- a/pkg/webagent/agent_test.go +++ b/pkg/webagent/agent_test.go @@ -16,7 +16,6 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/runner" "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/webproto" @@ -41,27 +40,6 @@ func TestWebNodeRefUsesWebIdentity(t *testing.T) { } } -func TestRunAndCommandErrorsKeepDistinctCorrelationIDs(t *testing.T) { - h := &chatAgentHandler{sessions: make(map[string]*runner.Session)} - - var runError webproto.Message - wait := h.HandleRun(context.Background(), webproto.Message{ - Type: webproto.TypeRun, TurnID: "turn-1", Payload: json.RawMessage(`{`), - }, func(message webproto.Message) { runError = message }) - wait() - if runError.Type != webproto.TypeError || runError.TurnID != "turn-1" || runError.TaskID != "" { - t.Fatalf("run error correlation = %+v", runError) - } - - var commandError webproto.Message - h.HandleCommand(context.Background(), webproto.Message{ - Type: webproto.TypeCommand, TaskID: "command-1", Payload: json.RawMessage(`{`), - }, func(message webproto.Message) { commandError = message }) - if commandError.Type != webproto.TypeError || commandError.TaskID != "command-1" || commandError.TurnID != "" { - t.Fatalf("command error correlation = %+v", commandError) - } -} - func connectForTest(ctx context.Context, serverURL, name string, reg *commands.CommandRegistry, bus *eventbus.Bus[aop.Event]) error { if _, ok := reg.GetTool("bash"); !ok { bash := commands.NewBashTool(".", 5) diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go index aa184c8f..4ce55c9e 100644 --- a/pkg/webagent/connection.go +++ b/pkg/webagent/connection.go @@ -57,10 +57,7 @@ type connectionConfig struct { // Implementations live in webagent or other packages that have access to the // agent runtime and provider. type chatHandler interface { - HandleSessionOpen(ctx context.Context, msg webproto.Message, send func(webproto.Message)) - HandleSessionClose(ctx context.Context, msg webproto.Message, send func(webproto.Message)) - HandleRun(ctx context.Context, msg webproto.Message, send func(webproto.Message)) func() - HandleCommand(ctx context.Context, msg webproto.Message, send func(webproto.Message)) + HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool // HandleUpload processes a file upload message. HandleUpload(msg webproto.Message, send func(webproto.Message)) @@ -224,7 +221,6 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg var mu sync.Mutex execTasks := make(map[string]context.CancelFunc) // active tool.call tasks - turnCancels := make(map[string]context.CancelFunc) // Tool telemetry: scanner tool.data and normalized tool.sco events ride the // same connection, correlated to the calling task by call ID. @@ -299,35 +295,11 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg } switch msg.Type { - case webproto.TypeSessionOpen: + case webproto.TypeSessionOpen, webproto.TypeSessionClose, webproto.TypeRun, webproto.TypeRunCancel: if cc.Chat != nil { - cc.Chat.HandleSessionOpen(connectionCtx, msg, send) + cc.Chat.HandleProtocol(connectionCtx, msg, send) } - case webproto.TypeSessionClose: - if cc.Chat != nil { - cc.Chat.HandleSessionClose(connectionCtx, msg, send) - } - - case webproto.TypeRun: - if cc.Chat == nil || msg.TurnID == "" { - continue - } - runCtx, runCancel := context.WithCancel(connectionCtx) - mu.Lock() - turnCancels[msg.TurnID] = runCancel - mu.Unlock() - wait := cc.Chat.HandleRun(runCtx, msg, send) - go func(turnID string) { - defer runCancel() - defer func() { - mu.Lock() - delete(turnCancels, turnID) - mu.Unlock() - }() - wait() - }(msg.TurnID) - case webproto.TypeCommand: var command webproto.CommandPayload if json.Unmarshal(msg.Payload, &command) != nil { @@ -348,7 +320,7 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg HandleToolCommand(taskCtx, m, call, cc.Registry, cc.DataBus, send) }(msg, *command.ToolCall) } else if cc.Chat != nil { - go cc.Chat.HandleCommand(connectionCtx, msg, send) + cc.Chat.HandleProtocol(connectionCtx, msg, send) } case "upload": @@ -392,14 +364,6 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg go cc.Chat.HandleConfigReload(cc.ServerURL, send) } - case webproto.TypeRunCancel: - mu.Lock() - cancel := turnCancels[msg.TurnID] - mu.Unlock() - if cancel != nil { - cancel() - } - case "cancel": mu.Lock() if cancel, ok := execTasks[msg.TaskID]; ok { diff --git a/pkg/webagent/connection_lifecycle_test.go b/pkg/webagent/connection_lifecycle_test.go index f14d5b4b..2d908215 100644 --- a/pkg/webagent/connection_lifecycle_test.go +++ b/pkg/webagent/connection_lifecycle_test.go @@ -22,19 +22,16 @@ type disconnectChatHandler struct { once sync.Once } -func (h *disconnectChatHandler) HandleRun(ctx context.Context, _ webproto.Message, _ func(webproto.Message)) func() { +func (h *disconnectChatHandler) HandleProtocol(ctx context.Context, msg webproto.Message, _ func(webproto.Message)) bool { + if msg.Type != webproto.TypeRun { + return false + } h.once.Do(func() { close(h.started) }) - return func() { + go func() { <-ctx.Done() close(h.canceled) - } -} - -func (*disconnectChatHandler) HandleSessionOpen(context.Context, webproto.Message, func(webproto.Message)) { -} -func (*disconnectChatHandler) HandleSessionClose(context.Context, webproto.Message, func(webproto.Message)) { -} -func (*disconnectChatHandler) HandleCommand(context.Context, webproto.Message, func(webproto.Message)) { + }() + return true } func (*disconnectChatHandler) HandleUpload(webproto.Message, func(webproto.Message)) {} func (*disconnectChatHandler) HandleConfigReload(string, func(webproto.Message)) {} diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go index ee7688e6..1669a00a 100644 --- a/pkg/webproto/message.go +++ b/pkg/webproto/message.go @@ -46,6 +46,7 @@ type SessionLifecyclePayload struct { type RunPayload struct { SessionID string `json:"session_id"` Parts []aop.MessagePart `json:"parts"` + Continue bool `json:"continue,omitempty"` NoEcho bool `json:"no_echo,omitempty"` MaxTurns int `json:"max_turns,omitempty"` EvalCriteria string `json:"eval_criteria,omitempty"` From 8658d10d37e9b0857a4c8cc7700d22decf8cec28 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:56:17 +0800 Subject: [PATCH 113/348] refactor(protocol): route direct tool execution through AOP --- pkg/agent/loop.go | 10 +--- pkg/aop/tool_result.go | 60 +++++++++++++++++++ pkg/aop/tool_result_test.go | 34 +++++++++++ pkg/web/agents.go | 106 +++++++++++++++------------------- pkg/web/agents_test.go | 26 +++++---- pkg/webagent/agent_test.go | 15 +++-- pkg/webagent/aop_tool.go | 70 +++++++++++----------- pkg/webagent/aop_tool_test.go | 62 +++++++++++++++----- pkg/webagent/connection.go | 41 +++++++------ pkg/webagent/toolnode_test.go | 30 ++++++---- pkg/webproto/message.go | 5 +- 11 files changed, 290 insertions(+), 169 deletions(-) create mode 100644 pkg/aop/tool_result.go create mode 100644 pkg/aop/tool_result_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0a1f1332..744420e9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -535,14 +535,8 @@ func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc T // eventContent returns the AOP tool.result payload: a plain string, or the // {content, images} variant when the tool returned images. func (e toolExecution) eventContent() any { - if e.fullResult != nil && e.fullResult.HasImages() { - trc := aop.ToolResultContent{Content: e.eventResultText()} - for _, block := range e.fullResult.Content { - if block.Type == "image" { - trc.Images = append(trc.Images, aop.ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}) - } - } - return trc + if e.fullResult != nil { + return aop.ToolResultContentFromResult(*e.fullResult, e.eventResultText()) } return e.eventResultText() } diff --git a/pkg/aop/tool_result.go b/pkg/aop/tool_result.go new file mode 100644 index 00000000..93ad9d0a --- /dev/null +++ b/pkg/aop/tool_result.go @@ -0,0 +1,60 @@ +package aop + +import ( + "fmt" + "time" + + "github.com/chainreactors/aiscan/core/tool" +) + +// ToolResultContentFromResult converts the canonical tool Result blocks to the +// AOP content variant without flattening images or changing the supplied text. +func ToolResultContentFromResult(result tool.Result, text string) any { + if !result.HasImages() { + return text + } + content := ToolResultContent{Content: text} + for _, block := range result.Content { + if block.Type == "image" { + content.Images = append(content.Images, ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}) + } + } + return content +} + +// ToolResultDataFromResult is the single conversion used by Agent-internal and +// direct remote tool execution. +func ToolResultDataFromResult(call ToolCallData, result tool.Result, execErr error, duration time.Duration) ToolResultData { + text := result.Text() + if execErr != nil { + text = execErr.Error() + } + return ToolResultData{ + ToolCallID: call.ToolCallID, + ToolName: call.ToolName, + Content: ToolResultContentFromResult(result, text), + Details: result.Details, + Terminate: result.Terminate, + IsError: execErr != nil || result.IsError, + DurationMs: int(duration.Milliseconds()), + } +} + +// ToolResultText reads both in-memory and JSON-decoded structured content. +func ToolResultText(content any) string { + switch value := content.(type) { + case string: + return value + case ToolResultContent: + return value.Content + case *ToolResultContent: + if value != nil { + return value.Content + } + case map[string]any: + if text, ok := value["content"].(string); ok { + return text + } + } + return fmt.Sprint(content) +} diff --git a/pkg/aop/tool_result_test.go b/pkg/aop/tool_result_test.go new file mode 100644 index 00000000..3f213dd0 --- /dev/null +++ b/pkg/aop/tool_result_test.go @@ -0,0 +1,34 @@ +package aop + +import ( + "errors" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/tool" +) + +func TestToolResultDataFromResultPreservesStructuredContent(t *testing.T) { + result := tool.Result{ + Content: []tool.ContentBlock{ + tool.TextBlock("done"), + tool.ImageBlock("image/png", "aGVsbG8="), + }, + Details: map[string]any{"ports": 3}, Terminate: true, + } + data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1", ToolName: "scan"}, result, nil, 12*time.Millisecond) + if data.ToolCallID != "call-1" || data.ToolName != "scan" || data.DurationMs != 12 || !data.Terminate || data.IsError { + t.Fatalf("data = %+v", data) + } + content, ok := data.Content.(ToolResultContent) + if !ok || content.Content != "done" || len(content.Images) != 1 || content.Images[0].MediaType != "image/png" { + t.Fatalf("content = %#v", data.Content) + } +} + +func TestToolResultDataFromResultUsesExecutionError(t *testing.T) { + data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1"}, tool.TextResult("partial"), errors.New("failed"), 0) + if !data.IsError || ToolResultText(data.Content) != "failed" { + t.Fatalf("data = %+v", data) + } +} diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 3090d552..b1c0bfcc 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "sync" "sync/atomic" "time" @@ -265,31 +264,51 @@ func (p *AgentPool) PickChat() *remoteAgent { return fallback } -// DispatchToolCall sends a structured Command to a tool-capable node and -// returns a channel for the result. taskID correlates this non-Run RPC and its -// progress telemetry. +// DispatchToolCall sends a canonical AOP tool.call to a tool-capable node. +// The task completes only on the matching AOP tool.result. func (p *AgentPool) DispatchToolCall(agentID, taskID string, call aop.ToolCallData) (<-chan taskResult, error) { - payload, err := json.Marshal(webproto.CommandPayload{SessionID: taskID, ToolCall: &call}) - if err != nil { - return nil, fmt.Errorf("marshal command: %w", err) + a := p.get(agentID) + if a == nil { + return nil, fmt.Errorf("agent %s not connected", agentID) } - if a := p.get(agentID); a != nil { - a.mu.Lock() - if a.toolCalls == nil { - a.toolCalls = map[string]struct{}{} + call.ToolCallID = taskID + sessionID := taskID + if p.sessions != nil { + if sid, ok := p.sessions.TaskSession(taskID); ok { + sessionID = sid } - a.toolCalls[taskID] = struct{}{} - a.mu.Unlock() } - ch, err := p.dispatchMessage(agentID, taskID, webproto.Message{Type: webproto.TypeCommand, TaskID: taskID, Payload: payload}) + agentName := a.name + if agentName == "" { + agentName = a.id + } + data, err := json.Marshal(call) if err != nil { - if a := p.get(agentID); a != nil { - a.mu.Lock() - delete(a.toolCalls, taskID) - a.mu.Unlock() - } + return nil, fmt.Errorf("marshal tool.call: %w", err) + } + event := aop.Event{ + Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano), + SessionID: sessionID, TurnID: taskID, Agent: agentName, Data: data, + } + payload, _ := json.Marshal(event) + a.mu.Lock() + if a.toolCalls == nil { + a.toolCalls = map[string]struct{}{} + } + a.toolCalls[taskID] = struct{}{} + a.mu.Unlock() + ch, err := p.dispatchMessage(agentID, taskID, webproto.Message{ + Type: webproto.TypeAOP, TaskID: taskID, TurnID: taskID, Payload: payload, + }) + if err != nil { + a.mu.Lock() + delete(a.toolCalls, taskID) + a.mu.Unlock() return nil, err } + if p.sessions != nil && sessionID != taskID { + p.sessions.BroadcastAOPEvent(sessionID, event) + } return ch, nil } @@ -878,25 +897,10 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) { if ok && ch != nil { result := taskResult{Result: msg.Payload} if isToolCall { - var command webproto.CommandResultPayload - if err := json.Unmarshal(msg.Payload, &command); err != nil { - result.Err = "decode command.result: " + err.Error() - } else { - result.Output = commandPartsText(command.Parts) - if isError, _ := command.Metadata["is_error"].(bool); isError { - result.Err, result.Output = result.Output, "" - } - if details := command.Metadata["details"]; details != nil { - result.Result, _ = json.Marshal(details) - } - } + result = taskResult{Err: "direct tool task returned command.result; expected AOP tool.result"} } ch <- result close(ch) - if isToolCall { - p.recordScanResultStats(a, result.Result) - p.persistResultRecords(a, msg.TaskID, result.Result) - } } // complete/error are the terminal envelopes of the file RPCs only; agent @@ -1000,7 +1004,11 @@ func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) { // Session-topic broadcast is optional (scans dispatched outside chat have // no chat session); task convergence below is not. if p.sessions != nil { - if sid, ok := p.sessions.TaskSession(msg.TurnID); ok { + correlationID := msg.TurnID + if msg.TaskID != "" { + correlationID = msg.TaskID + } + if sid, ok := p.sessions.TaskSession(correlationID); ok { p.sessions.BroadcastAOPEvent(sid, aopEv) } else if aopEv.SessionID != "" { p.sessions.BroadcastAOPEvent(aopEv.SessionID, aopEv) @@ -1045,7 +1053,7 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a close(ch) return } - res := taskResult{Output: toolResultText(d.Content), Turn: turn} + res := taskResult{Output: aop.ToolResultText(d.Content), Turn: turn} if d.IsError { res.Err = res.Output res.Output = "" @@ -1061,30 +1069,6 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a p.persistResultRecords(a, taskID, details) } -// toolResultText flattens tool.result content: a plain string, or the text of -// a structured ToolResultContent (text plus images). -func toolResultText(content any) string { - switch c := content.(type) { - case string: - return c - case map[string]any: - if text, ok := c["content"].(string); ok { - return text - } - } - return "" -} - -func commandPartsText(parts []aop.MessagePart) string { - var values []string - for _, part := range parts { - if part.Type == aop.PartText && part.Text != "" { - values = append(values, part.Text) - } - } - return strings.Join(values, "\n") -} - // convergeTaskOnSessionEnd closes a chat task when the ROOT agent session // ends: this terminal event drives task cleanup; child (derived sub-agent) // session ends and mid-run AOP error events are not terminal. Idempotent — diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index ce9128d3..6da788dc 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -235,17 +235,20 @@ func TestWSDispatchAndComplete(t *testing.T) { var cmd WSMessage conn.ReadJSON(&cmd) - if cmd.Type != webproto.TypeCommand { + if cmd.Type != webproto.TypeAOP || cmd.TaskID != "task-1" { t.Fatalf("unexpected: %+v", cmd) } - var command webproto.CommandPayload - if err := json.Unmarshal(cmd.Payload, &command); err != nil { + var callEvent aop.Event + if err := json.Unmarshal(cmd.Payload, &callEvent); err != nil { t.Fatal(err) } - if command.ToolCall == nil || command.SessionID != "task-1" { - t.Fatalf("unexpected command: %+v", command) + if callEvent.Type != aop.TypeToolCall || callEvent.TurnID != "task-1" { + t.Fatalf("unexpected tool.call event: %+v", callEvent) + } + call, err := aop.DecodeData[aop.ToolCallData](callEvent) + if err != nil { + t.Fatal(err) } - call := *command.ToolCall args, _ := call.Args.(map[string]any) if call.ToolName != "bash" || args["command"] != "scan -i 1.2.3.4" { t.Fatalf("unexpected tool.call data: %+v", call) @@ -262,11 +265,14 @@ func TestWSDispatchAndComplete(t *testing.T) { t.Fatal("timeout") } - resultPayload, _ := json.Marshal(webproto.CommandResultPayload{ - Parts: []aop.MessagePart{{Type: aop.PartText, Text: "done"}}, - Metadata: map[string]any{"tool_call_id": "task-1", "tool_name": "bash", "details": map[string]int{"ports": 3}}, + resultData, _ := json.Marshal(aop.ToolResultData{ + ToolCallID: "task-1", ToolName: "bash", Content: "done", Details: map[string]int{"ports": 3}, }) - conn.WriteJSON(WSMessage{Type: webproto.TypeCommandResult, TaskID: "task-1", Payload: resultPayload}) + resultEvent := callEvent + resultEvent.Type = aop.TypeToolResult + resultEvent.TS = time.Now().UTC().Format(time.RFC3339Nano) + resultEvent.Data = resultData + conn.WriteJSON(WSMessage{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: webproto.MustJSON(resultEvent)}) select { case res := <-resultCh: if res.Err != "" || res.Output != "done" { diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go index 160510da..52a85fcd 100644 --- a/pkg/webagent/agent_test.go +++ b/pkg/webagent/agent_test.go @@ -107,12 +107,12 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) { registeredOnce.Do(func() { close(registered) }) call := aop.ToolCallData{ - ToolCallID: "call-1", + ToolCallID: "task-1", ToolName: "bash", Args: map[string]any{"command": `echo "hello world"`}, } - payload, _ := json.Marshal(webproto.CommandPayload{SessionID: "task-1", ToolCall: &call}) - if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeCommand, TaskID: "task-1", Payload: payload}); err != nil { + payload, _ := json.Marshal(toolEvent(t, call)) + if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: payload}); err != nil { t.Errorf("tool.call write: %v", err) return } @@ -122,7 +122,7 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) { return } messages <- msg - if msg.Type == webproto.TypeCommandResult { + if msg.Type == webproto.TypeAOP { return } } @@ -165,8 +165,11 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) { seenOutput = true } } - case webproto.TypeCommandResult: - seenResult = true + case webproto.TypeAOP: + var event aop.Event + if json.Unmarshal(msg.Payload, &event) == nil && event.Type == aop.TypeToolResult { + seenResult = true + } } case <-deadline: t.Fatal("timeout waiting for web agent messages") diff --git a/pkg/webagent/aop_tool.go b/pkg/webagent/aop_tool.go index a54c37b7..75511a33 100644 --- a/pkg/webagent/aop_tool.go +++ b/pkg/webagent/aop_tool.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "strings" "time" @@ -32,48 +33,49 @@ type foregroundTool interface { RunForegroundTool(context.Context, string, commands.BashExecOptions) (tool.Result, error) } -// HandleToolCommand executes one structured direct Command and returns a -// command.result frame. It does not create a Session or Turn. -func HandleToolCommand(ctx context.Context, msg webproto.Message, call aop.ToolCallData, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent], send func(webproto.Message)) { +// HandleToolCallEvent executes one direct AOP tool.call and returns its +// terminal tool.result through the same AOP envelope. +func HandleToolCallEvent(ctx context.Context, msg webproto.Message, event aop.Event, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent], send func(webproto.Message)) { + sendError := func(err error) { + payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) + send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload}) + } + if !event.Valid() { + sendError(fmt.Errorf("invalid inbound AOP event")) + return + } + if event.Type != aop.TypeToolCall { + sendError(fmt.Errorf("unsupported inbound AOP event %q", event.Type)) + return + } + call, err := aop.DecodeData[aop.ToolCallData](event) + if err != nil { + sendError(fmt.Errorf("decode tool.call: %w", err)) + return + } + if msg.TaskID == "" || call.ToolCallID != msg.TaskID { + sendError(fmt.Errorf("tool.call correlation requires task_id == tool_call_id")) + return + } + if strings.TrimSpace(call.ToolName) == "" { + sendError(fmt.Errorf("tool.call tool_name is required")) + return + } if call.WorkDir != "" { ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: call.WorkDir}) } callID := msg.TaskID - if callID == "" { - callID = call.ToolCallID - } ctx = output.ContextWithCallID(ctx, callID) started := time.Now() result, execErr := executeCall(ctx, executor, call, dataBus, callID) - metadata := map[string]any{ - "tool_call_id": call.ToolCallID, - "tool_name": call.ToolName, - "duration_ms": int(time.Since(started).Milliseconds()), - } - var parts []aop.MessagePart - if execErr != nil { - parts = []aop.MessagePart{{Type: aop.PartText, Text: execErr.Error()}} - metadata["is_error"] = true - } else { - if result.Text() != "" { - parts = append(parts, aop.MessagePart{Type: aop.PartText, Text: result.Text()}) - } - if result.HasImages() { - for _, block := range result.Content { - if block.Type == "image" { - parts = append(parts, aop.MessagePart{Type: aop.PartImage, Image: &aop.ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}}) - } - } - } - if result.Details != nil { - metadata["details"] = result.Details - } - metadata["terminate"] = result.Terminate - metadata["is_error"] = result.IsError - } - payload, _ := json.Marshal(webproto.CommandResultPayload{Parts: parts, Metadata: metadata}) - send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: callID, Payload: payload}) + data := aop.ToolResultDataFromResult(call, result, execErr, time.Since(started)) + raw, _ := json.Marshal(data) + event.Type = aop.TypeToolResult + event.TS = time.Now().UTC().Format(time.RFC3339Nano) + event.Data = raw + payload, _ := json.Marshal(event) + send(webproto.Message{Type: webproto.TypeAOP, TaskID: callID, TurnID: event.TurnID, Payload: payload}) } // executeCall runs the tool call. Tools with foreground capability stream diff --git a/pkg/webagent/aop_tool_test.go b/pkg/webagent/aop_tool_test.go index 85212406..dee00af7 100644 --- a/pkg/webagent/aop_tool_test.go +++ b/pkg/webagent/aop_tool_test.go @@ -3,6 +3,7 @@ package webagent import ( "context" "encoding/json" + "strings" "testing" "time" @@ -28,33 +29,62 @@ func toolCommand(toolCallID, toolName string, args map[string]any) aop.ToolCallD } } -func decodeCommandResult(t *testing.T, msg webproto.Message) webproto.CommandResultPayload { +func toolEvent(t *testing.T, call aop.ToolCallData) aop.Event { t.Helper() - if msg.Type != webproto.TypeCommandResult { + data, err := json.Marshal(call) + if err != nil { + t.Fatal(err) + } + return aop.Event{ + Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano), + SessionID: "session-1", TurnID: call.ToolCallID, Agent: "worker", Data: data, + } +} + +func decodeToolResult(t *testing.T, msg webproto.Message) aop.ToolResultData { + t.Helper() + if msg.Type != webproto.TypeAOP { t.Fatalf("result envelope = %+v", msg) } - var result webproto.CommandResultPayload - if err := json.Unmarshal(msg.Payload, &result); err != nil { + var event aop.Event + if err := json.Unmarshal(msg.Payload, &event); err != nil { + t.Fatal(err) + } + if event.Type != aop.TypeToolResult { + t.Fatalf("result event = %+v", event) + } + result, err := aop.DecodeData[aop.ToolResultData](event) + if err != nil { t.Fatal(err) } return result } -func TestHandleToolCommand(t *testing.T) { +func TestHandleToolCallEvent(t *testing.T) { var got webproto.Message - HandleToolCommand(context.Background(), webproto.Message{Type: webproto.TypeCommand, TaskID: "call-1"}, - toolCommand("call-1", "echo", map[string]any{"value": "hello"}), + call := toolCommand("call-1", "echo", map[string]any{"value": "hello"}) + HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "call-1"}, toolEvent(t, call), aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg }) if got.TaskID != "call-1" { t.Fatalf("result envelope = %+v", got) } - result := decodeCommandResult(t, got) - if result.Metadata["tool_call_id"] != "call-1" || result.Metadata["tool_name"] != "echo" || len(result.Parts) != 1 { + result := decodeToolResult(t, got) + if result.ToolCallID != "call-1" || result.ToolName != "echo" || !strings.Contains(aop.ToolResultText(result.Content), "echo") { t.Fatalf("result data = %+v", result) } } +func TestHandleToolCallEventRejectsMismatchedCorrelation(t *testing.T) { + var got webproto.Message + call := toolCommand("call-1", "echo", map[string]any{"value": "hello"}) + HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "other"}, toolEvent(t, call), + aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg }) + if got.Type != webproto.TypeError || got.TaskID != "other" { + t.Fatalf("error envelope = %+v", got) + } +} + type recordingBash struct { command string options commands.BashExecOptions @@ -79,11 +109,11 @@ func (b *recordingBash) RunForegroundTool(_ context.Context, command string, opt return result, nil } -// TestHandleToolCommandForeground verifies that a foreground-capable tool is +// TestHandleToolCallEventForeground verifies that a foreground-capable tool is // run via RunForegroundTool, that output lines stream as tool.data progress // events correlated by the call session id, and that the tool.result carries // the text content plus structured Details. -func TestHandleToolCommandForeground(t *testing.T) { +func TestHandleToolCallEventForeground(t *testing.T) { reg := commands.NewRegistry() bash := &recordingBash{} reg.RegisterTool(bash) @@ -97,8 +127,8 @@ func TestHandleToolCommandForeground(t *testing.T) { }) var got webproto.Message - HandleToolCommand(context.Background(), webproto.Message{Type: webproto.TypeCommand, TaskID: "task-1"}, - toolCommand("call-1", "bash", map[string]any{"command": "echo test", "timeout": 7}), + call := toolCommand("task-1", "bash", map[string]any{"command": "echo test", "timeout": 7}) + HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1"}, toolEvent(t, call), reg, dataBus, func(msg webproto.Message) { got = msg }) if bash.command != "echo test" || bash.options.Timeout != 7*time.Second { @@ -108,11 +138,11 @@ func TestHandleToolCommandForeground(t *testing.T) { t.Fatalf("progress events = %+v", progress) } - result := decodeCommandResult(t, got) - if isError, _ := result.Metadata["is_error"].(bool); isError || len(result.Parts) == 0 || result.Parts[0].Text != "streamed" { + result := decodeToolResult(t, got) + if result.IsError || aop.ToolResultText(result.Content) != "streamed" { t.Fatalf("result data = %+v", result) } - details, _ := json.Marshal(result.Metadata["details"]) + details, _ := json.Marshal(result.Details) var structured output.Result if err := json.Unmarshal(details, &structured); err != nil { t.Fatalf("decode structured details: %v", err) diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go index 4ce55c9e..071d96e1 100644 --- a/pkg/webagent/connection.go +++ b/pkg/webagent/connection.go @@ -301,28 +301,31 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg } case webproto.TypeCommand: - var command webproto.CommandPayload - if json.Unmarshal(msg.Payload, &command) != nil { - continue - } - if command.ToolCall != nil { - taskCtx, cancel := context.WithCancel(connectionCtx) - mu.Lock() - execTasks[msg.TaskID] = cancel - mu.Unlock() - go func(m webproto.Message, call aop.ToolCallData) { - defer cancel() - defer func() { - mu.Lock() - delete(execTasks, m.TaskID) - mu.Unlock() - }() - HandleToolCommand(taskCtx, m, call, cc.Registry, cc.DataBus, send) - }(msg, *command.ToolCall) - } else if cc.Chat != nil { + if cc.Chat != nil { cc.Chat.HandleProtocol(connectionCtx, msg, send) } + case webproto.TypeAOP: + var event aop.Event + if err := json.Unmarshal(msg.Payload, &event); err != nil { + payload, _ := json.Marshal(webproto.ErrorPayload{Message: "decode AOP: " + err.Error()}) + send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload}) + continue + } + taskCtx, cancel := context.WithCancel(connectionCtx) + mu.Lock() + execTasks[msg.TaskID] = cancel + mu.Unlock() + go func(m webproto.Message, event aop.Event) { + defer cancel() + defer func() { + mu.Lock() + delete(execTasks, m.TaskID) + mu.Unlock() + }() + HandleToolCallEvent(taskCtx, m, event, cc.Registry, cc.DataBus, send) + }(msg, event) + case "upload": if cc.Chat != nil { go cc.Chat.HandleUpload(msg, send) diff --git a/pkg/webagent/toolnode_test.go b/pkg/webagent/toolnode_test.go index 7d988167..7a7cb120 100644 --- a/pkg/webagent/toolnode_test.go +++ b/pkg/webagent/toolnode_test.go @@ -29,7 +29,7 @@ type hubScript struct { t *testing.T registered chan webproto.RegisterPayload - toolResult chan webproto.CommandResultPayload + toolResult chan aop.ToolResultData progress chan string fileData chan []byte toolData chan webproto.Message @@ -39,7 +39,7 @@ func newHubScript(t *testing.T) *hubScript { return &hubScript{ t: t, registered: make(chan webproto.RegisterPayload, 1), - toolResult: make(chan webproto.CommandResultPayload, 1), + toolResult: make(chan aop.ToolResultData, 1), progress: make(chan string, 16), fileData: make(chan []byte, 1), toolData: make(chan webproto.Message, 4), @@ -81,10 +81,15 @@ func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { return } switch msg.Type { - case webproto.TypeCommandResult: - var result webproto.CommandResultPayload - if err := json.Unmarshal(msg.Payload, &result); err != nil { - h.t.Errorf("command.result: %v", err) + case webproto.TypeAOP: + var event aop.Event + if err := json.Unmarshal(msg.Payload, &event); err != nil || event.Type != aop.TypeToolResult { + h.t.Errorf("tool.result: event=%+v err=%v", event, err) + return + } + result, err := aop.DecodeData[aop.ToolResultData](event) + if err != nil { + h.t.Errorf("tool.result data: %v", err) return } h.toolResult <- result @@ -113,12 +118,13 @@ func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { // drive issues the server→runner calls once the connection is live. func (h *hubScript) drive(conn *websocket.Conn) { call := aop.ToolCallData{ - ToolCallID: "call-1", + ToolCallID: "exec-1", ToolName: "bash", Args: map[string]any{"command": "echo hello"}, } - payload, _ := json.Marshal(webproto.CommandPayload{SessionID: "exec-1", ToolCall: &call}) - if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeCommand, TaskID: "exec-1", Payload: payload}); err != nil { + event := toolEvent(h.t, call) + payload, _ := json.Marshal(event) + if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "exec-1", TurnID: "exec-1", Payload: payload}); err != nil { return } } @@ -190,9 +196,9 @@ func TestRunToolNodeWireInterop(t *testing.T) { if line != "streamed" { t.Fatalf("progress line = %q", line) } - result := wait(t, hub.toolResult, "command.result") - if isError, _ := result.Metadata["is_error"].(bool); isError || result.Metadata["tool_call_id"] != "call-1" || result.Metadata["tool_name"] != "bash" { - t.Fatalf("command.result = %+v", result) + result := wait(t, hub.toolResult, "tool.result") + if result.IsError || result.ToolCallID != "exec-1" || result.ToolName != "bash" { + t.Fatalf("tool.result = %+v", result) } // tool.data rides the same connection, correlated by call ID. diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go index 1669a00a..2de98f76 100644 --- a/pkg/webproto/message.go +++ b/pkg/webproto/message.go @@ -54,9 +54,8 @@ type RunPayload struct { } type CommandPayload struct { - SessionID string `json:"session_id"` - Line string `json:"line"` - ToolCall *aop.ToolCallData `json:"tool_call,omitempty"` + SessionID string `json:"session_id"` + Line string `json:"line"` } type CommandResultPayload struct { From 79fd75df9d5ec85a5875ab4c8829e94e120fe131 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 13:01:05 +0800 Subject: [PATCH 114/348] refactor(output): remove legacy agent timeline models --- core/output/record.go | 2 - core/output/timeline.go | 312 ++++++++++------------------------- core/output/timeline_test.go | 38 ++++- 3 files changed, 123 insertions(+), 229 deletions(-) diff --git a/core/output/record.go b/core/output/record.go index 93bcb2ab..a75c0bf9 100644 --- a/core/output/record.go +++ b/core/output/record.go @@ -18,7 +18,6 @@ const ( TypeSpray RecordType = "spray" TypeZombie RecordType = "zombie" TypeNeutron RecordType = "neutron" - TypeAgent RecordType = "agent" TypeScanEnd RecordType = "scan_end" TypeError RecordType = "error" @@ -61,7 +60,6 @@ func (r Record) Marshal() []byte { return b } - func ParseRecord(line []byte) (Record, error) { var r Record err := json.Unmarshal(line, &r) diff --git a/core/output/timeline.go b/core/output/timeline.go index 30371dc0..6280956a 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/utils/parsers" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" @@ -52,9 +53,12 @@ func ParseTimelineFile(path string) ([]TimelineEntry, error) { } func parseLine(line []byte) (TimelineEntry, bool) { - var event AOPTimelineEntry + var event aop.Event if json.Unmarshal(line, &event) == nil && event.Valid() { - return TimelineEntry{Timestamp: event.Timestamp, Type: event.Type, Data: &event}, true + timestamp, err := time.Parse(time.RFC3339Nano, event.TS) + if err == nil { + return TimelineEntry{Timestamp: timestamp, Type: event.Type, Data: &event}, true + } } rec, err := ParseRecord(line) if err != nil || rec.Type == "" { @@ -77,8 +81,6 @@ func parseRecordData(rec Record) any { return unmarshalItem[parsers.GOGOResult](rec.Data) case TypeSpray: return unmarshalItem[parsers.SprayResult](rec.Data) - case TypeAgent: - return unmarshalItem[AgentEvent](rec.Data) case TypeScanEnd: return unmarshalItem[ScanEnd](rec.Data) } @@ -122,10 +124,8 @@ func BuildTimelineMarkdown(entries []TimelineEntry) string { writeSprayMarkdown(&sb, d) case *parsers.Loot: writeLootMarkdown(&sb, d) - case *AgentEvent: - d.writeMarkdown(&sb) - case *AOPTimelineEntry: - d.writeMarkdown(&sb) + case *aop.Event: + writeAOPMarkdown(&sb, d) case *ScanEnd: d.writeMarkdown(&sb) } @@ -162,109 +162,6 @@ func writeHeader(sb *strings.Builder, sess *sessionMeta) { } } -// --------------------------------------------------------------------------- -// AgentEvent -// --------------------------------------------------------------------------- - -type AgentEvent struct { - Type string `json:"type"` - SessionID string `json:"session_id"` - ParentSessionID string `json:"parent_session_id"` - Turn int `json:"turn"` - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Arguments string `json:"arguments"` - Result string `json:"result"` - IsError bool `json:"is_error"` - Error string `json:"error"` - Stop string `json:"stop"` - Message *AgentEventMsg `json:"message"` - ToolResults []AgentEventMsg `json:"tool_results"` - Usage *AgentEventUsage `json:"usage"` - ContextTokens int `json:"context_tokens"` - NewMessages int `json:"new_messages"` - RequestModel string `json:"request_model"` - RequestMessages int `json:"request_messages"` - RequestTools int `json:"request_tools"` -} - -type AgentEventMsg struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []agentToolCall `json:"tool_calls"` - ToolCallID string `json:"tool_call_id"` -} - -type AgentEventUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - CacheWriteTokens int `json:"cache_write_tokens"` -} - -type agentToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` -} - -func (ev *AgentEvent) writeMarkdown(sb *strings.Builder) { - switch ev.Type { - case "turn_start": - sb.WriteString(fmt.Sprintf("## Turn %d\n\n", ev.Turn)) - - case "message_end": - if ev.Message == nil { - return - } - switch ev.Message.Role { - case "user": - sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(ev.Message.Content, 200))) - case "assistant": - if len(ev.Message.ToolCalls) > 0 { - return - } - if ev.Message.Content != "" { - sb.WriteString(ev.Message.Content + "\n\n") - } - } - - case "tool_execution_start": - args := summarizeToolArgs(ev.ToolName, ev.Arguments) - if args != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", ev.ToolName, args)) - } else { - sb.WriteString(fmt.Sprintf("- **%s**\n", ev.ToolName)) - } - - case "tool_execution_end": - if ev.IsError || ev.Error != "" { - errMsg := ev.Error - if errMsg == "" { - errMsg = TruncateStr(ev.Result, 120) - } - sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(errMsg, 120))) - } else { - sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(ev.Result, 150))) - } - - case "turn_end": - if ev.Usage != nil && ev.Usage.TotalTokens > 0 { - usage := fmt.Sprintf("*%d tokens", ev.Usage.TotalTokens) - if ev.Usage.CacheReadTokens > 0 && ev.Usage.PromptTokens > 0 { - pct := float64(ev.Usage.CacheReadTokens) / float64(ev.Usage.PromptTokens) * 100 - usage += fmt.Sprintf(", cache %.0f%%", pct) - } - sb.WriteString("\n" + usage + "*\n") - } - sb.WriteString("\n") - } -} - // --------------------------------------------------------------------------- // Scan types // --------------------------------------------------------------------------- @@ -299,56 +196,34 @@ func collectSessionMeta(entries []TimelineEntry) sessionMeta { var m sessionMeta for _, e := range entries { switch d := e.Data.(type) { - case *AgentEvent: + case *aop.Event: if m.id == "" { m.id = d.SessionID - m.parentID = d.ParentSessionID - } - if d.RequestModel != "" && m.model == "" { - m.model = d.RequestModel } switch d.Type { - case "agent_start": + case aop.TypeSessionStart: m.startTS = e.Timestamp - case "agent_end": - m.endTS = e.Timestamp - m.stop = d.Stop - case "turn_start": - m.turns++ - case "turn_end": - if d.Usage != nil { - m.totalTokens = d.Usage.TotalTokens + if data, err := aop.DecodeData[aop.SessionStartData](*d); err == nil { + m.parentID = data.ParentSessionID + if data.Model != "" && m.model == "" { + m.model = data.Model + } } - } - case *AOPTimelineEntry: - switch d.Type { - case "session.start": - m.startTS = e.Timestamp - var sd struct { - Model string `json:"model"` - } - _ = json.Unmarshal(d.Data, &sd) - if sd.Model != "" && m.model == "" { - m.model = sd.Model - } - case "session.end": + case aop.TypeSessionEnd: m.endTS = e.Timestamp - case "turn.start": + case aop.TypeTurnStart: m.turns++ - case "turn.end": + case aop.TypeTurnEnd: m.endTS = e.Timestamp - var td struct { - Stop string `json:"stop"` - } - _ = json.Unmarshal(d.Data, &td) - m.stop = td.Stop - case "usage": - var ud struct { - TotalTokens int `json:"total_tokens"` + if data, err := aop.DecodeData[aop.TurnEndData](*d); err == nil { + m.stop = data.Stop + if data.Usage != nil && data.Usage.TotalTokens > 0 { + m.totalTokens = data.Usage.TotalTokens + } } - _ = json.Unmarshal(d.Data, &ud) - if ud.TotalTokens > 0 { - m.totalTokens = ud.TotalTokens + case aop.TypeUsage: + if data, err := aop.DecodeData[aop.UsageData](*d); err == nil && data.TotalTokens > 0 { + m.totalTokens = data.TotalTokens } } } @@ -476,110 +351,97 @@ func compactResult(result string, maxLen int) string { return TruncateStr(first, maxLen-20) + fmt.Sprintf(" (+%d lines)", len(lines)-1) } -// --------------------------------------------------------------------------- -// AOP event support -// --------------------------------------------------------------------------- - -type AOPTimelineEntry struct { - Type string `json:"type"` - Timestamp time.Time `json:"ts"` - SessionID string `json:"session_id"` - TurnID string `json:"turn_id,omitempty"` - Agent string `json:"agent"` - Data json.RawMessage `json:"data"` -} - -func (e AOPTimelineEntry) Valid() bool { - return e.Type != "" && !e.Timestamp.IsZero() && e.SessionID != "" && e.Agent != "" && len(e.Data) > 0 -} - -func (e *AOPTimelineEntry) writeMarkdown(sb *strings.Builder) { - switch e.Type { - case "turn.start": - sb.WriteString(fmt.Sprintf("## Run %s\n\n", e.TurnID)) +func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { + if event == nil { + return + } + switch event.Type { + case aop.TypeTurnStart: + sb.WriteString(fmt.Sprintf("## Run %s\n\n", event.TurnID)) - case "text": - var d struct { - Content string `json:"content"` - Role string `json:"role"` - Delta bool `json:"delta"` + case aop.TypeMessage: + data, err := aop.DecodeData[aop.MessageData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) - if d.Delta || d.Content == "" { + var textParts []string + for _, part := range data.Parts { + if part.Type == aop.PartText && part.Text != "" { + textParts = append(textParts, part.Text) + } + } + text := strings.Join(textParts, "\n") + if text == "" { return } - if d.Role == "user" { - sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(d.Content, 200))) + if data.Role == "user" { + sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(text, 200))) } else { - sb.WriteString(d.Content + "\n\n") + sb.WriteString(text + "\n\n") } - case "tool.call": - var d struct { - ToolName string `json:"tool_name"` - Args any `json:"args"` + case aop.TypeToolCall: + data, err := aop.DecodeData[aop.ToolCallData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) argsStr := "" - switch a := d.Args.(type) { + switch args := data.Args.(type) { case string: - argsStr = a + argsStr = args case map[string]any: - raw, _ := json.Marshal(a) + raw, _ := json.Marshal(args) argsStr = string(raw) } - args := summarizeToolArgs(d.ToolName, argsStr) - if args != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", d.ToolName, args)) + summary := summarizeToolArgs(data.ToolName, argsStr) + if summary != "" { + sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", data.ToolName, summary)) } else { - sb.WriteString(fmt.Sprintf("- **%s**\n", d.ToolName)) + sb.WriteString(fmt.Sprintf("- **%s**\n", data.ToolName)) } - case "tool.result": - var d struct { - ToolName string `json:"tool_name"` - Content any `json:"content"` - IsError bool `json:"is_error"` - } - _ = json.Unmarshal(e.Data, &d) - result := "" - if s, ok := d.Content.(string); ok { - result = s + case aop.TypeToolResult: + data, err := aop.DecodeData[aop.ToolResultData](*event) + if err != nil { + return } - if d.IsError { + result := aop.ToolResultText(data.Content) + if data.IsError { sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(result, 120))) } else { sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(result, 150))) } - case "usage": - var d struct { - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - InputTokens int `json:"input_tokens"` + case aop.TypeUsage: + data, err := aop.DecodeData[aop.UsageData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) - if d.TotalTokens > 0 { - usage := fmt.Sprintf("*%d tokens", d.TotalTokens) - if d.CacheReadTokens > 0 && d.InputTokens > 0 { - pct := float64(d.CacheReadTokens) / float64(d.InputTokens) * 100 + if data.TotalTokens > 0 { + usage := fmt.Sprintf("*%d tokens", data.TotalTokens) + if data.CacheReadTokens > 0 && data.InputTokens > 0 { + pct := float64(data.CacheReadTokens) / float64(data.InputTokens) * 100 usage += fmt.Sprintf(", cache %.0f%%", pct) } sb.WriteString("\n" + usage + "*\n\n") } - case "turn.end": - var d struct { - Stop string `json:"stop"` + case aop.TypeError: + data, err := aop.DecodeData[aop.ErrorData](*event) + if err == nil && data.Message != "" { + sb.WriteString(fmt.Sprintf("\n> **error:** %s\n\n", data.Message)) + } + + case aop.TypeTurnEnd: + data, err := aop.DecodeData[aop.TurnEndData](*event) + if err == nil { + sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", data.Stop)) } - _ = json.Unmarshal(e.Data, &d) - sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", d.Stop)) - case "session.end": - var d struct { - Reason string `json:"reason"` + case aop.TypeSessionEnd: + data, err := aop.DecodeData[aop.SessionEndData](*event) + if err == nil { + sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", data.Reason)) } - _ = json.Unmarshal(e.Data, &d) - sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", d.Reason)) } } diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index b92aec35..342643bd 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -1,18 +1,22 @@ package output import ( + "encoding/json" "strings" "testing" + "time" + + "github.com/chainreactors/aiscan/pkg/aop" ) func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { - raw := []byte(`{"type":"text","ts":"2026-07-20T00:00:00Z","session_id":"session-1","agent":"aiscan","data":{"content":"hello","role":"assistant"}}`) + raw := []byte(`{"type":"message","ts":"2026-07-20T00:00:00Z","session_id":"session-1","agent":"aiscan","data":{"message_id":"m-1","role":"assistant","parts":[{"type":"text","text":"hello"}]}}`) entry, ok := parseLine(raw) if !ok { t.Fatal("native AOP envelope was not parsed") } - if _, ok := entry.Data.(*AOPTimelineEntry); !ok { + if _, ok := entry.Data.(*aop.Event); !ok { t.Fatalf("entry data type = %T", entry.Data) } if markdown := BuildTimelineMarkdown([]TimelineEntry{entry}); !strings.Contains(markdown, "hello") { @@ -20,6 +24,36 @@ func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { } } +func TestTimelineRendersStructuredToolResult(t *testing.T) { + data, _ := json.Marshal(aop.ToolResultData{ + ToolCallID: "call-1", ToolName: "scan", + Content: aop.ToolResultContent{Content: "three ports", Images: []aop.ImageSource{{MediaType: "image/png", Base64: "eA=="}}}, + }) + event := aop.Event{ + Type: aop.TypeToolResult, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", TurnID: "turn-1", Agent: "aiscan", Data: data, + } + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + if !strings.Contains(markdown, "three ports") { + t.Fatalf("timeline markdown = %q", markdown) + } +} + +func mustTimelineTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + t.Fatal(err) + } + return parsed +} + +func TestParseLineRejectsLegacyAgentRecord(t *testing.T) { + record := NewRecord(RecordType("agent"), map[string]any{"type": "message_end"}) + if _, ok := parseLine(record.Marshal()); ok { + t.Fatal("legacy agent record should not be accepted") + } +} + func TestParseLineRejectsLegacyAOPRecordPrefix(t *testing.T) { record := NewRecord(RecordType("aop.text"), map[string]any{"content": "legacy"}) if _, ok := parseLine(record.Marshal()); ok { From 01590a4ec9005762201d0ece635468ebdfc8d5f8 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 13:06:23 +0800 Subject: [PATCH 115/348] refactor: promote tools to top-level packages --- cmd/agent/imports.go | 2 +- cmd/aiscan/imports.go | 20 ++-- cmd/aiscan/imports_full.go | 6 +- cmd/aiscan/setup.go | 4 +- cmd/runner/imports.go | 14 +-- cmd/runner/main.go | 2 +- core/config/scanner_katana.go | 2 +- core/runner/runner.go | 2 +- docs/development.md | 20 ++-- pkg/agent/probe/conn.go | 2 +- pkg/web/service.go | 2 +- tools/README.md | 102 ++++++++++++++++++ {pkg/tools => tools}/arsenal/arsenal_tool.go | 0 .../arsenal/arsenal_tool_test.go | 0 {pkg/tools => tools}/arsenal/register.go | 0 .../functional_integration_full_test.go | 4 +- .../functional_integration_test.go | 8 +- .../tools => tools}/functional_norace_test.go | 0 {pkg/tools => tools}/functional_race_test.go | 0 .../functional_regression_full_test.go | 6 +- .../functional_regression_test.go | 12 +-- .../functional_testkit_test.go | 0 {pkg/tools => tools}/gogo/gogo.go | 2 +- {pkg/tools => tools}/gogo/gogo_test.go | 0 {pkg/tools => tools}/gogo/register.go | 2 +- {pkg/tools => tools}/ioa/commands.go | 0 {pkg/tools => tools}/ioa/commands_test.go | 2 +- {pkg/tools => tools}/ioa/register.go | 0 {pkg/tools => tools}/katana/katana.go | 2 +- {pkg/tools => tools}/katana/register.go | 0 {pkg/tools => tools}/neutron/neutron.go | 4 +- {pkg/tools => tools}/neutron/neutron_test.go | 0 {pkg/tools => tools}/neutron/register.go | 2 +- {pkg/tools => tools}/neutron/sdk_stage.go | 2 +- {pkg/tools => tools}/passive/passive.go | 2 +- {pkg/tools => tools}/passive/passive_test.go | 2 +- {pkg/tools => tools}/passive/register.go | 2 +- {pkg/tools => tools}/playwright/advanced.go | 0 {pkg/tools => tools}/playwright/autofill.go | 0 {pkg/tools => tools}/playwright/browser.go | 0 .../playwright/browser_test.go | 0 {pkg/tools => tools}/playwright/dialog.go | 0 {pkg/tools => tools}/playwright/discover.go | 0 {pkg/tools => tools}/playwright/headless.go | 0 {pkg/tools => tools}/playwright/interact.go | 0 {pkg/tools => tools}/playwright/navigation.go | 0 {pkg/tools => tools}/playwright/recorder.go | 0 .../playwright/recorder_test.go | 0 {pkg/tools => tools}/playwright/register.go | 0 {pkg/tools => tools}/playwright/session.go | 0 {pkg/tools => tools}/playwright/storage.go | 0 .../playwright/storage_test.go | 0 {pkg/tools => tools}/playwright/tabs.go | 0 .../playwright/testharness/.gitignore | 0 .../playwright/testharness/conftest.py | 0 .../testharness/fixtures/dynamic.html | 0 .../testharness/fixtures/forms.html | 0 .../fixtures/headless-extract.yaml | 0 .../fixtures/headless-template.yaml | 0 .../testharness/fixtures/login.html | 0 .../testharness/fixtures/navigation.html | 0 .../testharness/fixtures/page2.html | 0 .../playwright/testharness/pw_driver.go | 4 +- .../playwright/testharness/test_cli_parity.py | 0 .../playwright/testharness/test_dispatch.py | 0 .../playwright/testharness/test_extraction.py | 0 .../playwright/testharness/test_files.py | 0 .../playwright/testharness/test_focus.py | 0 .../playwright/testharness/test_headers.py | 0 .../testharness/test_headless_template.py | 0 .../testharness/test_interaction.py | 0 .../playwright/testharness/test_navigation.py | 0 .../playwright/testharness/test_route.py | 0 .../playwright/testharness/test_viewport.py | 0 .../playwright/testharness/test_wait.py | 0 {pkg/tools => tools}/proton/command.go | 2 +- {pkg/tools => tools}/proton/command_test.go | 2 +- {pkg/tools => tools}/proton/register.go | 0 {pkg/tools => tools}/proton/register_test.go | 0 {pkg/tools => tools}/proxy/command.go | 0 {pkg/tools => tools}/proxy/command_test.go | 0 {pkg/tools => tools}/proxy/mitm.go | 0 {pkg/tools => tools}/proxy/mitm_test.go | 0 .../tools => tools}/proxy/race_norace_test.go | 0 {pkg/tools => tools}/proxy/race_test.go | 0 .../tools => tools}/proxy/register_command.go | 0 {pkg/tools => tools}/proxy/state.go | 0 {pkg/tools => tools}/proxy/state_test.go | 0 {pkg/tools => tools}/register_command.go | 4 +- .../register_command_full_test.go | 2 +- .../register_command_integration_test.go | 6 +- {pkg/tools => tools}/register_command_test.go | 12 +-- {pkg/tools => tools}/scan/adapter.go | 4 +- {pkg/tools => tools}/scan/aggregate.go | 0 {pkg/tools => tools}/scan/aggregate_test.go | 0 {pkg/tools => tools}/scan/bridge.go | 2 +- {pkg/tools => tools}/scan/capability.go | 4 +- .../tools => tools}/scan/capability_katana.go | 26 ++--- .../scan/capability_katana_stub.go | 0 .../scan/capability_katana_test.go | 0 {pkg/tools => tools}/scan/collector.go | 2 +- {pkg/tools => tools}/scan/command.go | 6 +- {pkg/tools => tools}/scan/command_test.go | 4 +- {pkg/tools => tools}/scan/data_bus_test.go | 2 +- {pkg/tools => tools}/scan/engine/gogo.go | 0 {pkg/tools => tools}/scan/engine/gogo_test.go | 0 {pkg/tools => tools}/scan/engine/neutron.go | 0 .../scan/engine/race_norace_test.go | 0 {pkg/tools => tools}/scan/engine/race_test.go | 0 {pkg/tools => tools}/scan/engine/set.go | 0 {pkg/tools => tools}/scan/engine/set_test.go | 0 .../scan/engine/set_uncover_recon.go | 0 .../scan/engine/set_uncover_stub.go | 0 {pkg/tools => tools}/scan/engine/spray.go | 0 .../tools => tools}/scan/engine/spray_test.go | 0 {pkg/tools => tools}/scan/engine/uncover.go | 0 .../scan/engine/uncover_agents.go | 0 .../scan/engine/uncover_stub.go | 0 .../scan/engine/uncover_test.go | 0 {pkg/tools => tools}/scan/engine/zombie.go | 0 {pkg/tools => tools}/scan/event.go | 0 {pkg/tools => tools}/scan/http_auth.go | 0 {pkg/tools => tools}/scan/input.go | 0 {pkg/tools => tools}/scan/intent.go | 0 {pkg/tools => tools}/scan/jsonl_writer.go | 2 +- {pkg/tools => tools}/scan/options.go | 0 {pkg/tools => tools}/scan/output.go | 0 .../tools => tools}/scan/pipeline/pipeline.go | 0 .../scan/pipeline/pipeline_test.go | 0 {pkg/tools => tools}/scan/report.go | 0 {pkg/tools => tools}/scan/report_json.go | 0 {pkg/tools => tools}/scan/report_plain.go | 0 {pkg/tools => tools}/scan/scan_options.go | 0 {pkg/tools => tools}/scan/sco.go | 0 {pkg/tools => tools}/scan/sco_stub.go | 0 {pkg/tools => tools}/scan/sco_test.go | 2 +- {pkg/tools => tools}/scan/structured.go | 0 {pkg/tools => tools}/scan/target.go | 0 {pkg/tools => tools}/scan/verify.go | 0 {pkg/tools => tools}/search/cyberhub.go | 0 {pkg/tools => tools}/search/cyberhub_test.go | 0 {pkg/tools => tools}/search/fetch.go | 0 {pkg/tools => tools}/search/fetch_test.go | 0 {pkg/tools => tools}/search/register.go | 2 +- {pkg/tools => tools}/search/tavily.go | 0 {pkg/tools => tools}/search/tavily_test.go | 0 {pkg/tools => tools}/search/websearch.go | 0 {pkg/tools => tools}/search/websearch_tool.go | 0 {pkg/tools => tools}/spray/register.go | 2 +- {pkg/tools => tools}/spray/spray.go | 2 +- {pkg/tools => tools}/spray/spray_test.go | 0 {pkg/tools => tools}/toolargs/base.go | 0 {pkg/tools => tools}/toolargs/flags.go | 0 {pkg/tools => tools}/toolargs/help.go | 0 {pkg/tools => tools}/toolargs/normalize.go | 0 {pkg/tools => tools}/toolargs/resolve.go | 0 {pkg/tools => tools}/zombie/register.go | 2 +- {pkg/tools => tools}/zombie/zombie.go | 2 +- {pkg/tools => tools}/zombie/zombie_test.go | 0 159 files changed, 215 insertions(+), 111 deletions(-) create mode 100644 tools/README.md rename {pkg/tools => tools}/arsenal/arsenal_tool.go (100%) rename {pkg/tools => tools}/arsenal/arsenal_tool_test.go (100%) rename {pkg/tools => tools}/arsenal/register.go (100%) rename {pkg/tools => tools}/functional_integration_full_test.go (92%) rename {pkg/tools => tools}/functional_integration_test.go (94%) rename {pkg/tools => tools}/functional_norace_test.go (100%) rename {pkg/tools => tools}/functional_race_test.go (100%) rename {pkg/tools => tools}/functional_regression_full_test.go (94%) rename {pkg/tools => tools}/functional_regression_test.go (97%) rename {pkg/tools => tools}/functional_testkit_test.go (100%) rename {pkg/tools => tools}/gogo/gogo.go (98%) rename {pkg/tools => tools}/gogo/gogo_test.go (100%) rename {pkg/tools => tools}/gogo/register.go (93%) rename {pkg/tools => tools}/ioa/commands.go (100%) rename {pkg/tools => tools}/ioa/commands_test.go (99%) rename {pkg/tools => tools}/ioa/register.go (100%) rename {pkg/tools => tools}/katana/katana.go (99%) rename {pkg/tools => tools}/katana/register.go (100%) rename {pkg/tools => tools}/neutron/neutron.go (99%) rename {pkg/tools => tools}/neutron/neutron_test.go (100%) rename {pkg/tools => tools}/neutron/register.go (93%) rename {pkg/tools => tools}/neutron/sdk_stage.go (99%) rename {pkg/tools => tools}/passive/passive.go (99%) rename {pkg/tools => tools}/passive/passive_test.go (98%) rename {pkg/tools => tools}/passive/register.go (94%) rename {pkg/tools => tools}/playwright/advanced.go (100%) rename {pkg/tools => tools}/playwright/autofill.go (100%) rename {pkg/tools => tools}/playwright/browser.go (100%) rename {pkg/tools => tools}/playwright/browser_test.go (100%) rename {pkg/tools => tools}/playwright/dialog.go (100%) rename {pkg/tools => tools}/playwright/discover.go (100%) rename {pkg/tools => tools}/playwright/headless.go (100%) rename {pkg/tools => tools}/playwright/interact.go (100%) rename {pkg/tools => tools}/playwright/navigation.go (100%) rename {pkg/tools => tools}/playwright/recorder.go (100%) rename {pkg/tools => tools}/playwright/recorder_test.go (100%) rename {pkg/tools => tools}/playwright/register.go (100%) rename {pkg/tools => tools}/playwright/session.go (100%) rename {pkg/tools => tools}/playwright/storage.go (100%) rename {pkg/tools => tools}/playwright/storage_test.go (100%) rename {pkg/tools => tools}/playwright/tabs.go (100%) rename {pkg/tools => tools}/playwright/testharness/.gitignore (100%) rename {pkg/tools => tools}/playwright/testharness/conftest.py (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/dynamic.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/forms.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/headless-extract.yaml (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/headless-template.yaml (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/login.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/navigation.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/page2.html (100%) rename {pkg/tools => tools}/playwright/testharness/pw_driver.go (91%) rename {pkg/tools => tools}/playwright/testharness/test_cli_parity.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_dispatch.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_extraction.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_files.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_focus.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_headers.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_headless_template.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_interaction.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_navigation.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_route.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_viewport.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_wait.py (100%) rename {pkg/tools => tools}/proton/command.go (99%) rename {pkg/tools => tools}/proton/command_test.go (99%) rename {pkg/tools => tools}/proton/register.go (100%) rename {pkg/tools => tools}/proton/register_test.go (100%) rename {pkg/tools => tools}/proxy/command.go (100%) rename {pkg/tools => tools}/proxy/command_test.go (100%) rename {pkg/tools => tools}/proxy/mitm.go (100%) rename {pkg/tools => tools}/proxy/mitm_test.go (100%) rename {pkg/tools => tools}/proxy/race_norace_test.go (100%) rename {pkg/tools => tools}/proxy/race_test.go (100%) rename {pkg/tools => tools}/proxy/register_command.go (100%) rename {pkg/tools => tools}/proxy/state.go (100%) rename {pkg/tools => tools}/proxy/state_test.go (100%) rename {pkg/tools => tools}/register_command.go (89%) rename {pkg/tools => tools}/register_command_full_test.go (93%) rename {pkg/tools => tools}/register_command_integration_test.go (93%) rename {pkg/tools => tools}/register_command_test.go (95%) rename {pkg/tools => tools}/scan/adapter.go (99%) rename {pkg/tools => tools}/scan/aggregate.go (100%) rename {pkg/tools => tools}/scan/aggregate_test.go (100%) rename {pkg/tools => tools}/scan/bridge.go (96%) rename {pkg/tools => tools}/scan/capability.go (98%) rename {pkg/tools => tools}/scan/capability_katana.go (87%) rename {pkg/tools => tools}/scan/capability_katana_stub.go (100%) rename {pkg/tools => tools}/scan/capability_katana_test.go (100%) rename {pkg/tools => tools}/scan/collector.go (99%) rename {pkg/tools => tools}/scan/command.go (98%) rename {pkg/tools => tools}/scan/command_test.go (99%) rename {pkg/tools => tools}/scan/data_bus_test.go (95%) rename {pkg/tools => tools}/scan/engine/gogo.go (100%) rename {pkg/tools => tools}/scan/engine/gogo_test.go (100%) rename {pkg/tools => tools}/scan/engine/neutron.go (100%) rename {pkg/tools => tools}/scan/engine/race_norace_test.go (100%) rename {pkg/tools => tools}/scan/engine/race_test.go (100%) rename {pkg/tools => tools}/scan/engine/set.go (100%) rename {pkg/tools => tools}/scan/engine/set_test.go (100%) rename {pkg/tools => tools}/scan/engine/set_uncover_recon.go (100%) rename {pkg/tools => tools}/scan/engine/set_uncover_stub.go (100%) rename {pkg/tools => tools}/scan/engine/spray.go (100%) rename {pkg/tools => tools}/scan/engine/spray_test.go (100%) rename {pkg/tools => tools}/scan/engine/uncover.go (100%) rename {pkg/tools => tools}/scan/engine/uncover_agents.go (100%) rename {pkg/tools => tools}/scan/engine/uncover_stub.go (100%) rename {pkg/tools => tools}/scan/engine/uncover_test.go (100%) rename {pkg/tools => tools}/scan/engine/zombie.go (100%) rename {pkg/tools => tools}/scan/event.go (100%) rename {pkg/tools => tools}/scan/http_auth.go (100%) rename {pkg/tools => tools}/scan/input.go (100%) rename {pkg/tools => tools}/scan/intent.go (100%) rename {pkg/tools => tools}/scan/jsonl_writer.go (97%) rename {pkg/tools => tools}/scan/options.go (100%) rename {pkg/tools => tools}/scan/output.go (100%) rename {pkg/tools => tools}/scan/pipeline/pipeline.go (100%) rename {pkg/tools => tools}/scan/pipeline/pipeline_test.go (100%) rename {pkg/tools => tools}/scan/report.go (100%) rename {pkg/tools => tools}/scan/report_json.go (100%) rename {pkg/tools => tools}/scan/report_plain.go (100%) rename {pkg/tools => tools}/scan/scan_options.go (100%) rename {pkg/tools => tools}/scan/sco.go (100%) rename {pkg/tools => tools}/scan/sco_stub.go (100%) rename {pkg/tools => tools}/scan/sco_test.go (97%) rename {pkg/tools => tools}/scan/structured.go (100%) rename {pkg/tools => tools}/scan/target.go (100%) rename {pkg/tools => tools}/scan/verify.go (100%) rename {pkg/tools => tools}/search/cyberhub.go (100%) rename {pkg/tools => tools}/search/cyberhub_test.go (100%) rename {pkg/tools => tools}/search/fetch.go (100%) rename {pkg/tools => tools}/search/fetch_test.go (100%) rename {pkg/tools => tools}/search/register.go (95%) rename {pkg/tools => tools}/search/tavily.go (100%) rename {pkg/tools => tools}/search/tavily_test.go (100%) rename {pkg/tools => tools}/search/websearch.go (100%) rename {pkg/tools => tools}/search/websearch_tool.go (100%) rename {pkg/tools => tools}/spray/register.go (93%) rename {pkg/tools => tools}/spray/spray.go (98%) rename {pkg/tools => tools}/spray/spray_test.go (100%) rename {pkg/tools => tools}/toolargs/base.go (100%) rename {pkg/tools => tools}/toolargs/flags.go (100%) rename {pkg/tools => tools}/toolargs/help.go (100%) rename {pkg/tools => tools}/toolargs/normalize.go (100%) rename {pkg/tools => tools}/toolargs/resolve.go (100%) rename {pkg/tools => tools}/zombie/register.go (92%) rename {pkg/tools => tools}/zombie/zombie.go (97%) rename {pkg/tools => tools}/zombie/zombie_test.go (100%) diff --git a/cmd/agent/imports.go b/cmd/agent/imports.go index 3cd79600..5cb8d167 100644 --- a/cmd/agent/imports.go +++ b/cmd/agent/imports.go @@ -1,3 +1,3 @@ package main -import _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" +import _ "github.com/chainreactors/aiscan/tools/arsenal" diff --git a/cmd/aiscan/imports.go b/cmd/aiscan/imports.go index ac30b23f..73cd56db 100644 --- a/cmd/aiscan/imports.go +++ b/cmd/aiscan/imports.go @@ -4,14 +4,14 @@ package main // Each package has a register.go that calls command.RegisterFactory(). import ( - _ "github.com/chainreactors/aiscan/pkg/tools" - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/ioa" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - _ "github.com/chainreactors/aiscan/pkg/tools/proxy" - _ "github.com/chainreactors/aiscan/pkg/tools/search" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools" + _ "github.com/chainreactors/aiscan/tools/arsenal" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/ioa" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + _ "github.com/chainreactors/aiscan/tools/proxy" + _ "github.com/chainreactors/aiscan/tools/search" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" ) diff --git a/cmd/aiscan/imports_full.go b/cmd/aiscan/imports_full.go index 3bd0b7b8..cdf24a34 100644 --- a/cmd/aiscan/imports_full.go +++ b/cmd/aiscan/imports_full.go @@ -3,7 +3,7 @@ package main import ( - _ "github.com/chainreactors/aiscan/pkg/tools/katana" - _ "github.com/chainreactors/aiscan/pkg/tools/passive" - _ "github.com/chainreactors/aiscan/pkg/tools/playwright" + _ "github.com/chainreactors/aiscan/tools/katana" + _ "github.com/chainreactors/aiscan/tools/passive" + _ "github.com/chainreactors/aiscan/tools/playwright" ) diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 7bb65080..87f16108 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -17,10 +17,10 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" + "github.com/chainreactors/aiscan/tools/scan" + "github.com/chainreactors/aiscan/tools/scan/engine" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/ioa/protocols" ioaserver "github.com/chainreactors/ioa/server" diff --git a/cmd/runner/imports.go b/cmd/runner/imports.go index cba2dca8..6a000c8d 100644 --- a/cmd/runner/imports.go +++ b/cmd/runner/imports.go @@ -1,11 +1,11 @@ package main import ( - _ "github.com/chainreactors/aiscan/pkg/tools" - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools" + _ "github.com/chainreactors/aiscan/tools/arsenal" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" ) diff --git a/cmd/runner/main.go b/cmd/runner/main.go index 48dd2941..0a5d4293 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -15,8 +15,8 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" "github.com/chainreactors/aiscan/pkg/webagent" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func main() { diff --git a/core/config/scanner_katana.go b/core/config/scanner_katana.go index ee35992d..8870151a 100644 --- a/core/config/scanner_katana.go +++ b/core/config/scanner_katana.go @@ -2,7 +2,7 @@ package config -import katanacmd "github.com/chainreactors/aiscan/pkg/tools/katana" +import katanacmd "github.com/chainreactors/aiscan/tools/katana" func init() { ExtraCommands["katana"] = true diff --git a/core/runner/runner.go b/core/runner/runner.go index 5d1dda03..a9ac8c3c 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -18,9 +18,9 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" cmdpkg "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" + "github.com/chainreactors/aiscan/tools/toolargs" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/ioa/protocols" ) diff --git a/docs/development.md b/docs/development.md index 49b61d5e..2ed17424 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,7 @@ # Aiscan 扩展开发手册 +> 新增结构化原生工具时,优先阅读 [`tools/README.md`](../tools/README.md)。最小实现只需要 `core/tool.Tool` 的四个方法和一次 `RegisterTool`;本页主要说明需要 Bash、扫描引擎或其他 Runtime 依赖的 Pseudo-Command/Factory 路径。 + Aiscan 提供两种对 AI 零侵入的扩展机制,开发者无需修改 agent 核心代码即可为 AI 增加新能力: | 扩展方式 | 实现方式 | 侵入程度 | 适用场景 | @@ -155,11 +157,11 @@ type Deps struct { ```go // cmd/aiscan/imports.go import ( - _ "github.com/chainreactors/aiscan/pkg/tools" // scanner 组 - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" // arsenal 组 - _ "github.com/chainreactors/aiscan/pkg/tools/ioa" // ioa 组 - _ "github.com/chainreactors/aiscan/pkg/tools/proxy" // proxy 组 - _ "github.com/chainreactors/aiscan/pkg/tools/search" // search 组 + _ "github.com/chainreactors/aiscan/tools" // scanner 组 + _ "github.com/chainreactors/aiscan/tools/arsenal" // arsenal 组 + _ "github.com/chainreactors/aiscan/tools/ioa" // ioa 组 + _ "github.com/chainreactors/aiscan/tools/proxy" // proxy 组 + _ "github.com/chainreactors/aiscan/tools/search" // search 组 ) ``` @@ -170,12 +172,12 @@ import ( **步骤 1:创建包目录** ``` -pkg/tools/whatweb/ +tools/whatweb/ ├── whatweb.go # 命令实现 └── register.go # 工厂注册 ``` -**步骤 2:实现 Command 接口** — `pkg/tools/whatweb/whatweb.go` +**步骤 2:实现 Command 接口** — `tools/whatweb/whatweb.go` ```go package whatweb @@ -259,7 +261,7 @@ func (c *Command) Execute(ctx context.Context, args []string) error { } ``` -**步骤 3:注册工厂** — `pkg/tools/whatweb/register.go` +**步骤 3:注册工厂** — `tools/whatweb/register.go` ```go package whatweb @@ -289,7 +291,7 @@ func init() { ```go import ( // ...existing imports... - _ "github.com/chainreactors/aiscan/pkg/tools/whatweb" + _ "github.com/chainreactors/aiscan/tools/whatweb" ) ``` diff --git a/pkg/agent/probe/conn.go b/pkg/agent/probe/conn.go index 9f17a76b..e941f34c 100644 --- a/pkg/agent/probe/conn.go +++ b/pkg/agent/probe/conn.go @@ -19,7 +19,7 @@ import ( ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/sdk/pkg/cyberhub" - "github.com/chainreactors/aiscan/pkg/tools/search" + "github.com/chainreactors/aiscan/tools/search" ) // ConnCheck is the outcome of probing one external dependency. A single diff --git a/pkg/web/service.go b/pkg/web/service.go index ef1fb211..368e25fc 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -22,9 +22,9 @@ import ( xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/aiscan/pkg/commands" - scantool "github.com/chainreactors/aiscan/pkg/tools/scan" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" + scantool "github.com/chainreactors/aiscan/tools/scan" ) // hubCommands are the 3 commands that run on the web hub, not the agent. diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..af871aba --- /dev/null +++ b/tools/README.md @@ -0,0 +1,102 @@ +# Extending aiscan with a tool + +Use a native tool when the model needs one structured capability. A tool only +implements `core/tool.Tool`; it does not need a plugin manager, lifecycle +container, global hook, or factory. + +## Minimal implementation + +Create `tools/echo/echo.go`: + +```go +package echo + +import ( + "context" + + "github.com/chainreactors/aiscan/core/tool" + "github.com/chainreactors/aiscan/pkg/commands" +) + +type Args struct { + Text string `json:"text" jsonschema:"description=Text to return"` +} + +type Tool struct{} + +func (Tool) Name() string { return "echo" } +func (Tool) Description() string { return "Return text unchanged." } +func (Tool) Definition() tool.Definition { + return tool.Def("echo", "Return text unchanged.", Args{}) +} + +func (Tool) Execute(ctx context.Context, arguments string) (tool.Result, error) { + args, err := tool.ParseArgs[Args](arguments) + if err != nil { + return tool.Result{}, err + } + if err := ctx.Err(); err != nil { + return tool.Result{}, err + } + return tool.TextResult(args.Text), nil +} + +func Register(reg *commands.CommandRegistry) { + reg.RegisterTool(Tool{}) +} +``` + +Call `echo.Register(reg)` from the application composition point that should +expose the tool. Keep registration explicit when the tool has no Runtime +dependencies. + +## Minimal test + +```go +package echo + +import ( + "context" + "testing" + + "github.com/chainreactors/aiscan/pkg/commands" +) + +func TestEcho(t *testing.T) { + reg := commands.NewRegistry() + Register(reg) + + result, err := reg.ExecuteTool(context.Background(), "echo", `{"text":"hello"}`) + if err != nil { + t.Fatal(err) + } + if result.Text() != "hello" { + t.Fatalf("result = %q", result.Text()) + } +} +``` + +This test proves schema registration, argument decoding, dispatch, and result +conversion without starting an Agent or transport. + +## Result rules + +- Use `tool.TextResult` for normal text. +- Use `tool.ErrorResult` for a tool-level failure the model should observe. +- Return a Go `error` when execution itself failed. +- Add `tool.ImageBlock` only when the result contains an image. +- Put machine-readable domain output in `Result.Details`; do not encode it into + an extra transport payload. +- Honor `ctx` for cancellation and deadlines. + +## When a factory is justified + +Use `commands.RegisterFactory` only when construction needs shared Runtime +dependencies such as the scanner engine set, IOA client, provider, data bus, or +working directory, or when an `init` registration must be activated by several +binaries. The factory should only construct the tool and call `RegisterTool`. + +Do not add a new abstraction until at least two tools need the same behavior. + +Pseudo-commands exposed through the `bash` tool are documented separately in +[`docs/development.md`](../docs/development.md). diff --git a/pkg/tools/arsenal/arsenal_tool.go b/tools/arsenal/arsenal_tool.go similarity index 100% rename from pkg/tools/arsenal/arsenal_tool.go rename to tools/arsenal/arsenal_tool.go diff --git a/pkg/tools/arsenal/arsenal_tool_test.go b/tools/arsenal/arsenal_tool_test.go similarity index 100% rename from pkg/tools/arsenal/arsenal_tool_test.go rename to tools/arsenal/arsenal_tool_test.go diff --git a/pkg/tools/arsenal/register.go b/tools/arsenal/register.go similarity index 100% rename from pkg/tools/arsenal/register.go rename to tools/arsenal/register.go diff --git a/pkg/tools/functional_integration_full_test.go b/tools/functional_integration_full_test.go similarity index 92% rename from pkg/tools/functional_integration_full_test.go rename to tools/functional_integration_full_test.go index 6cd0e694..e84ab212 100644 --- a/pkg/tools/functional_integration_full_test.go +++ b/tools/functional_integration_full_test.go @@ -13,8 +13,8 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/katana" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/katana" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func TestFullScannerPublicIntegration(t *testing.T) { diff --git a/pkg/tools/functional_integration_test.go b/tools/functional_integration_test.go similarity index 94% rename from pkg/tools/functional_integration_test.go rename to tools/functional_integration_test.go index 19556641..3a415a16 100644 --- a/pkg/tools/functional_integration_test.go +++ b/tools/functional_integration_test.go @@ -15,10 +15,10 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/neutron" + "github.com/chainreactors/aiscan/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/spray" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/functional_norace_test.go b/tools/functional_norace_test.go similarity index 100% rename from pkg/tools/functional_norace_test.go rename to tools/functional_norace_test.go diff --git a/pkg/tools/functional_race_test.go b/tools/functional_race_test.go similarity index 100% rename from pkg/tools/functional_race_test.go rename to tools/functional_race_test.go diff --git a/pkg/tools/functional_regression_full_test.go b/tools/functional_regression_full_test.go similarity index 94% rename from pkg/tools/functional_regression_full_test.go rename to tools/functional_regression_full_test.go index 5c4aba66..d1457a96 100644 --- a/pkg/tools/functional_regression_full_test.go +++ b/tools/functional_regression_full_test.go @@ -13,9 +13,9 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/katana" - passivecmd "github.com/chainreactors/aiscan/pkg/tools/passive" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/katana" + passivecmd "github.com/chainreactors/aiscan/tools/passive" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/projectdiscovery/uncover/sources" ) diff --git a/pkg/tools/functional_regression_test.go b/tools/functional_regression_test.go similarity index 97% rename from pkg/tools/functional_regression_test.go rename to tools/functional_regression_test.go index 2a9fbc0d..3547584c 100644 --- a/pkg/tools/functional_regression_test.go +++ b/tools/functional_regression_test.go @@ -21,12 +21,12 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + "github.com/chainreactors/aiscan/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/functional_testkit_test.go b/tools/functional_testkit_test.go similarity index 100% rename from pkg/tools/functional_testkit_test.go rename to tools/functional_testkit_test.go diff --git a/pkg/tools/gogo/gogo.go b/tools/gogo/gogo.go similarity index 98% rename from pkg/tools/gogo/gogo.go rename to tools/gogo/gogo.go index 5687ab39..71b42381 100644 --- a/pkg/tools/gogo/gogo.go +++ b/tools/gogo/gogo.go @@ -11,7 +11,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" gogocore "github.com/chainreactors/gogo/v2/core" "github.com/chainreactors/sdk/gogo" "github.com/chainreactors/utils/parsers" diff --git a/pkg/tools/gogo/gogo_test.go b/tools/gogo/gogo_test.go similarity index 100% rename from pkg/tools/gogo/gogo_test.go rename to tools/gogo/gogo_test.go diff --git a/pkg/tools/gogo/register.go b/tools/gogo/register.go similarity index 93% rename from pkg/tools/gogo/register.go rename to tools/gogo/register.go index afebce61..65eb87a7 100644 --- a/pkg/tools/gogo/register.go +++ b/tools/gogo/register.go @@ -3,7 +3,7 @@ package gogo import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/ioa/commands.go b/tools/ioa/commands.go similarity index 100% rename from pkg/tools/ioa/commands.go rename to tools/ioa/commands.go diff --git a/pkg/tools/ioa/commands_test.go b/tools/ioa/commands_test.go similarity index 99% rename from pkg/tools/ioa/commands_test.go rename to tools/ioa/commands_test.go index 5c9ab8df..b027edb0 100644 --- a/pkg/tools/ioa/commands_test.go +++ b/tools/ioa/commands_test.go @@ -489,7 +489,7 @@ func TestDefaultSpaceSkipsJoin(t *testing.T) { // Run with: // // LIVE_TEST_API_KEY=sk-xxx \ -// go test -v -run TestLLMIOAToolUsage ./pkg/tools/ioa/ -timeout 120s +// go test -v -run TestLLMIOAToolUsage ./tools/ioa/ -timeout 120s func TestLLMIOAToolUsage(t *testing.T) { apiKey := os.Getenv("LIVE_TEST_API_KEY") if apiKey == "" { diff --git a/pkg/tools/ioa/register.go b/tools/ioa/register.go similarity index 100% rename from pkg/tools/ioa/register.go rename to tools/ioa/register.go diff --git a/pkg/tools/katana/katana.go b/tools/katana/katana.go similarity index 99% rename from pkg/tools/katana/katana.go rename to tools/katana/katana.go index cf66e080..e52d2e06 100644 --- a/pkg/tools/katana/katana.go +++ b/tools/katana/katana.go @@ -15,7 +15,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/projectdiscovery/goflags" "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/gologger/levels" diff --git a/pkg/tools/katana/register.go b/tools/katana/register.go similarity index 100% rename from pkg/tools/katana/register.go rename to tools/katana/register.go diff --git a/pkg/tools/neutron/neutron.go b/tools/neutron/neutron.go similarity index 99% rename from pkg/tools/neutron/neutron.go rename to tools/neutron/neutron.go index ad0632d4..ca0ac53e 100644 --- a/pkg/tools/neutron/neutron.go +++ b/tools/neutron/neutron.go @@ -16,8 +16,8 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - scanengine "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + scanengine "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/neutron/templates" sdkneutron "github.com/chainreactors/sdk/neutron" "github.com/chainreactors/sdk/pkg/association" diff --git a/pkg/tools/neutron/neutron_test.go b/tools/neutron/neutron_test.go similarity index 100% rename from pkg/tools/neutron/neutron_test.go rename to tools/neutron/neutron_test.go diff --git a/pkg/tools/neutron/register.go b/tools/neutron/register.go similarity index 93% rename from pkg/tools/neutron/register.go rename to tools/neutron/register.go index 9ba4251e..9a83bc4a 100644 --- a/pkg/tools/neutron/register.go +++ b/tools/neutron/register.go @@ -3,7 +3,7 @@ package neutron import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/neutron/sdk_stage.go b/tools/neutron/sdk_stage.go similarity index 99% rename from pkg/tools/neutron/sdk_stage.go rename to tools/neutron/sdk_stage.go index e21685d0..e9832d44 100644 --- a/pkg/tools/neutron/sdk_stage.go +++ b/tools/neutron/sdk_stage.go @@ -9,7 +9,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/telemetry" - scanengine "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + scanengine "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/neutron/common" "github.com/chainreactors/neutron/templates" sdkneutron "github.com/chainreactors/sdk/neutron" diff --git a/pkg/tools/passive/passive.go b/tools/passive/passive.go similarity index 99% rename from pkg/tools/passive/passive.go rename to tools/passive/passive.go index dc503f28..6a1f5f73 100644 --- a/pkg/tools/passive/passive.go +++ b/tools/passive/passive.go @@ -16,7 +16,7 @@ import ( "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/projectdiscovery/uncover/sources" ) diff --git a/pkg/tools/passive/passive_test.go b/tools/passive/passive_test.go similarity index 98% rename from pkg/tools/passive/passive_test.go rename to tools/passive/passive_test.go index b4726f76..be2df63f 100644 --- a/pkg/tools/passive/passive_test.go +++ b/tools/passive/passive_test.go @@ -7,7 +7,7 @@ import ( "reflect" "testing" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/projectdiscovery/uncover/sources" ) diff --git a/pkg/tools/passive/register.go b/tools/passive/register.go similarity index 94% rename from pkg/tools/passive/register.go rename to tools/passive/register.go index ecdc26a7..e4b6f275 100644 --- a/pkg/tools/passive/register.go +++ b/tools/passive/register.go @@ -5,7 +5,7 @@ package passive import ( "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/playwright/advanced.go b/tools/playwright/advanced.go similarity index 100% rename from pkg/tools/playwright/advanced.go rename to tools/playwright/advanced.go diff --git a/pkg/tools/playwright/autofill.go b/tools/playwright/autofill.go similarity index 100% rename from pkg/tools/playwright/autofill.go rename to tools/playwright/autofill.go diff --git a/pkg/tools/playwright/browser.go b/tools/playwright/browser.go similarity index 100% rename from pkg/tools/playwright/browser.go rename to tools/playwright/browser.go diff --git a/pkg/tools/playwright/browser_test.go b/tools/playwright/browser_test.go similarity index 100% rename from pkg/tools/playwright/browser_test.go rename to tools/playwright/browser_test.go diff --git a/pkg/tools/playwright/dialog.go b/tools/playwright/dialog.go similarity index 100% rename from pkg/tools/playwright/dialog.go rename to tools/playwright/dialog.go diff --git a/pkg/tools/playwright/discover.go b/tools/playwright/discover.go similarity index 100% rename from pkg/tools/playwright/discover.go rename to tools/playwright/discover.go diff --git a/pkg/tools/playwright/headless.go b/tools/playwright/headless.go similarity index 100% rename from pkg/tools/playwright/headless.go rename to tools/playwright/headless.go diff --git a/pkg/tools/playwright/interact.go b/tools/playwright/interact.go similarity index 100% rename from pkg/tools/playwright/interact.go rename to tools/playwright/interact.go diff --git a/pkg/tools/playwright/navigation.go b/tools/playwright/navigation.go similarity index 100% rename from pkg/tools/playwright/navigation.go rename to tools/playwright/navigation.go diff --git a/pkg/tools/playwright/recorder.go b/tools/playwright/recorder.go similarity index 100% rename from pkg/tools/playwright/recorder.go rename to tools/playwright/recorder.go diff --git a/pkg/tools/playwright/recorder_test.go b/tools/playwright/recorder_test.go similarity index 100% rename from pkg/tools/playwright/recorder_test.go rename to tools/playwright/recorder_test.go diff --git a/pkg/tools/playwright/register.go b/tools/playwright/register.go similarity index 100% rename from pkg/tools/playwright/register.go rename to tools/playwright/register.go diff --git a/pkg/tools/playwright/session.go b/tools/playwright/session.go similarity index 100% rename from pkg/tools/playwright/session.go rename to tools/playwright/session.go diff --git a/pkg/tools/playwright/storage.go b/tools/playwright/storage.go similarity index 100% rename from pkg/tools/playwright/storage.go rename to tools/playwright/storage.go diff --git a/pkg/tools/playwright/storage_test.go b/tools/playwright/storage_test.go similarity index 100% rename from pkg/tools/playwright/storage_test.go rename to tools/playwright/storage_test.go diff --git a/pkg/tools/playwright/tabs.go b/tools/playwright/tabs.go similarity index 100% rename from pkg/tools/playwright/tabs.go rename to tools/playwright/tabs.go diff --git a/pkg/tools/playwright/testharness/.gitignore b/tools/playwright/testharness/.gitignore similarity index 100% rename from pkg/tools/playwright/testharness/.gitignore rename to tools/playwright/testharness/.gitignore diff --git a/pkg/tools/playwright/testharness/conftest.py b/tools/playwright/testharness/conftest.py similarity index 100% rename from pkg/tools/playwright/testharness/conftest.py rename to tools/playwright/testharness/conftest.py diff --git a/pkg/tools/playwright/testharness/fixtures/dynamic.html b/tools/playwright/testharness/fixtures/dynamic.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/dynamic.html rename to tools/playwright/testharness/fixtures/dynamic.html diff --git a/pkg/tools/playwright/testharness/fixtures/forms.html b/tools/playwright/testharness/fixtures/forms.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/forms.html rename to tools/playwright/testharness/fixtures/forms.html diff --git a/pkg/tools/playwright/testharness/fixtures/headless-extract.yaml b/tools/playwright/testharness/fixtures/headless-extract.yaml similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/headless-extract.yaml rename to tools/playwright/testharness/fixtures/headless-extract.yaml diff --git a/pkg/tools/playwright/testharness/fixtures/headless-template.yaml b/tools/playwright/testharness/fixtures/headless-template.yaml similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/headless-template.yaml rename to tools/playwright/testharness/fixtures/headless-template.yaml diff --git a/pkg/tools/playwright/testharness/fixtures/login.html b/tools/playwright/testharness/fixtures/login.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/login.html rename to tools/playwright/testharness/fixtures/login.html diff --git a/pkg/tools/playwright/testharness/fixtures/navigation.html b/tools/playwright/testharness/fixtures/navigation.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/navigation.html rename to tools/playwright/testharness/fixtures/navigation.html diff --git a/pkg/tools/playwright/testharness/fixtures/page2.html b/tools/playwright/testharness/fixtures/page2.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/page2.html rename to tools/playwright/testharness/fixtures/page2.html diff --git a/pkg/tools/playwright/testharness/pw_driver.go b/tools/playwright/testharness/pw_driver.go similarity index 91% rename from pkg/tools/playwright/testharness/pw_driver.go rename to tools/playwright/testharness/pw_driver.go index 62fcc368..fc66e3f9 100644 --- a/pkg/tools/playwright/testharness/pw_driver.go +++ b/tools/playwright/testharness/pw_driver.go @@ -4,7 +4,7 @@ // It reads JSON-line commands from stdin and writes JSON-line responses to stdout. // The Command instance (and its sessions) persist across calls. // -// Build: go build -tags browser -o pw_driver ./pkg/tools/playwright/testharness/pw_driver.go +// Build: go build -tags browser -o pw_driver ./tools/playwright/testharness/pw_driver.go // // Protocol: // @@ -24,7 +24,7 @@ import ( "os/signal" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/playwright" + "github.com/chainreactors/aiscan/tools/playwright" ) type request struct { diff --git a/pkg/tools/playwright/testharness/test_cli_parity.py b/tools/playwright/testharness/test_cli_parity.py similarity index 100% rename from pkg/tools/playwright/testharness/test_cli_parity.py rename to tools/playwright/testharness/test_cli_parity.py diff --git a/pkg/tools/playwright/testharness/test_dispatch.py b/tools/playwright/testharness/test_dispatch.py similarity index 100% rename from pkg/tools/playwright/testharness/test_dispatch.py rename to tools/playwright/testharness/test_dispatch.py diff --git a/pkg/tools/playwright/testharness/test_extraction.py b/tools/playwright/testharness/test_extraction.py similarity index 100% rename from pkg/tools/playwright/testharness/test_extraction.py rename to tools/playwright/testharness/test_extraction.py diff --git a/pkg/tools/playwright/testharness/test_files.py b/tools/playwright/testharness/test_files.py similarity index 100% rename from pkg/tools/playwright/testharness/test_files.py rename to tools/playwright/testharness/test_files.py diff --git a/pkg/tools/playwright/testharness/test_focus.py b/tools/playwright/testharness/test_focus.py similarity index 100% rename from pkg/tools/playwright/testharness/test_focus.py rename to tools/playwright/testharness/test_focus.py diff --git a/pkg/tools/playwright/testharness/test_headers.py b/tools/playwright/testharness/test_headers.py similarity index 100% rename from pkg/tools/playwright/testharness/test_headers.py rename to tools/playwright/testharness/test_headers.py diff --git a/pkg/tools/playwright/testharness/test_headless_template.py b/tools/playwright/testharness/test_headless_template.py similarity index 100% rename from pkg/tools/playwright/testharness/test_headless_template.py rename to tools/playwright/testharness/test_headless_template.py diff --git a/pkg/tools/playwright/testharness/test_interaction.py b/tools/playwright/testharness/test_interaction.py similarity index 100% rename from pkg/tools/playwright/testharness/test_interaction.py rename to tools/playwright/testharness/test_interaction.py diff --git a/pkg/tools/playwright/testharness/test_navigation.py b/tools/playwright/testharness/test_navigation.py similarity index 100% rename from pkg/tools/playwright/testharness/test_navigation.py rename to tools/playwright/testharness/test_navigation.py diff --git a/pkg/tools/playwright/testharness/test_route.py b/tools/playwright/testharness/test_route.py similarity index 100% rename from pkg/tools/playwright/testharness/test_route.py rename to tools/playwright/testharness/test_route.py diff --git a/pkg/tools/playwright/testharness/test_viewport.py b/tools/playwright/testharness/test_viewport.py similarity index 100% rename from pkg/tools/playwright/testharness/test_viewport.py rename to tools/playwright/testharness/test_viewport.py diff --git a/pkg/tools/playwright/testharness/test_wait.py b/tools/playwright/testharness/test_wait.py similarity index 100% rename from pkg/tools/playwright/testharness/test_wait.py rename to tools/playwright/testharness/test_wait.py diff --git a/pkg/tools/proton/command.go b/tools/proton/command.go similarity index 99% rename from pkg/tools/proton/command.go rename to tools/proton/command.go index 0ebe3612..af7adf37 100644 --- a/pkg/tools/proton/command.go +++ b/tools/proton/command.go @@ -19,7 +19,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/neutron/operators" "github.com/chainreactors/neutron/protocols" "github.com/chainreactors/proton/proton/file" diff --git a/pkg/tools/proton/command_test.go b/tools/proton/command_test.go similarity index 99% rename from pkg/tools/proton/command_test.go rename to tools/proton/command_test.go index 3ad30f11..090df25d 100644 --- a/pkg/tools/proton/command_test.go +++ b/tools/proton/command_test.go @@ -11,7 +11,7 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" - protoncmd "github.com/chainreactors/aiscan/pkg/tools/proton" + protoncmd "github.com/chainreactors/aiscan/tools/proton" ) // --------------------------------------------------------------------------- diff --git a/pkg/tools/proton/register.go b/tools/proton/register.go similarity index 100% rename from pkg/tools/proton/register.go rename to tools/proton/register.go diff --git a/pkg/tools/proton/register_test.go b/tools/proton/register_test.go similarity index 100% rename from pkg/tools/proton/register_test.go rename to tools/proton/register_test.go diff --git a/pkg/tools/proxy/command.go b/tools/proxy/command.go similarity index 100% rename from pkg/tools/proxy/command.go rename to tools/proxy/command.go diff --git a/pkg/tools/proxy/command_test.go b/tools/proxy/command_test.go similarity index 100% rename from pkg/tools/proxy/command_test.go rename to tools/proxy/command_test.go diff --git a/pkg/tools/proxy/mitm.go b/tools/proxy/mitm.go similarity index 100% rename from pkg/tools/proxy/mitm.go rename to tools/proxy/mitm.go diff --git a/pkg/tools/proxy/mitm_test.go b/tools/proxy/mitm_test.go similarity index 100% rename from pkg/tools/proxy/mitm_test.go rename to tools/proxy/mitm_test.go diff --git a/pkg/tools/proxy/race_norace_test.go b/tools/proxy/race_norace_test.go similarity index 100% rename from pkg/tools/proxy/race_norace_test.go rename to tools/proxy/race_norace_test.go diff --git a/pkg/tools/proxy/race_test.go b/tools/proxy/race_test.go similarity index 100% rename from pkg/tools/proxy/race_test.go rename to tools/proxy/race_test.go diff --git a/pkg/tools/proxy/register_command.go b/tools/proxy/register_command.go similarity index 100% rename from pkg/tools/proxy/register_command.go rename to tools/proxy/register_command.go diff --git a/pkg/tools/proxy/state.go b/tools/proxy/state.go similarity index 100% rename from pkg/tools/proxy/state.go rename to tools/proxy/state.go diff --git a/pkg/tools/proxy/state_test.go b/tools/proxy/state_test.go similarity index 100% rename from pkg/tools/proxy/state_test.go rename to tools/proxy/state_test.go diff --git a/pkg/tools/register_command.go b/tools/register_command.go similarity index 89% rename from pkg/tools/register_command.go rename to tools/register_command.go index 2507b4c4..33bf7f10 100644 --- a/pkg/tools/register_command.go +++ b/tools/register_command.go @@ -3,8 +3,8 @@ package tools import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/register_command_full_test.go b/tools/register_command_full_test.go similarity index 93% rename from pkg/tools/register_command_full_test.go rename to tools/register_command_full_test.go index 9ab600c0..962e00ea 100644 --- a/pkg/tools/register_command_full_test.go +++ b/tools/register_command_full_test.go @@ -5,7 +5,7 @@ package tools import ( "testing" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/sdk/gogo" "github.com/chainreactors/sdk/spray" ) diff --git a/pkg/tools/register_command_integration_test.go b/tools/register_command_integration_test.go similarity index 93% rename from pkg/tools/register_command_integration_test.go rename to tools/register_command_integration_test.go index 88ac149f..4f31e33e 100644 --- a/pkg/tools/register_command_integration_test.go +++ b/tools/register_command_integration_test.go @@ -1,7 +1,7 @@ //go:build full && integration // Run with: AISCAN_INTEGRATION=1 FOFA_EMAIL=... FOFA_KEY=... \ -// go test -tags 'full integration' ./pkg/tools/... -run TestIntegration -v +// go test -tags 'full integration' ./tools/... -run TestIntegration -v package tools import ( @@ -15,8 +15,8 @@ import ( "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - passivecmd "github.com/chainreactors/aiscan/pkg/tools/passive" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + passivecmd "github.com/chainreactors/aiscan/tools/passive" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func passiveExecString(t *testing.T, cmd *passivecmd.Command, ctx context.Context, args []string) string { diff --git a/pkg/tools/register_command_test.go b/tools/register_command_test.go similarity index 95% rename from pkg/tools/register_command_test.go rename to tools/register_command_test.go index bca2d107..5c6ad7d4 100644 --- a/pkg/tools/register_command_test.go +++ b/tools/register_command_test.go @@ -13,12 +13,12 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/gogo" - "github.com/chainreactors/aiscan/pkg/tools/neutron" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - _ "github.com/chainreactors/aiscan/pkg/tools/search" - "github.com/chainreactors/aiscan/pkg/tools/spray" - "github.com/chainreactors/aiscan/pkg/tools/zombie" + "github.com/chainreactors/aiscan/tools/gogo" + "github.com/chainreactors/aiscan/tools/neutron" + "github.com/chainreactors/aiscan/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/search" + "github.com/chainreactors/aiscan/tools/spray" + "github.com/chainreactors/aiscan/tools/zombie" fingerslib "github.com/chainreactors/fingers/fingers" neutronhttp "github.com/chainreactors/neutron/protocols/http" "github.com/chainreactors/proxyclient" diff --git a/pkg/tools/scan/adapter.go b/tools/scan/adapter.go similarity index 99% rename from pkg/tools/scan/adapter.go rename to tools/scan/adapter.go index 4abb30c6..c5a56a6a 100644 --- a/pkg/tools/scan/adapter.go +++ b/tools/scan/adapter.go @@ -6,11 +6,11 @@ import ( "net/url" "strings" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/aiscan/tools/scan/engine" sdktypes "github.com/chainreactors/sdk/pkg/types" sdkzombie "github.com/chainreactors/sdk/zombie" "github.com/chainreactors/utils" + "github.com/chainreactors/utils/parsers" zombiepkg "github.com/chainreactors/zombie/pkg" ) diff --git a/pkg/tools/scan/aggregate.go b/tools/scan/aggregate.go similarity index 100% rename from pkg/tools/scan/aggregate.go rename to tools/scan/aggregate.go diff --git a/pkg/tools/scan/aggregate_test.go b/tools/scan/aggregate_test.go similarity index 100% rename from pkg/tools/scan/aggregate_test.go rename to tools/scan/aggregate_test.go diff --git a/pkg/tools/scan/bridge.go b/tools/scan/bridge.go similarity index 96% rename from pkg/tools/scan/bridge.go rename to tools/scan/bridge.go index b883b728..92b6a3bb 100644 --- a/pkg/tools/scan/bridge.go +++ b/tools/scan/bridge.go @@ -6,7 +6,7 @@ import ( "io" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) type pipelineEvent struct { diff --git a/pkg/tools/scan/capability.go b/tools/scan/capability.go similarity index 98% rename from pkg/tools/scan/capability.go rename to tools/scan/capability.go index c188986d..f18f54e1 100644 --- a/pkg/tools/scan/capability.go +++ b/tools/scan/capability.go @@ -3,8 +3,8 @@ package scan import ( "context" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) const ( diff --git a/pkg/tools/scan/capability_katana.go b/tools/scan/capability_katana.go similarity index 87% rename from pkg/tools/scan/capability_katana.go rename to tools/scan/capability_katana.go index f6497b6f..74699eb0 100644 --- a/pkg/tools/scan/capability_katana.go +++ b/tools/scan/capability_katana.go @@ -16,7 +16,7 @@ import ( katanatypes "github.com/projectdiscovery/katana/pkg/types" "github.com/projectdiscovery/katana/pkg/utils/queue" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) const ( @@ -87,17 +87,17 @@ func runKatanaCrawl(ctx context.Context, c *Command, e event, depth int, jsMode seen := make(map[string]struct{}) options := &katanatypes.Options{ - MaxDepth: depth, - FieldScope: "rdn", - BodyReadSize: math.MaxInt, - RateLimit: 150, - Strategy: queue.DepthFirst.String(), - Silent: true, - ScrapeJSResponses: jsMode, + MaxDepth: depth, + FieldScope: "rdn", + BodyReadSize: math.MaxInt, + RateLimit: 150, + Strategy: queue.DepthFirst.String(), + Silent: true, + ScrapeJSResponses: jsMode, ScrapeJSLuiceResponses: jsMode, - Timeout: 10, - Concurrency: 10, - Parallelism: 10, + Timeout: 10, + Concurrency: 10, + Parallelism: 10, OnResult: func(r katanaoutput.Result) { if r.Request == nil || r.Request.URL == "" { return @@ -177,6 +177,6 @@ func sameRootDomain(rawURL, rdn string) bool { type silentWriter struct{} -func (w *silentWriter) Close() error { return nil } -func (w *silentWriter) Write(_ *katanaoutput.Result) error { return nil } +func (w *silentWriter) Close() error { return nil } +func (w *silentWriter) Write(_ *katanaoutput.Result) error { return nil } func (w *silentWriter) WriteErr(_ *katanaoutput.Error) error { return nil } diff --git a/pkg/tools/scan/capability_katana_stub.go b/tools/scan/capability_katana_stub.go similarity index 100% rename from pkg/tools/scan/capability_katana_stub.go rename to tools/scan/capability_katana_stub.go diff --git a/pkg/tools/scan/capability_katana_test.go b/tools/scan/capability_katana_test.go similarity index 100% rename from pkg/tools/scan/capability_katana_test.go rename to tools/scan/capability_katana_test.go diff --git a/pkg/tools/scan/collector.go b/tools/scan/collector.go similarity index 99% rename from pkg/tools/scan/collector.go rename to tools/scan/collector.go index 303692c4..f8854e9b 100644 --- a/pkg/tools/scan/collector.go +++ b/tools/scan/collector.go @@ -8,7 +8,7 @@ import ( "time" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" sdktypes "github.com/chainreactors/sdk/pkg/types" "github.com/chainreactors/utils" "github.com/chainreactors/utils/parsers" diff --git a/pkg/tools/scan/command.go b/tools/scan/command.go similarity index 98% rename from pkg/tools/scan/command.go rename to tools/scan/command.go index 02a8837d..c405df38 100644 --- a/pkg/tools/scan/command.go +++ b/tools/scan/command.go @@ -13,9 +13,9 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/toolargs" goflags "github.com/jessevdk/go-flags" ) diff --git a/pkg/tools/scan/command_test.go b/tools/scan/command_test.go similarity index 99% rename from pkg/tools/scan/command_test.go rename to tools/scan/command_test.go index 548b6c51..5bf8967a 100644 --- a/pkg/tools/scan/command_test.go +++ b/tools/scan/command_test.go @@ -18,8 +18,8 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/pipeline" "github.com/chainreactors/fingers/common" "github.com/chainreactors/logs" "github.com/chainreactors/neutron/operators" diff --git a/pkg/tools/scan/data_bus_test.go b/tools/scan/data_bus_test.go similarity index 95% rename from pkg/tools/scan/data_bus_test.go rename to tools/scan/data_bus_test.go index 03903ed9..1a0b2e77 100644 --- a/pkg/tools/scan/data_bus_test.go +++ b/tools/scan/data_bus_test.go @@ -6,7 +6,7 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/scan/engine/gogo.go b/tools/scan/engine/gogo.go similarity index 100% rename from pkg/tools/scan/engine/gogo.go rename to tools/scan/engine/gogo.go diff --git a/pkg/tools/scan/engine/gogo_test.go b/tools/scan/engine/gogo_test.go similarity index 100% rename from pkg/tools/scan/engine/gogo_test.go rename to tools/scan/engine/gogo_test.go diff --git a/pkg/tools/scan/engine/neutron.go b/tools/scan/engine/neutron.go similarity index 100% rename from pkg/tools/scan/engine/neutron.go rename to tools/scan/engine/neutron.go diff --git a/pkg/tools/scan/engine/race_norace_test.go b/tools/scan/engine/race_norace_test.go similarity index 100% rename from pkg/tools/scan/engine/race_norace_test.go rename to tools/scan/engine/race_norace_test.go diff --git a/pkg/tools/scan/engine/race_test.go b/tools/scan/engine/race_test.go similarity index 100% rename from pkg/tools/scan/engine/race_test.go rename to tools/scan/engine/race_test.go diff --git a/pkg/tools/scan/engine/set.go b/tools/scan/engine/set.go similarity index 100% rename from pkg/tools/scan/engine/set.go rename to tools/scan/engine/set.go diff --git a/pkg/tools/scan/engine/set_test.go b/tools/scan/engine/set_test.go similarity index 100% rename from pkg/tools/scan/engine/set_test.go rename to tools/scan/engine/set_test.go diff --git a/pkg/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go similarity index 100% rename from pkg/tools/scan/engine/set_uncover_recon.go rename to tools/scan/engine/set_uncover_recon.go diff --git a/pkg/tools/scan/engine/set_uncover_stub.go b/tools/scan/engine/set_uncover_stub.go similarity index 100% rename from pkg/tools/scan/engine/set_uncover_stub.go rename to tools/scan/engine/set_uncover_stub.go diff --git a/pkg/tools/scan/engine/spray.go b/tools/scan/engine/spray.go similarity index 100% rename from pkg/tools/scan/engine/spray.go rename to tools/scan/engine/spray.go diff --git a/pkg/tools/scan/engine/spray_test.go b/tools/scan/engine/spray_test.go similarity index 100% rename from pkg/tools/scan/engine/spray_test.go rename to tools/scan/engine/spray_test.go diff --git a/pkg/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go similarity index 100% rename from pkg/tools/scan/engine/uncover.go rename to tools/scan/engine/uncover.go diff --git a/pkg/tools/scan/engine/uncover_agents.go b/tools/scan/engine/uncover_agents.go similarity index 100% rename from pkg/tools/scan/engine/uncover_agents.go rename to tools/scan/engine/uncover_agents.go diff --git a/pkg/tools/scan/engine/uncover_stub.go b/tools/scan/engine/uncover_stub.go similarity index 100% rename from pkg/tools/scan/engine/uncover_stub.go rename to tools/scan/engine/uncover_stub.go diff --git a/pkg/tools/scan/engine/uncover_test.go b/tools/scan/engine/uncover_test.go similarity index 100% rename from pkg/tools/scan/engine/uncover_test.go rename to tools/scan/engine/uncover_test.go diff --git a/pkg/tools/scan/engine/zombie.go b/tools/scan/engine/zombie.go similarity index 100% rename from pkg/tools/scan/engine/zombie.go rename to tools/scan/engine/zombie.go diff --git a/pkg/tools/scan/event.go b/tools/scan/event.go similarity index 100% rename from pkg/tools/scan/event.go rename to tools/scan/event.go diff --git a/pkg/tools/scan/http_auth.go b/tools/scan/http_auth.go similarity index 100% rename from pkg/tools/scan/http_auth.go rename to tools/scan/http_auth.go diff --git a/pkg/tools/scan/input.go b/tools/scan/input.go similarity index 100% rename from pkg/tools/scan/input.go rename to tools/scan/input.go diff --git a/pkg/tools/scan/intent.go b/tools/scan/intent.go similarity index 100% rename from pkg/tools/scan/intent.go rename to tools/scan/intent.go diff --git a/pkg/tools/scan/jsonl_writer.go b/tools/scan/jsonl_writer.go similarity index 97% rename from pkg/tools/scan/jsonl_writer.go rename to tools/scan/jsonl_writer.go index b80c73ea..b634d555 100644 --- a/pkg/tools/scan/jsonl_writer.go +++ b/tools/scan/jsonl_writer.go @@ -7,7 +7,7 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) type scanJSONLWriter struct { diff --git a/pkg/tools/scan/options.go b/tools/scan/options.go similarity index 100% rename from pkg/tools/scan/options.go rename to tools/scan/options.go diff --git a/pkg/tools/scan/output.go b/tools/scan/output.go similarity index 100% rename from pkg/tools/scan/output.go rename to tools/scan/output.go diff --git a/pkg/tools/scan/pipeline/pipeline.go b/tools/scan/pipeline/pipeline.go similarity index 100% rename from pkg/tools/scan/pipeline/pipeline.go rename to tools/scan/pipeline/pipeline.go diff --git a/pkg/tools/scan/pipeline/pipeline_test.go b/tools/scan/pipeline/pipeline_test.go similarity index 100% rename from pkg/tools/scan/pipeline/pipeline_test.go rename to tools/scan/pipeline/pipeline_test.go diff --git a/pkg/tools/scan/report.go b/tools/scan/report.go similarity index 100% rename from pkg/tools/scan/report.go rename to tools/scan/report.go diff --git a/pkg/tools/scan/report_json.go b/tools/scan/report_json.go similarity index 100% rename from pkg/tools/scan/report_json.go rename to tools/scan/report_json.go diff --git a/pkg/tools/scan/report_plain.go b/tools/scan/report_plain.go similarity index 100% rename from pkg/tools/scan/report_plain.go rename to tools/scan/report_plain.go diff --git a/pkg/tools/scan/scan_options.go b/tools/scan/scan_options.go similarity index 100% rename from pkg/tools/scan/scan_options.go rename to tools/scan/scan_options.go diff --git a/pkg/tools/scan/sco.go b/tools/scan/sco.go similarity index 100% rename from pkg/tools/scan/sco.go rename to tools/scan/sco.go diff --git a/pkg/tools/scan/sco_stub.go b/tools/scan/sco_stub.go similarity index 100% rename from pkg/tools/scan/sco_stub.go rename to tools/scan/sco_stub.go diff --git a/pkg/tools/scan/sco_test.go b/tools/scan/sco_test.go similarity index 97% rename from pkg/tools/scan/sco_test.go rename to tools/scan/sco_test.go index 9518e1b1..82c63893 100644 --- a/pkg/tools/scan/sco_test.go +++ b/tools/scan/sco_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/scan/structured.go b/tools/scan/structured.go similarity index 100% rename from pkg/tools/scan/structured.go rename to tools/scan/structured.go diff --git a/pkg/tools/scan/target.go b/tools/scan/target.go similarity index 100% rename from pkg/tools/scan/target.go rename to tools/scan/target.go diff --git a/pkg/tools/scan/verify.go b/tools/scan/verify.go similarity index 100% rename from pkg/tools/scan/verify.go rename to tools/scan/verify.go diff --git a/pkg/tools/search/cyberhub.go b/tools/search/cyberhub.go similarity index 100% rename from pkg/tools/search/cyberhub.go rename to tools/search/cyberhub.go diff --git a/pkg/tools/search/cyberhub_test.go b/tools/search/cyberhub_test.go similarity index 100% rename from pkg/tools/search/cyberhub_test.go rename to tools/search/cyberhub_test.go diff --git a/pkg/tools/search/fetch.go b/tools/search/fetch.go similarity index 100% rename from pkg/tools/search/fetch.go rename to tools/search/fetch.go diff --git a/pkg/tools/search/fetch_test.go b/tools/search/fetch_test.go similarity index 100% rename from pkg/tools/search/fetch_test.go rename to tools/search/fetch_test.go diff --git a/pkg/tools/search/register.go b/tools/search/register.go similarity index 95% rename from pkg/tools/search/register.go rename to tools/search/register.go index 7c183290..5d9a4817 100644 --- a/pkg/tools/search/register.go +++ b/tools/search/register.go @@ -4,7 +4,7 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/agent/provider" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/sdk/pkg/association" ) diff --git a/pkg/tools/search/tavily.go b/tools/search/tavily.go similarity index 100% rename from pkg/tools/search/tavily.go rename to tools/search/tavily.go diff --git a/pkg/tools/search/tavily_test.go b/tools/search/tavily_test.go similarity index 100% rename from pkg/tools/search/tavily_test.go rename to tools/search/tavily_test.go diff --git a/pkg/tools/search/websearch.go b/tools/search/websearch.go similarity index 100% rename from pkg/tools/search/websearch.go rename to tools/search/websearch.go diff --git a/pkg/tools/search/websearch_tool.go b/tools/search/websearch_tool.go similarity index 100% rename from pkg/tools/search/websearch_tool.go rename to tools/search/websearch_tool.go diff --git a/pkg/tools/spray/register.go b/tools/spray/register.go similarity index 93% rename from pkg/tools/spray/register.go rename to tools/spray/register.go index 4c0c14ac..3489fdd2 100644 --- a/pkg/tools/spray/register.go +++ b/tools/spray/register.go @@ -3,7 +3,7 @@ package spray import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/spray/spray.go b/tools/spray/spray.go similarity index 98% rename from pkg/tools/spray/spray.go rename to tools/spray/spray.go index cf28ee5f..69e0035c 100644 --- a/pkg/tools/spray/spray.go +++ b/tools/spray/spray.go @@ -11,7 +11,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/sdk/spray" spraycore "github.com/chainreactors/spray/core" "github.com/chainreactors/utils/parsers" diff --git a/pkg/tools/spray/spray_test.go b/tools/spray/spray_test.go similarity index 100% rename from pkg/tools/spray/spray_test.go rename to tools/spray/spray_test.go diff --git a/pkg/tools/toolargs/base.go b/tools/toolargs/base.go similarity index 100% rename from pkg/tools/toolargs/base.go rename to tools/toolargs/base.go diff --git a/pkg/tools/toolargs/flags.go b/tools/toolargs/flags.go similarity index 100% rename from pkg/tools/toolargs/flags.go rename to tools/toolargs/flags.go diff --git a/pkg/tools/toolargs/help.go b/tools/toolargs/help.go similarity index 100% rename from pkg/tools/toolargs/help.go rename to tools/toolargs/help.go diff --git a/pkg/tools/toolargs/normalize.go b/tools/toolargs/normalize.go similarity index 100% rename from pkg/tools/toolargs/normalize.go rename to tools/toolargs/normalize.go diff --git a/pkg/tools/toolargs/resolve.go b/tools/toolargs/resolve.go similarity index 100% rename from pkg/tools/toolargs/resolve.go rename to tools/toolargs/resolve.go diff --git a/pkg/tools/zombie/register.go b/tools/zombie/register.go similarity index 92% rename from pkg/tools/zombie/register.go rename to tools/zombie/register.go index 722d7023..8b78f30b 100644 --- a/pkg/tools/zombie/register.go +++ b/tools/zombie/register.go @@ -3,7 +3,7 @@ package zombie import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/zombie/zombie.go b/tools/zombie/zombie.go similarity index 97% rename from pkg/tools/zombie/zombie.go rename to tools/zombie/zombie.go index 5975a5a0..6d3e0966 100644 --- a/pkg/tools/zombie/zombie.go +++ b/tools/zombie/zombie.go @@ -10,7 +10,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" sdkzombie "github.com/chainreactors/sdk/zombie" zombiecore "github.com/chainreactors/zombie/core" ) diff --git a/pkg/tools/zombie/zombie_test.go b/tools/zombie/zombie_test.go similarity index 100% rename from pkg/tools/zombie/zombie_test.go rename to tools/zombie/zombie_test.go From 9fc27e5b9753480767b60146d47b569200d513b3 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 13:20:14 +0800 Subject: [PATCH 116/348] refactor(commands): remove TUI from runtime command execution --- core/output/timeline.go | 16 ++- core/output/timeline_test.go | 15 +++ core/runner/runtime_protocol.go | 12 +- core/runner/runtime_semantics_test.go | 5 + core/runner/runtime_session.go | 128 +++++++++++++++++----- pkg/aop/x/command/command.go | 13 +++ pkg/tui/commands.go | 2 +- pkg/tui/console.go | 20 ++++ pkg/webagent/agent.go | 34 +----- pkg/webagent/agent_test.go | 25 ----- pkg/webproto/message.go | 6 - web/frontend/src/components/ChatPanel.tsx | 26 ++++- 12 files changed, 203 insertions(+), 99 deletions(-) create mode 100644 pkg/aop/x/command/command.go diff --git a/core/output/timeline.go b/core/output/timeline.go index 6280956a..22b0ba77 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -11,6 +11,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/utils/parsers" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" @@ -377,7 +378,12 @@ func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { if data.Role == "user" { sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(text, 200))) } else { - sb.WriteString(text + "\n\n") + detail, ok, _ := xcommand.GetDetail(*event) + if ok && detail.Presentation == "preformatted" { + sb.WriteString(markdownCodeFence(text) + "\n\n") + } else { + sb.WriteString(text + "\n\n") + } } case aop.TypeToolCall: @@ -445,3 +451,11 @@ func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { } } } + +func markdownCodeFence(text string) string { + fence := "```" + for strings.Contains(text, fence) { + fence += "`" + } + return fence + "\n" + text + "\n" + fence +} diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index 342643bd..baac2462 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" ) func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { @@ -38,6 +39,20 @@ func TestTimelineRendersStructuredToolResult(t *testing.T) { } } +func TestTimelineFormatsPreformattedCommandAtPresentationBoundary(t *testing.T) { + data, _ := json.Marshal(aop.MessageData{ + MessageID: "command-1", Role: "assistant", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "one\ntwo"}}, + }) + event := aop.Event{ + Type: aop.TypeMessage, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", Agent: "aiscan", Data: data, + } + _ = xcommand.SetDetail(&event, xcommand.Detail{Line: "/status", Presentation: "preformatted"}) + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + if !strings.Contains(markdown, "```\none\ntwo\n```") { + t.Fatalf("timeline markdown = %q", markdown) + } +} + func mustTimelineTime(t *testing.T, value string) time.Time { t.Helper() parsed, err := time.Parse(time.RFC3339Nano, value) diff --git a/core/runner/runtime_protocol.go b/core/runner/runtime_protocol.go index 97111dde..aeb9a4ec 100644 --- a/core/runner/runtime_protocol.go +++ b/core/runner/runtime_protocol.go @@ -9,6 +9,14 @@ import ( "github.com/chainreactors/aiscan/pkg/webproto" ) +func RuntimeCommandSpecs() []webproto.CommandSpec { + return []webproto.CommandSpec{ + {Name: "/status", Description: "Show Runtime session and provider status"}, + {Name: "/clear", Description: "Clear the current Agent context"}, + {Name: "/compact", Usage: "/compact [focus]", Description: "Compact the current Agent context"}, + } +} + // HandleProtocol handles the transport-neutral Agent Runtime control frames. // The caller owns framing and I/O; AgentRuntime owns all Session and Run state. func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { @@ -92,9 +100,7 @@ func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message sendError("", msg.TaskID, err) return } - encoded, _ := json.Marshal(webproto.CommandResultPayload{ - SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata, - }) + encoded, _ := json.Marshal(result) send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) }() return true diff --git a/core/runner/runtime_semantics_test.go b/core/runner/runtime_semantics_test.go index e2b616f7..e2b1c4e3 100644 --- a/core/runner/runtime_semantics_test.go +++ b/core/runner/runtime_semantics_test.go @@ -11,6 +11,7 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -177,6 +178,10 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { if commandEvent.Type != aop.TypeMessage || commandEvent.TurnID != "" { t.Fatalf("command AOP event = %+v", commandEvent) } + detail, ok, err := xcommand.GetDetail(commandEvent) + if err != nil || !ok || detail.Line != "!printf COMMAND_OK" || detail.Presentation != CommandPresentationPreformatted { + t.Fatalf("command extension = %+v ok=%v err=%v", detail, ok, err) + } after := session.MessagesSnapshot() if len(after) != len(before) { t.Fatalf("command changed transcript: before=%d after=%d", len(before), len(after)) diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index ed0d95f2..747f17ec 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -1,7 +1,6 @@ package runner import ( - "bytes" "context" "encoding/json" "errors" @@ -11,11 +10,11 @@ import ( "time" "github.com/chainreactors/aiscan/core/eventbus" - outputpkg "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/evaluator" inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" @@ -60,10 +59,16 @@ type RunResult struct { } type CommandResult struct { - Parts []aop.MessagePart - Metadata map[string]any + Command string `json:"command"` + Presentation string `json:"presentation,omitempty"` + Parts []aop.MessagePart `json:"parts,omitempty"` } +const ( + CommandPresentationPlain = "plain" + CommandPresentationPreformatted = "preformatted" +) + type Session struct { state *sessionState } @@ -179,36 +184,99 @@ func (s *commandSession) execute(ctx context.Context, input string) commandOutco if line == "/continue" || strings.HasPrefix(line, "/followup ") || strings.HasPrefix(line, "/skill:") { return commandOutcome{err: fmt.Errorf("%s requires a Run", line)} } - - var stdout, stderr bytes.Buffer ctx = commands.ContextWithInbox(ctx, s.state.inbox) ctx = agent.ContextWithLoopScheduler(ctx, s.state.scheduler) - option := s.state.runtime.option - if option != nil { - copy := *option - copy.NoColor = true - option = © - } - console := tui.NewAgentConsoleWithWriters(ctx, option, s.state.runtime.consoleAppInfo(), s.state.agent, &stdout, &stderr) - console.SetEvalCriteria(s.evalCriteria) - _, err := console.ExecuteLineAndWait(line) - s.evalCriteria = console.EvalCriteria() - out := strings.TrimRight(outputpkg.StripANSI(stdout.String()), " \t\r\n") - errOut := strings.TrimRight(outputpkg.StripANSI(stderr.String()), " \t\r\n") + + if strings.HasPrefix(line, "!") { + return s.executeBash(ctx, line, strings.TrimSpace(strings.TrimPrefix(line, "!"))) + } + args, err := commands.SplitCommandLine(line) if err != nil { - if errOut != "" { - err = fmt.Errorf("%s: %w", errOut, err) - } return commandOutcome{err: err} } - if out == "" { - out = errOut - } else if errOut != "" { - out = strings.TrimRight(out+"\n"+errOut, " \t\r\n") + if len(args) == 0 { + return commandOutcome{err: fmt.Errorf("command line is required")} + } + name := args[0] + values := args[1:] + switch name { + case "/help": + return commandText(line, CommandPresentationPreformatted, + "Runtime commands:\n /status\n /clear\n /compact [focus]\n /eval [criteria|off]\n /loop [interval prompt|list|stop name]\n !") + case "/status": + provider, model, _ := s.state.runtime.providerSnapshot() + providerName := "not configured" + if provider != nil { + providerName = provider.Name() + } + return commandText(line, CommandPresentationPreformatted, fmt.Sprintf( + "Session: %s\nAgent: %s\nProvider: %s\nModel: %s\nMessages: %d", + s.state.id, s.state.agentName, providerName, model, len(s.state.agent.MessagesSnapshot()))) + case "/clear": + s.state.agent.Reset() + return commandText(line, CommandPresentationPlain, "Context cleared.") + case "/compact": + if len(s.state.agent.MessagesSnapshot()) < 4 { + return commandText(line, CommandPresentationPlain, "Nothing to compact (too few messages).") + } + result, err := s.state.agent.Compact(ctx, agent.CompactConfig{CustomInstructions: strings.TrimSpace(strings.Join(values, " "))}) + if err != nil { + return commandOutcome{err: err} + } + return commandText(line, CommandPresentationPlain, fmt.Sprintf( + "Compacted: ~%d -> ~%d tokens (%d messages kept)", result.TokensBefore, result.TokensAfter, result.KeptMessages)) + case "/eval", "/goal": + criteria := strings.TrimSpace(strings.Join(values, " ")) + switch criteria { + case "": + if s.evalCriteria == "" { + return commandText(line, CommandPresentationPlain, "Goal evaluation: off") + } + return commandText(line, CommandPresentationPlain, "Goal evaluation: on\n criteria: "+s.evalCriteria) + case "off": + s.evalCriteria = "" + return commandText(line, CommandPresentationPlain, "Goal evaluation disabled.") + default: + s.evalCriteria = criteria + return commandText(line, CommandPresentationPlain, "Goal evaluation enabled: "+criteria) + } + case "/loop": + command := "loop" + if len(values) == 0 { + command += " list" + } else { + command += " " + strings.Join(values, " ") + } + return s.executeBash(ctx, line, command) + default: + return commandOutcome{err: fmt.Errorf("command %q is not a Runtime command", name)} + } +} + +func (s *commandSession) executeBash(ctx context.Context, line, command string) commandOutcome { + if command == "" { + return commandOutcome{err: fmt.Errorf("command is required after !")} + } + registry := s.state.runtime.app.Commands + if registry == nil { + return commandOutcome{err: fmt.Errorf("command registry is not available")} } - result := CommandResult{Metadata: map[string]any{"command": line}} - if out != "" { - result.Parts = []aop.MessagePart{{Type: aop.PartText, Text: out}} + bash, ok := registry.GetTool("bash") + if !ok { + return commandOutcome{err: fmt.Errorf("bash tool is not registered")} + } + payload, _ := json.Marshal(commands.BashArgs{Command: command}) + result, err := bash.Execute(ctx, string(payload)) + if err != nil { + return commandOutcome{err: err} + } + return commandText(line, CommandPresentationPreformatted, strings.TrimRight(result.Text(), " \t\r\n")) +} + +func commandText(line, presentation, text string) commandOutcome { + result := CommandResult{Command: line, Presentation: presentation} + if text != "" { + result.Parts = []aop.MessagePart{{Type: aop.PartText, Text: text}} } return commandOutcome{result: result} } @@ -735,7 +803,9 @@ func (s *sessionState) emitCommandResult(result CommandResult) { raw, _ := json.Marshal(aop.MessageData{ MessageID: s.runtime.nextRuntimeID("command"), Role: "assistant", Parts: result.Parts, }) - s.runtime.sessionEvents.emit(aop.Event{Type: aop.TypeMessage, SessionID: s.id, Agent: s.agentName, Data: raw}) + event := aop.Event{Type: aop.TypeMessage, SessionID: s.id, Agent: s.agentName, Data: raw} + _ = xcommand.SetDetail(&event, xcommand.Detail{Line: result.Command, Presentation: result.Presentation}) + s.runtime.sessionEvents.emit(event) } func (rt *AgentRuntime) pendingLimit() int { diff --git a/pkg/aop/x/command/command.go b/pkg/aop/x/command/command.go new file mode 100644 index 00000000..123965de --- /dev/null +++ b/pkg/aop/x/command/command.go @@ -0,0 +1,13 @@ +package command + +import "github.com/chainreactors/aiscan/pkg/aop" + +const NS = "command" + +type Detail struct { + Line string `json:"line"` + Presentation string `json:"presentation,omitempty"` +} + +func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) } +func SetDetail(event *aop.Event, value Detail) error { return aop.SetExt(event, NS, value) } diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index ff781628..925b72b1 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -77,7 +77,7 @@ func SkillCommands(s *Session) []Command { } sk := skill cmds = append(cmds, Command{ - Name: "/" + sk.Name, + Name: "/skill:" + sk.Name, Description: sk.Description, Args: ArgsOptional, Run: func(ctx context.Context, s *Session, args []string) error { diff --git a/pkg/tui/console.go b/pkg/tui/console.go index 6ecede85..0dc558e5 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -443,6 +443,13 @@ func (r *AgentConsole) handleRuntimeInputLine(line string) (bool, error) { return r.appInfo.Run(ctx, prompt, false) }) } + if runtimeTUICommand(text) { + args, err := AgentConsoleArgsForLine(text) + if err != nil { + return false, err + } + return false, r.executeArgs(r.ctx, args) + } if strings.HasPrefix(text, "!") { return false, r.appInfo.Command(r.ctx, text) } @@ -455,6 +462,19 @@ func (r *AgentConsole) handleRuntimeInputLine(line string) (bool, error) { return false, r.appInfo.Command(r.ctx, text) } +func runtimeTUICommand(line string) bool { + name := strings.Fields(strings.TrimSpace(line)) + if len(name) == 0 { + return false + } + switch name[0] { + case "/help", "/resume", "/provider", "/model", "/spaces", "/messages", "/context", "/nodes": + return true + default: + return false + } +} + func (r *AgentConsole) promptString() string { return agentPromptString(r.ensureOutput()) } diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index b3923e73..142c920d 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -14,7 +14,6 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" @@ -239,33 +238,6 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) { }) } -// --------------------------------------------------------------------------- -// REPL helpers -// --------------------------------------------------------------------------- - -// fenceTerminalOutput wraps multi-line REPL/`!` command output in a Markdown -// code fence. runChatREPLLine runs the same TUI console the interactive REPL -// uses, whose panels (/status, /provider, /nodes ...) are drawn with box-drawing -// characters and column padding that only line up in a fixed-width, -// newline-preserving context. The web chat renders replies as Markdown prose, -// which collapses single newlines to spaces and uses a proportional font -- so an -// unfenced panel flattens into one mangled line. A fence makes the frontend -// render it verbatim in a monospace
. Single-line output (short status
-// confirmations like "Provider ready: ...") is left as prose.
-func fenceTerminalOutput(s string) string {
-	if !strings.Contains(s, "\n") {
-		return s
-	}
-	// Opening fence must be longer than any backtick run inside the payload
-	// (a `!cat` of a Markdown file could contain ```); grow it until it can't
-	// collide. Panel output never contains backticks, so this is just insurance.
-	fence := "```"
-	for strings.Contains(s, fence) {
-		fence += "`"
-	}
-	return fence + "\n" + s + "\n" + fence
-}
-
 // ---------------------------------------------------------------------------
 // Identity and command catalog (agent-specific, needs runner.AgentRuntime)
 // ---------------------------------------------------------------------------
@@ -275,9 +247,7 @@ func fenceTerminalOutput(s string) string {
 // non-internal) skill. The hub merges it with its hub-scope commands to build
 // the web "/" menu and /help, so the menu reflects what this agent can run.
 func agentCommandCatalog(app *runner.App) []webproto.CommandSpec {
-	// Build a zero-value console to extract command metadata without a live session.
-	r := &tui.AgentConsole{}
-	specs := tui.WebMenuSpecs(r.StaticCommands())
+	specs := runner.RuntimeCommandSpecs()
 	if app == nil || app.Skills == nil {
 		return specs
 	}
@@ -286,7 +256,7 @@ func agentCommandCatalog(app *runner.App) []webproto.CommandSpec {
 			continue
 		}
 		specs = append(specs, webproto.CommandSpec{
-			Name:        "/" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"),
+			Name:        "/skill:" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"),
 			Description: sk.Description,
 		})
 	}
diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go
index 52a85fcd..defcdf1d 100644
--- a/pkg/webagent/agent_test.go
+++ b/pkg/webagent/agent_test.go
@@ -510,28 +510,3 @@ func frameHasSessionActivity(frame pty.Frame, sessionID string) bool {
 	}
 	return false
 }
-
-func TestFenceTerminalOutput(t *testing.T) {
-	// Single-line status stays prose — no fence.
-	if got := fenceTerminalOutput("Provider ready: anthropic / glm-5.2"); strings.Contains(got, "```") {
-		t.Errorf("single-line output should not be fenced, got %q", got)
-	}
-	// Multi-line panel (box art) gets fenced so the web renders it monospace.
-	panel := "╭────╮\n│ providers │\n╰────╯"
-	got := fenceTerminalOutput(panel)
-	if !strings.HasPrefix(got, "```\n") || !strings.HasSuffix(got, "\n```") {
-		t.Errorf("multi-line panel should be wrapped in a code fence, got %q", got)
-	}
-	if !strings.Contains(got, panel) {
-		t.Errorf("fenced output should preserve the panel verbatim, got %q", got)
-	}
-	// A payload containing a triple-backtick run grows the fence so it can't collide.
-	got = fenceTerminalOutput("line1\n```\nline2")
-	if !strings.HasPrefix(got, "````\n") {
-		t.Errorf("fence must be longer than an inner backtick run, got %q", got)
-	}
-	// Empty stays empty.
-	if got := fenceTerminalOutput(""); got != "" {
-		t.Errorf("empty input should stay empty, got %q", got)
-	}
-}
diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go
index 2de98f76..e1225fb2 100644
--- a/pkg/webproto/message.go
+++ b/pkg/webproto/message.go
@@ -58,12 +58,6 @@ type CommandPayload struct {
 	Line      string `json:"line"`
 }
 
-type CommandResultPayload struct {
-	SessionID string            `json:"session_id"`
-	Parts     []aop.MessagePart `json:"parts,omitempty"`
-	Metadata  map[string]any    `json:"metadata,omitempty"`
-}
-
 type ErrorPayload struct {
 	Message string `json:"message"`
 }
diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx
index 633a1070..bba50c86 100644
--- a/web/frontend/src/components/ChatPanel.tsx
+++ b/web/frontend/src/components/ChatPanel.tsx
@@ -116,6 +116,28 @@ function eventText(event: AOPEvent): string {
     .join('\n')
 }
 
+function markdownCodeFence(text: string): string {
+  let fence = '```'
+  while (text.includes(fence)) fence += '`'
+  return `${fence}\n${text}\n${fence}`
+}
+
+function presentAOPEvent(event: AOPEvent): AOPEvent {
+  if (event.type !== 'message') return event
+  const command = event.ext?.command as { presentation?: string } | undefined
+  if (command?.presentation !== 'preformatted') return event
+  const data = event.data as { parts?: Array<{ type?: string; text?: string }> }
+  return {
+    ...event,
+    data: {
+      ...data,
+      parts: (data.parts ?? []).map((part) => (
+        part.type === 'text' && part.text ? { ...part, text: markdownCodeFence(part.text) } : part
+      )),
+    },
+  }
+}
+
 function extensionBlock(event: AOPEvent): Record {
   for (const value of Object.values(event.ext ?? {})) {
     if (value && typeof value === 'object') return value as Record
@@ -145,7 +167,7 @@ function reduceConversationAOP(
 
   const childIDs = new Set(childStarts.keys())
   const topLevel = reduceAOPToTimeline(
-    events.filter((event) => !childIDs.has(event.session_id)),
+    events.filter((event) => !childIDs.has(event.session_id)).map(presentAOPEvent),
     { streaming, lifecycle: 'errors' },
   ) as ViewerTimelineItem[]
 
@@ -170,7 +192,7 @@ function reduceConversationAOP(
           ? 'canceled'
           : 'completed'
     const timestamp = Date.parse(start.ts)
-    const items = reduceAOPToTimeline(childEvents, {
+    const items = reduceAOPToTimeline(childEvents.map(presentAOPEvent), {
       streaming: streaming && !end,
       lifecycle: 'errors',
     }).filter((item) => item.kind !== 'divider' || item.variant === 'warning') as ViewerTimelineItem[]

From 102a3635b71b6d990e4fb6571f5e3cf79fc6ed9a Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Sat, 25 Jul 2026 13:37:28 +0800
Subject: [PATCH 117/348] docs: remove obsolete agent compatibility references

---
 docs/mechanisms.md        | 28 ++++++++++------------------
 pkg/tui/remote_console.go | 10 +++++-----
 2 files changed, 15 insertions(+), 23 deletions(-)

diff --git a/docs/mechanisms.md b/docs/mechanisms.md
index 6b375acf..a86cfbc6 100644
--- a/docs/mechanisms.md
+++ b/docs/mechanisms.md
@@ -217,27 +217,19 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。
 
 ---
 
-## 12. completeAssistantRun 始终广播
+## 12. Agent 生命周期统一由 AOP 驱动
 
-**问题**: 旧 `persistAssistantMessage` 在 content 为空时跳过广播和持久化。tool-only turn 或 eval 命中轮次上限时 UI 卡在 streaming indicator。
+**问题**: 旧 Web 路径通过 `completeAssistantRun` 合成终止消息,并另外持久化中间轮次。它与 Runtime 已产生的 AOP message/turn 生命周期重复,tool-only turn 还需要额外的空消息规则才能释放 UI 状态。
 
-**机制**: 新 `completeAssistantRun` **始终广播** terminal message event,但只在有文本时持久化。空回复不留空行,UI 正常释放 composer。
+**机制**: Runtime 产生的 typed AOP event 是 Agent 消息、工具调用和 turn 状态的唯一语义来源。Web 层直接转发和持久化这些事件,不再合成第二套 assistant 完成事件,也不再为中间轮次维护独立的聊天事件协议。
 
-**文件**: `pkg/web/service.go`
+scan、agent joined、session cleared 等产品事件保留独立的 `DomainEvent`,不携带 Agent 的 role/content/message ID 字段。
 
----
-
-## 13. message_end 中间轮持久化
-
-**问题**: 多轮对话中只有最终聚合回复被持久化(`completeAssistantRun`),中间每轮的 assistant 文本只在 SSE 流中出现,页面刷新后消失。
-
-**机制**: `persistRuntimeChatEvent` 新增 `ChatEventMessageEnd` case。每轮非空的 finalized text 存为 assistant message,带 turn 元数据。`buildTimelineFromMessages` 按 turn 归到正确的气泡。streaming partials (message_start/message_delta) 不持久化。
-
-**文件**: `pkg/web/service.go`
+**文件**: `core/runner/`, `pkg/aop/`, `pkg/web/service.go`
 
 ---
 
-## 14. TUI 渲染改进
+## 13. TUI 渲染改进
 
 ### CJK 感知宽度
 
@@ -255,15 +247,15 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。
 
 `redactIOAURL` 剥离 `http://@host/ioa` 中的 userinfo,防止 token 泄露到终端/截图。
 
-### fenceTerminalOutput
+### 命令展示边界
 
-REPL 多行输出(box-drawing panel)在 web chat 中包裹 code fence,让前端以 monospace `
` 渲染。单行输出保持 prose。fence 长度自适应避免与内容中的 backtick 冲突。
+跨界面 Runtime 命令通过 typed AOP command detail 标记 `presentation: preformatted`。Web timeline 在最终展示边界生成自适应 Markdown code fence;Runtime、Session 和 transport 不再处理 Markdown 或终端格式。
 
-**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/webagent/agent.go`
+**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/aop/x/command/command.go`, `core/output/timeline.go`
 
 ---
 
-## 15. 环境变量优先级修正
+## 14. 环境变量优先级修正
 
 旧逻辑中 provider-scoped env(如 `ANTHROPIC_MODEL`)和 aiscan 自有 env(`AISCAN_MODEL`)在 `else if` 链中平级。hub 启动的 agent 继承 hub 环境后,Settings UI 配置的 model 被环境变量覆盖。
 
diff --git a/pkg/tui/remote_console.go b/pkg/tui/remote_console.go
index 17ba1c57..dd8193f1 100644
--- a/pkg/tui/remote_console.go
+++ b/pkg/tui/remote_console.go
@@ -13,13 +13,13 @@ import (
 	rlterm "github.com/chainreactors/tui/readline/terminal"
 )
 
-// AgentEventSubscriber connects a console-local renderer to the runtime AOP
+// AOPEventSubscriber connects a console-local renderer to the runtime AOP
 // bus and returns an unsubscribe function owned by that console attachment.
-type AgentEventSubscriber func(func(aop.Event)) func()
+type AOPEventSubscriber func(func(aop.Event)) func()
 
 // RunRemoteAgentConsoleWithControl adapts a byte-stream terminal while keeping
 // event rendering scoped to the attached agent session.
-func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, control *rlterm.StreamControl, subscribers ...AgentEventSubscriber) error {
+func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, control *rlterm.StreamControl, subscribers ...AOPEventSubscriber) error {
 	if control == nil {
 		control = rlterm.NewControl(true, 80, 24)
 	}
@@ -30,7 +30,7 @@ func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, a
 // RunAgentConsoleWithTerminal creates the renderer and readline console for an
 // explicit terminal. Local callers pass the process terminal directly so
 // control sequences are never buffered and replayed through a PTY.
-func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, terminal *rlterm.Terminal, subscribers ...AgentEventSubscriber) error {
+func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, terminal *rlterm.Terminal, subscribers ...AOPEventSubscriber) error {
 	if terminal == nil {
 		return fmt.Errorf("terminal is nil")
 	}
@@ -43,7 +43,7 @@ func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf
 
 // subscribeAgentOutput filters the shared runtime bus by session ID so a
 // remote or local REPL cannot render sibling/subagent events accidentally.
-func subscribeAgentOutput(output *AgentOutput, session *agent.Agent, subscribers ...AgentEventSubscriber) func() {
+func subscribeAgentOutput(output *AgentOutput, session *agent.Agent, subscribers ...AOPEventSubscriber) func() {
 	if output == nil || session == nil || len(subscribers) == 0 || subscribers[0] == nil {
 		return func() {}
 	}

From aa433b248b8ef92f8eda22e993641a218f0edc01 Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Sun, 26 Jul 2026 12:02:45 +0800
Subject: [PATCH 118/348] fix(ci): update tool test paths after package move

---
 .github/workflows/ci.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 45c1416e..dec57644 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -142,7 +142,7 @@ jobs:
       - name: Run proxy tool tests
         run: |
           go test -tags "re2_cgo re2_static" -race -count=1 -timeout 5m -v \
-            ./pkg/tools/proxy/
+            ./tools/proxy/
 
       - name: Run tmux command tests
         run: |
@@ -182,7 +182,7 @@ jobs:
         run: |
           go test -tags "full re2_cgo re2_static" -count=1 -timeout 5m -v \
             -run 'Test(ScannerFunctionalRegression|FullScannerFunctionalRegression)$' \
-            ./pkg/tools
+            ./tools
 
   # ── Generated templates tests (depends on tidy) ───────────────
 

From aa8002112dac5ba6d200cae875d6a22a2106202a Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Sun, 26 Jul 2026 12:52:14 +0800
Subject: [PATCH 119/348] fix(agent): preload manual assessment rules

---
 core/runner/prompt_test.go | 39 ++++++++++++++++++++++++++++++++++++++
 core/runner/runner.go      | 28 +++++++++++++++++++++++----
 skills/aiscan/SKILL.md     | 12 ++++++++++++
 3 files changed, 75 insertions(+), 4 deletions(-)

diff --git a/core/runner/prompt_test.go b/core/runner/prompt_test.go
index 5d4446ca..3970a9d0 100644
--- a/core/runner/prompt_test.go
+++ b/core/runner/prompt_test.go
@@ -1,10 +1,13 @@
 package runner
 
 import (
+	"context"
 	"strings"
 	"testing"
 
+	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/pkg/commands"
+	"github.com/chainreactors/aiscan/pkg/telemetry"
 	"github.com/chainreactors/aiscan/skills"
 )
 
@@ -81,3 +84,39 @@ func TestBuildSystemPromptLoadsSkillBody(t *testing.T) {
 		t.Fatal("loaded skills should appear before principles")
 	}
 }
+
+func TestAgentRuntimePreloadsBaseSkillOnce(t *testing.T) {
+	for _, tc := range []struct {
+		name   string
+		skills []string
+	}{
+		{name: "default"},
+		{name: "explicit duplicate", skills: []string{"aiscan"}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			option := &cfg.Option{}
+			option.Skills = tc.skills
+			rt, err := NewAgentRuntime(context.Background(), option, telemetry.NopLogger(), &RuntimeConfig{
+				ProviderOptional: true,
+				NoOutput:         true,
+			})
+			if err != nil {
+				t.Fatalf("NewAgentRuntime() error = %v", err)
+			}
+			defer rt.Close()
+
+			if count := strings.Count(rt.systemPrompt, "## Skill: aiscan"); count != 1 {
+				t.Fatalf("base skill count = %d, want 1", count)
+			}
+			for _, want := range []string{
+				"## User Tool Restrictions",
+				"map the application before focused testing",
+				"capture same-origin network/API calls",
+			} {
+				if !strings.Contains(rt.systemPrompt, want) {
+					t.Fatalf("system prompt missing base skill rule %q", want)
+				}
+			}
+		})
+	}
+}
diff --git a/core/runner/runner.go b/core/runner/runner.go
index a9ac8c3c..a0df525b 100644
--- a/core/runner/runner.go
+++ b/core/runner/runner.go
@@ -76,6 +76,8 @@ type RuntimeConfig struct {
 	MaxPending        int
 }
 
+const baseAgentSkillName = "aiscan"
+
 func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.Logger, rc *RuntimeConfig) (*AgentRuntime, error) {
 	if ctx == nil {
 		ctx = context.Background()
@@ -155,7 +157,19 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L
 		NodeName:    nodeName,
 		Space:       option.Space,
 	}
-	for _, name := range option.Skills {
+	if rc != nil && rc.PromptConfig != nil {
+		promptConfig := *rc.PromptConfig
+		promptConfig.LoadedSkills = append([]LoadedSkill(nil), rc.PromptConfig.LoadedSkills...)
+		pc = &promptConfig
+	}
+	skillNames := option.Skills
+	if !pc.ScannerAgentMode {
+		skillNames = append([]string{baseAgentSkillName}, skillNames...)
+	}
+	for _, name := range skillNames {
+		if promptHasLoadedSkill(pc, name) {
+			continue
+		}
 		body := rt.app.Skills.ReadBody(name)
 		if body == "" {
 			body = skills.ReadFile("skills/" + name + ".md")
@@ -167,9 +181,6 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L
 			pc.LoadedSkills = append(pc.LoadedSkills, LoadedSkill{Name: name, Body: body})
 		}
 	}
-	if rc != nil && rc.PromptConfig != nil {
-		pc = rc.PromptConfig
-	}
 	rt.systemPrompt = BuildSystemPrompt(pc, nil)
 	logger.Debugf("system prompt length: %d chars", len(rt.systemPrompt))
 
@@ -300,6 +311,15 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L
 	return rt, nil
 }
 
+func promptHasLoadedSkill(pc *PromptConfig, name string) bool {
+	for _, loaded := range pc.LoadedSkills {
+		if loaded.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
 func (rt *AgentRuntime) Close() {
 	if rt == nil {
 		return
diff --git a/skills/aiscan/SKILL.md b/skills/aiscan/SKILL.md
index 101b6321..13c9688e 100644
--- a/skills/aiscan/SKILL.md
+++ b/skills/aiscan/SKILL.md
@@ -26,6 +26,18 @@ Core agent tools:
 - `web_search`: search the web for CVEs, advisories, exploits, and documentation.
 - `fetch`: fetch and read a specific URL.
 
+## User Tool Restrictions
+
+Treat a user restriction as a constraint on tools and traffic, not as permission to reduce the requested assessment depth. Follow explicit scope and rate limits exactly.
+
+When the user says not to use scanners or automated scanning:
+
+- Do not invoke `scan`, `gogo`, `spray`, `zombie`, `neutron`, `proton`, `passive`, or `katana` unless the user later allows it.
+- Continue with allowed manual techniques unless the user also narrows the task itself. Do not silently reduce a broad web assessment to one vulnerability class or one browser action.
+- For a web target, map the application before focused testing: inspect the rendered page and forms, identify loaded JavaScript, capture same-origin network/API calls, check route or source-map clues, and review authentication/session boundaries.
+- Use `playwright`, `fetch`, and bounded shell requests only when they remain within the user's stated restrictions. Keep requests targeted and do not expand to related hosts without permission.
+- Explain any material coverage gap caused by the restriction in the final result.
+
 ## Pseudo-Commands
 
 All pseudo-commands run through `bash`. They are **not** system binaries.

From 071ed6828ee0accbf16cc6ebdab5fad7463a3bea Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Sun, 26 Jul 2026 15:42:22 +0800
Subject: [PATCH 120/348] feat(output): configure agent rendering detail

---
 cmd/aiscan/cli_test.go     |   3 +
 core/config/config_gen.go  |   7 +-
 core/config/env.go         |   3 +
 core/config/loader.go      |   1 +
 core/config/loader_test.go |  12 ++
 core/config/options.go     |   3 +-
 core/config/output.go      | 220 +++++++++++++++++++++++++++++++++++++
 core/config/output_test.go | 146 ++++++++++++++++++++++++
 docs/reference.md          |  32 +++++-
 pkg/tui/keybindings.go     |   5 +-
 pkg/tui/live.go            |  30 +++--
 pkg/tui/output.go          | 140 ++++++++++++++++++-----
 pkg/tui/output_test.go     | 186 ++++++++++++++++++++++++++++++-
 pkg/tui/stream.go          |  16 ++-
 14 files changed, 749 insertions(+), 55 deletions(-)
 create mode 100644 core/config/output.go
 create mode 100644 core/config/output_test.go

diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go
index 2bdd298e..631101ea 100644
--- a/cmd/aiscan/cli_test.go
+++ b/cmd/aiscan/cli_test.go
@@ -227,6 +227,9 @@ func TestAgentHelpRendersAgentOptionsWithoutRootCatalog(t *testing.T) {
 	for _, wants := range [][]string{
 		{"agent [OPTIONS]"},
 		{"Agent Options:"},
+		{"-v", "--verbose"},
+		{"thinking and tool previews"},
+		{"full tool results"},
 		{"--prompt", "/prompt"},
 		{"--transport", "/transport"},
 		{"--server-url", "/server-url"},
diff --git a/core/config/config_gen.go b/core/config/config_gen.go
index 6c59f1ec..1ce485c1 100644
--- a/core/config/config_gen.go
+++ b/core/config/config_gen.go
@@ -74,6 +74,7 @@ func generateFromStruct(t reflect.Type, v reflect.Value, indent int) string {
 		groupTag := field.Tag.Get("group")
 		descTag := field.Tag.Get("description")
 		defaultTag := field.Tag.Get("default")
+		optionalTag := field.Tag.Get("config_optional") == "true"
 
 		fieldType := field.Type
 		if fieldType.Kind() == reflect.Pointer {
@@ -108,7 +109,11 @@ func generateFromStruct(t reflect.Type, v reflect.Value, indent int) string {
 				b.WriteString(fmt.Sprintf("%s# %s\n", prefix, descTag))
 			}
 			val := formatValue(fieldType.Kind(), defaultTag)
-			b.WriteString(fmt.Sprintf("%s%s: %s\n", prefix, configTag, val))
+			if optionalTag {
+				b.WriteString(fmt.Sprintf("%s# %s: %s\n", prefix, configTag, val))
+			} else {
+				b.WriteString(fmt.Sprintf("%s%s: %s\n", prefix, configTag, val))
+			}
 		}
 	}
 	return b.String()
diff --git a/core/config/env.go b/core/config/env.go
index 1daebff5..709d1198 100644
--- a/core/config/env.go
+++ b/core/config/env.go
@@ -17,6 +17,9 @@ func ResolveRuntimeConfig(option *Option) (string, error) {
 	}
 	applyEnvironment(option, explicit, os.LookupEnv)
 	ApplyDefaults(option)
+	if _, err := ResolveOutputPolicy(option); err != nil {
+		return configPath, err
+	}
 	if strings.TrimSpace(option.DataDir) != "" {
 		SetDataDir(option.DataDir)
 	}
diff --git a/core/config/loader.go b/core/config/loader.go
index 3d6ae671..6729703f 100644
--- a/core/config/loader.go
+++ b/core/config/loader.go
@@ -148,6 +148,7 @@ func mergeOption(dst, src *Option) {
 	if !dst.SaveSession && src.SaveSession {
 		dst.SaveSession = true
 	}
+	mergeOutputOptions(&dst.OutputOptions, &src.OutputOptions)
 	dst.DataDir = ResolveString(dst.DataDir, src.DataDir)
 }
 
diff --git a/core/config/loader_test.go b/core/config/loader_test.go
index 3d100656..a4ff5e19 100644
--- a/core/config/loader_test.go
+++ b/core/config/loader_test.go
@@ -3,6 +3,7 @@ package config
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/chainreactors/aiscan/pkg/telemetry"
@@ -439,6 +440,17 @@ func TestInitDefaultConfig(t *testing.T) {
 	if err := LoadConfig(path, &opt); err != nil {
 		t.Errorf("generated config should be parseable: %v", err)
 	}
+	for _, want := range []string{
+		"output:",
+		"preset: \"default\"",
+		"# reasoning: \"hidden\"",
+		"# tool_results: \"hidden\"",
+		"# live_status: true",
+	} {
+		if !strings.Contains(content, want) {
+			t.Errorf("generated config missing %q", want)
+		}
+	}
 }
 
 func TestFullPriorityChain(t *testing.T) {
diff --git a/core/config/options.go b/core/config/options.go
index fefa656a..fd280825 100644
--- a/core/config/options.go
+++ b/core/config/options.go
@@ -17,6 +17,7 @@ type Option struct {
 	AgentOptions   `group:"Agent Options" config:"agent"`
 	IOAOptions     `group:"Server Options" config:"ioa"`
 	ReconOptions   `group:"Recon Options" config:"recon"`
+	OutputOptions  `group:"Agent Output Options" config:"output"`
 	MiscOptions    `group:"Miscellaneous Options" config:"misc"`
 	ScanConfig     ScanConfigOptions `no-flag:"true" config:"scan"`
 }
@@ -125,7 +126,7 @@ type MiscOptions struct {
 	ViewFormat string `short:"o" long:"output" description:"Output format for -F: terminal (default), markdown" default:"terminal"`
 	ViewOutput string `short:"f" long:"file" description:"Write -F output to file instead of stdout"`
 	Debug      bool   `long:"debug" config:"debug" description:"Enable debug logging"`
-	Verbose    []bool `short:"v" long:"verbose" description:"Increase verbosity (-v tools, -vv thinking)"`
+	Verbose    []bool `short:"v" long:"verbose" description:"Increase verbosity (-v thinking and tool previews, -vv full tool results)"`
 	Quiet      bool   `short:"q" long:"quiet" config:"quiet" description:"Quiet mode — only show final result"`
 	NoColor    bool   `long:"no-color" config:"no_color" description:"Disable ANSI colors in scanner output"`
 	Version    bool   `long:"version" description:"Print version and exit"`
diff --git a/core/config/output.go b/core/config/output.go
new file mode 100644
index 00000000..7d9029a5
--- /dev/null
+++ b/core/config/output.go
@@ -0,0 +1,220 @@
+package config
+
+import (
+	"fmt"
+	"strings"
+)
+
+type OutputOptions struct {
+	Preset        string `config:"preset" default:"default" description:"Output preset: default, verbose, or full"`
+	Reasoning     string `config:"reasoning" default:"hidden" config_optional:"true" description:"Reasoning output: hidden or full"`
+	ToolCalls     string `config:"tool_calls" default:"compact" config_optional:"true" description:"Tool call output: hidden or compact"`
+	ToolArguments string `config:"tool_arguments" default:"hidden" config_optional:"true" description:"Tool argument output: hidden, preview, or full"`
+	ToolResults   string `config:"tool_results" default:"hidden" config_optional:"true" description:"Tool result output: hidden, preview, or full"`
+	LiveStatus    *bool  `config:"live_status" default:"true" config_optional:"true" description:"Show the transient thinking/tooling/talking status"`
+	Usage         *bool  `config:"usage" default:"true" config_optional:"true" description:"Show token and context usage in the live status"`
+}
+
+type OutputDetail string
+
+const (
+	OutputDetailHidden  OutputDetail = "hidden"
+	OutputDetailPreview OutputDetail = "preview"
+	OutputDetailFull    OutputDetail = "full"
+)
+
+type OutputCalls string
+
+const (
+	OutputCallsHidden  OutputCalls = "hidden"
+	OutputCallsCompact OutputCalls = "compact"
+)
+
+type OutputPreset string
+
+const (
+	OutputPresetDefault OutputPreset = "default"
+	OutputPresetVerbose OutputPreset = "verbose"
+	OutputPresetFull    OutputPreset = "full"
+	OutputPresetQuiet   OutputPreset = "quiet"
+)
+
+type OutputPolicy struct {
+	Preset        OutputPreset
+	Reasoning     OutputDetail
+	ToolCalls     OutputCalls
+	ToolArguments OutputDetail
+	ToolResults   OutputDetail
+	LiveStatus    bool
+	Usage         bool
+	Custom        bool
+}
+
+func (p OutputPolicy) Quiet() bool {
+	return p.Preset == OutputPresetQuiet
+}
+
+func (p OutputPolicy) ShowReasoning() bool {
+	return !p.Quiet() && p.Reasoning == OutputDetailFull
+}
+
+func OutputPolicyForPreset(preset OutputPreset) OutputPolicy {
+	switch preset {
+	case OutputPresetVerbose:
+		return OutputPolicy{
+			Preset: preset, Reasoning: OutputDetailFull, ToolCalls: OutputCallsCompact,
+			ToolArguments: OutputDetailPreview, ToolResults: OutputDetailPreview,
+			LiveStatus: true, Usage: true,
+		}
+	case OutputPresetFull:
+		return OutputPolicy{
+			Preset: preset, Reasoning: OutputDetailFull, ToolCalls: OutputCallsCompact,
+			ToolArguments: OutputDetailPreview, ToolResults: OutputDetailFull,
+			LiveStatus: true, Usage: true,
+		}
+	case OutputPresetQuiet:
+		return OutputPolicy{
+			Preset: preset, Reasoning: OutputDetailHidden, ToolCalls: OutputCallsHidden,
+			ToolArguments: OutputDetailHidden, ToolResults: OutputDetailHidden,
+		}
+	default:
+		return OutputPolicy{
+			Preset: OutputPresetDefault, Reasoning: OutputDetailHidden, ToolCalls: OutputCallsCompact,
+			ToolArguments: OutputDetailHidden, ToolResults: OutputDetailHidden,
+			LiveStatus: true, Usage: true,
+		}
+	}
+}
+
+func OutputPolicyForLevel(level int) OutputPolicy {
+	switch {
+	case level < 0:
+		return OutputPolicyForPreset(OutputPresetQuiet)
+	case level == 1:
+		return OutputPolicyForPreset(OutputPresetVerbose)
+	case level >= 2:
+		return OutputPolicyForPreset(OutputPresetFull)
+	default:
+		return OutputPolicyForPreset(OutputPresetDefault)
+	}
+}
+
+func ResolveOutputPolicy(option *Option) (OutputPolicy, error) {
+	if option != nil {
+		if option.Quiet {
+			return OutputPolicyForPreset(OutputPresetQuiet), nil
+		}
+		if len(option.Verbose) > 0 {
+			return OutputPolicyForLevel(len(option.Verbose)), nil
+		}
+	}
+
+	opts := OutputOptions{}
+	if option != nil {
+		opts = option.OutputOptions
+	}
+	preset, err := parseOutputPreset(opts.Preset)
+	if err != nil {
+		return OutputPolicy{}, err
+	}
+	base := OutputPolicyForPreset(preset)
+	policy := base
+
+	if opts.Reasoning != "" {
+		policy.Reasoning, err = parseOutputDetail("reasoning", opts.Reasoning, false)
+		if err != nil {
+			return OutputPolicy{}, err
+		}
+	}
+	if opts.ToolCalls != "" {
+		policy.ToolCalls, err = parseOutputCalls(opts.ToolCalls)
+		if err != nil {
+			return OutputPolicy{}, err
+		}
+	}
+	if opts.ToolArguments != "" {
+		policy.ToolArguments, err = parseOutputDetail("tool_arguments", opts.ToolArguments, true)
+		if err != nil {
+			return OutputPolicy{}, err
+		}
+	}
+	if opts.ToolResults != "" {
+		policy.ToolResults, err = parseOutputDetail("tool_results", opts.ToolResults, true)
+		if err != nil {
+			return OutputPolicy{}, err
+		}
+	}
+	if opts.LiveStatus != nil {
+		policy.LiveStatus = *opts.LiveStatus
+	}
+	if opts.Usage != nil {
+		policy.Usage = *opts.Usage
+	}
+	policy.Custom = !outputPoliciesEqual(policy, base)
+	return policy, nil
+}
+
+func parseOutputPreset(value string) (OutputPreset, error) {
+	switch preset := OutputPreset(strings.ToLower(strings.TrimSpace(value))); preset {
+	case "", OutputPresetDefault:
+		return OutputPresetDefault, nil
+	case OutputPresetVerbose, OutputPresetFull:
+		return preset, nil
+	default:
+		return "", fmt.Errorf("output.preset must be default, verbose, or full, got %q", value)
+	}
+}
+
+func parseOutputDetail(field, value string, preview bool) (OutputDetail, error) {
+	detail := OutputDetail(strings.ToLower(strings.TrimSpace(value)))
+	if detail == OutputDetailHidden || detail == OutputDetailFull || (preview && detail == OutputDetailPreview) {
+		return detail, nil
+	}
+	allowed := "hidden or full"
+	if preview {
+		allowed = "hidden, preview, or full"
+	}
+	return "", fmt.Errorf("output.%s must be %s, got %q", field, allowed, value)
+}
+
+func parseOutputCalls(value string) (OutputCalls, error) {
+	calls := OutputCalls(strings.ToLower(strings.TrimSpace(value)))
+	if calls == OutputCallsHidden || calls == OutputCallsCompact {
+		return calls, nil
+	}
+	return "", fmt.Errorf("output.tool_calls must be hidden or compact, got %q", value)
+}
+
+func outputPoliciesEqual(a, b OutputPolicy) bool {
+	return a.Preset == b.Preset &&
+		a.Reasoning == b.Reasoning &&
+		a.ToolCalls == b.ToolCalls &&
+		a.ToolArguments == b.ToolArguments &&
+		a.ToolResults == b.ToolResults &&
+		a.LiveStatus == b.LiveStatus &&
+		a.Usage == b.Usage
+}
+
+func mergeOutputOptions(dst, src *OutputOptions) {
+	if dst.Preset == "" {
+		dst.Preset = src.Preset
+	}
+	if dst.Reasoning == "" {
+		dst.Reasoning = src.Reasoning
+	}
+	if dst.ToolCalls == "" {
+		dst.ToolCalls = src.ToolCalls
+	}
+	if dst.ToolArguments == "" {
+		dst.ToolArguments = src.ToolArguments
+	}
+	if dst.ToolResults == "" {
+		dst.ToolResults = src.ToolResults
+	}
+	if dst.LiveStatus == nil {
+		dst.LiveStatus = src.LiveStatus
+	}
+	if dst.Usage == nil {
+		dst.Usage = src.Usage
+	}
+}
diff --git a/core/config/output_test.go b/core/config/output_test.go
new file mode 100644
index 00000000..9e78df89
--- /dev/null
+++ b/core/config/output_test.go
@@ -0,0 +1,146 @@
+package config
+
+import "testing"
+
+func boolPtr(value bool) *bool { return &value }
+
+func TestOutputPresetPolicies(t *testing.T) {
+	tests := []struct {
+		name   string
+		option Option
+		want   OutputPolicy
+	}{
+		{name: "default", want: OutputPolicyForPreset(OutputPresetDefault)},
+		{
+			name:   "verbose CLI",
+			option: Option{MiscOptions: MiscOptions{Verbose: []bool{true}}},
+			want:   OutputPolicyForPreset(OutputPresetVerbose),
+		},
+		{
+			name:   "full CLI",
+			option: Option{MiscOptions: MiscOptions{Verbose: []bool{true, true}}},
+			want:   OutputPolicyForPreset(OutputPresetFull),
+		},
+		{
+			name:   "quiet CLI",
+			option: Option{MiscOptions: MiscOptions{Quiet: true, Verbose: []bool{true, true}}},
+			want:   OutputPolicyForPreset(OutputPresetQuiet),
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := ResolveOutputPolicy(&tc.option)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if !outputPoliciesEqual(got, tc.want) || got.Custom != tc.want.Custom {
+				t.Fatalf("policy = %#v, want %#v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestOutputConfigOverridesPreset(t *testing.T) {
+	option := Option{OutputOptions: OutputOptions{
+		Preset:        "verbose",
+		Reasoning:     "hidden",
+		ToolArguments: "full",
+		ToolResults:   "hidden",
+		LiveStatus:    boolPtr(false),
+		Usage:         boolPtr(false),
+	}}
+
+	got, err := ResolveOutputPolicy(&option)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got.Reasoning != OutputDetailHidden || got.ToolArguments != OutputDetailFull ||
+		got.ToolResults != OutputDetailHidden || got.LiveStatus || got.Usage || !got.Custom {
+		t.Fatalf("custom policy = %#v", got)
+	}
+}
+
+func TestOutputCLIOverridesEntireConfig(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		verbose []bool
+		preset  OutputPreset
+	}{
+		{name: "verbose", verbose: []bool{true}, preset: OutputPresetVerbose},
+		{name: "full", verbose: []bool{true, true}, preset: OutputPresetFull},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			option := Option{
+				OutputOptions: OutputOptions{
+					Preset: "default", Reasoning: "hidden", ToolCalls: "hidden",
+					ToolArguments: "full", ToolResults: "hidden",
+					LiveStatus: boolPtr(false), Usage: boolPtr(false),
+				},
+				MiscOptions: MiscOptions{Verbose: tc.verbose},
+			}
+
+			got, err := ResolveOutputPolicy(&option)
+			if err != nil {
+				t.Fatal(err)
+			}
+			want := OutputPolicyForPreset(tc.preset)
+			if !outputPoliciesEqual(got, want) || got.Custom {
+				t.Fatalf("CLI policy = %#v, want %#v", got, want)
+			}
+		})
+	}
+}
+
+func TestLoadedOutputPresetKeepsUnspecifiedPresetValues(t *testing.T) {
+	path := writeTestConfig(t, t.TempDir(), `
+output:
+  preset: verbose
+`)
+	var option Option
+	if err := LoadConfig(path, &option); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := ResolveOutputPolicy(&option)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := OutputPolicyForPreset(OutputPresetVerbose)
+	if !outputPoliciesEqual(got, want) || got.Custom {
+		t.Fatalf("loaded preset policy = %#v, want %#v", got, want)
+	}
+}
+
+func TestOutputPolicyRejectsInvalidValues(t *testing.T) {
+	tests := []OutputOptions{
+		{Preset: "debug"},
+		{Reasoning: "preview"},
+		{ToolCalls: "full"},
+		{ToolArguments: "compact"},
+		{ToolResults: "compact"},
+	}
+	for _, opts := range tests {
+		if _, err := ResolveOutputPolicy(&Option{OutputOptions: opts}); err == nil {
+			t.Fatalf("ResolveOutputPolicy(%#v) succeeded", opts)
+		}
+	}
+}
+
+func TestMergeOutputOptionsKeepsLocalValues(t *testing.T) {
+	dst := OutputOptions{Preset: "full", ToolResults: "hidden", LiveStatus: boolPtr(false)}
+	src := OutputOptions{
+		Preset: "verbose", Reasoning: "full", ToolCalls: "compact",
+		ToolArguments: "preview", ToolResults: "full",
+		LiveStatus: boolPtr(true), Usage: boolPtr(true),
+	}
+	mergeOutputOptions(&dst, &src)
+
+	if dst.Preset != "full" || dst.ToolResults != "hidden" || dst.LiveStatus == nil || *dst.LiveStatus {
+		t.Fatalf("local output values were overwritten: %#v", dst)
+	}
+	if dst.Reasoning != "full" || dst.ToolCalls != "compact" ||
+		dst.ToolArguments != "preview" || dst.Usage == nil || !*dst.Usage {
+		t.Fatalf("config output values were not merged: %#v", dst)
+	}
+}
diff --git a/docs/reference.md b/docs/reference.md
index cd9b0eec..9d429180 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -89,6 +89,16 @@ ioa:
   node_name: ""
   space: ""
 
+# Agent 交互输出
+output:
+  preset: "default"       # default、verbose 或 full
+  # reasoning: "hidden"   # hidden 或 full
+  # tool_calls: "compact" # hidden 或 compact
+  # tool_arguments: "hidden" # hidden、preview 或 full
+  # tool_results: "hidden"   # hidden、preview 或 full
+  # live_status: true      # thinking/tooling/talking 瞬时状态
+  # usage: true            # 瞬时状态中的 token/上下文用量
+
 # 扫描默认值
 scan:
   verify: ""          # auto, off, low, medium, high, critical
@@ -101,6 +111,25 @@ misc:
   no_color: false
 ```
 
+### Agent 输出
+
+`output.preset` 提供三组基线;未注释的细粒度字段会覆盖所选 preset:
+
+| 输出项 | `default` | `verbose` / `-v` | `full` / `-vv` |
+| --- | --- | --- | --- |
+| reasoning | hidden | full | full |
+| tool_calls | compact | compact | compact |
+| tool_arguments | hidden | preview | preview |
+| tool_results | hidden | preview | full |
+| live_status | true | true | true |
+| usage | true | true | true |
+
+默认输出只保留紧凑的工具调用摘要,不显示 reasoning、结构化参数或工具结果。`tool_calls: hidden` 是总开关,同时隐藏工具参数和结果。`live_status: false` 只关闭动态状态,仍按策略输出静态工具摘要;`usage: false` 只隐藏动态状态中的 token 和上下文用量,不产生或删除永久统计行。
+
+输出优先级为 `-q > -vv > -v > output 配置 > default`。`-q` 只显示最终回答;`-v` 和 `-vv` 会完整覆盖 `output` 中的 preset 和细粒度字段。此配置只影响 Agent 交互输出,不改变 scanner 输出或 `--debug` 日志,也不改变最终回答的 stdout 输出。
+
+交互模式下 `Ctrl+O` 按 `default → thinking → full → default` 循环固定 preset。当前为自定义细粒度配置时,第一次按键先切换到 `default`,之后再继续循环。
+
 ---
 
 ## 全局参数
@@ -158,7 +187,8 @@ misc:
 | 参数 | 说明 |
 | --- | --- |
 | `--debug` | 输出调试日志 |
-| `-q, --quiet` | 减少日志输出 |
+| `-v, --verbose` | 显示完整 reasoning 和预览后的工具参数/结果;重复为 `-vv`,显示完整工具结果 |
+| `-q, --quiet` | 只显示最终回答(优先于 `-v/-vv`) |
 | `--no-color` | 禁用 ANSI 颜色 |
 | `--version` | 输出版本号并退出 |
 
diff --git a/pkg/tui/keybindings.go b/pkg/tui/keybindings.go
index d9a7f3b3..4087dd98 100644
--- a/pkg/tui/keybindings.go
+++ b/pkg/tui/keybindings.go
@@ -128,10 +128,7 @@ func (r *AgentConsole) handleToggleVerbosity() {
 	if out == nil {
 		return
 	}
-	current := out.VerbosityLevel()
-	next := (current + 1) % 3
-	out.SetVerbosity(next)
-	label := out.VerbosityLabel()
+	label := out.CycleOutputPreset()
 	if out.color.Enabled {
 		fmt.Fprintf(r.stderr, "\n%s %s\n",
 			out.dim("verbosity:"),
diff --git a/pkg/tui/live.go b/pkg/tui/live.go
index 33af5a92..20735502 100644
--- a/pkg/tui/live.go
+++ b/pkg/tui/live.go
@@ -43,6 +43,7 @@ type LiveStatus struct {
 	outputEstimate int
 	contextTokens  int
 	contextWindow  int
+	showUsage      bool
 
 	tools map[string]*toolEvent
 	order []string
@@ -52,6 +53,12 @@ type LiveStatus struct {
 	renderToolLine func(*toolEvent) string
 }
 
+func (l *LiveStatus) SetUsageVisible(visible bool) {
+	if l != nil {
+		l.showUsage = visible
+	}
+}
+
 func NewLiveStatus(view *LiveView, dim func(string) string, renderToolLine func(*toolEvent) string) *LiveStatus {
 	if dim == nil {
 		dim = func(s string) string { return s }
@@ -62,6 +69,7 @@ func NewLiveStatus(view *LiveView, dim func(string) string, renderToolLine func(
 	return &LiveStatus{
 		view:           view,
 		status:         liveStatusThinking,
+		showUsage:      true,
 		tools:          make(map[string]*toolEvent),
 		dim:            dim,
 		renderToolLine: renderToolLine,
@@ -367,17 +375,19 @@ func (l *LiveStatus) formatTurnDetails() string {
 	if l.turnToolCalls > 0 {
 		parts = append(parts, fmt.Sprintf("tools=%d", l.turnToolCalls))
 	}
-	contextTokens := l.contextTokens
-	if l.turnUsage != nil {
-		parts = append(parts, formatTokenUsage(l.turnUsage))
-		if l.turnUsage.PromptTokens > 0 {
-			contextTokens = l.turnUsage.PromptTokens
+	if l.showUsage {
+		contextTokens := l.contextTokens
+		if l.turnUsage != nil {
+			parts = append(parts, formatTokenUsage(l.turnUsage))
+			if l.turnUsage.PromptTokens > 0 {
+				contextTokens = l.turnUsage.PromptTokens
+			}
+		} else if l.outputEstimate > 0 {
+			parts = append(parts, outputTokenMarker+"≈"+util.FormatNumber(l.outputEstimate))
+		}
+		if context := l.ContextUsage(contextTokens); context != "" {
+			parts = append(parts, context)
 		}
-	} else if l.outputEstimate > 0 {
-		parts = append(parts, outputTokenMarker+"≈"+util.FormatNumber(l.outputEstimate))
-	}
-	if context := l.ContextUsage(contextTokens); context != "" {
-		parts = append(parts, context)
 	}
 	parts = append(parts, elapsedSentinel)
 	return "[" + strings.Join(parts, " | ") + "]"
diff --git a/pkg/tui/output.go b/pkg/tui/output.go
index 19c832d8..654a826a 100644
--- a/pkg/tui/output.go
+++ b/pkg/tui/output.go
@@ -48,6 +48,7 @@ type AgentOutput struct {
 	color     output.Color
 	debug     bool
 	verbosity int
+	policy    cfg.OutputPolicy
 
 	stream  *StreamWriter
 	aborted bool
@@ -111,20 +112,20 @@ func newAgentOutputWithWriters(option *cfg.Option, stdout, stderr io.Writer, ter
 
 func newAgentOutput(option *cfg.Option, stdout, stderr io.Writer, stdoutTTY, stderrTTY bool, mode RenderMode) *AgentOutput {
 	debug := false
-	verbosity := 0
 	noColor := false
 	model := ""
 	contextWindow := 0
 	if option != nil {
 		debug = option.Debug
-		verbosity = len(option.Verbose)
-		if option.Quiet {
-			verbosity = -1
-		}
 		noColor = option.NoColor
 		model = option.Model
 		contextWindow = option.ContextWindow
 	}
+	policy, err := cfg.ResolveOutputPolicy(option)
+	if err != nil {
+		policy = cfg.OutputPolicyForPreset(cfg.OutputPresetDefault)
+	}
+	verbosity := outputPolicyLevel(policy)
 	useColor := !noColor && stderrTTY
 	color := output.NewColor(useColor)
 	lv := NewLiveView(stderr, color.Code(output.ANSICyan))
@@ -132,12 +133,14 @@ func newAgentOutput(option *cfg.Option, stdout, stderr io.Writer, stdoutTTY, std
 		color:     color,
 		debug:     debug,
 		verbosity: verbosity,
-		stream:    NewStreamWriter(stdout, stderr, stdoutTTY, !noColor && stdoutTTY, color, verbosity),
+		policy:    policy,
+		stream:    NewStreamWriter(stdout, stderr, stdoutTTY, !noColor && stdoutTTY, color, policy.ShowReasoning()),
 		mode:      mode,
 		tty:       stderrTTY,
 		deltas:    make(map[string]*deltaAccumulator),
 	}
 	o.live = NewLiveStatus(lv, o.dim, o.renderToolLine)
+	o.live.SetUsageVisible(policy.Usage)
 	if contextWindow <= 0 {
 		contextWindow = agent.ModelContextWindow(model)
 	}
@@ -188,8 +191,21 @@ func (o *AgentOutput) SetVerbosity(level int) {
 	}
 	o.mu.Lock()
 	defer o.mu.Unlock()
-	o.verbosity = level
-	o.stream.verbosity = level
+	o.applyOutputPolicyLocked(cfg.OutputPolicyForLevel(level))
+}
+
+func (o *AgentOutput) CycleOutputPreset() string {
+	if o == nil {
+		return "default"
+	}
+	o.mu.Lock()
+	defer o.mu.Unlock()
+	next := 0
+	if !o.policy.Custom {
+		next = (o.verbosity + 1) % 3
+	}
+	o.applyOutputPolicyLocked(cfg.OutputPolicyForLevel(next))
+	return o.outputLabelLocked()
 }
 
 func (o *AgentOutput) VerbosityLevel() int {
@@ -202,18 +218,54 @@ func (o *AgentOutput) VerbosityLevel() int {
 }
 
 func (o *AgentOutput) VerbosityLabel() string {
-	switch o.VerbosityLevel() {
+	if o == nil {
+		return "default"
+	}
+	o.mu.Lock()
+	defer o.mu.Unlock()
+	return o.outputLabelLocked()
+}
+
+func (o *AgentOutput) outputLabelLocked() string {
+	if o.policy.Custom {
+		return "custom"
+	}
+	switch o.verbosity {
 	case -1:
 		return "quiet"
 	case 0:
 		return "default"
 	case 1:
-		return "tools"
-	default:
 		return "thinking"
+	default:
+		return "full"
 	}
 }
 
+func (o *AgentOutput) applyOutputPolicyLocked(policy cfg.OutputPolicy) {
+	o.policy = policy
+	o.verbosity = outputPolicyLevel(policy)
+	o.stream.SetReasoning(policy.ShowReasoning())
+	o.live.SetUsageVisible(policy.Usage)
+}
+
+func outputPolicyLevel(policy cfg.OutputPolicy) int {
+	switch policy.Preset {
+	case cfg.OutputPresetQuiet:
+		return -1
+	case cfg.OutputPresetVerbose:
+		return 1
+	case cfg.OutputPresetFull:
+		return 2
+	default:
+		return 0
+	}
+}
+
+func (o *AgentOutput) quiet() bool {
+	return o == nil || o.policy.Quiet()
+}
+
 // ---------------------------------------------------------------------------
 // Lifecycle
 // ---------------------------------------------------------------------------
@@ -227,7 +279,7 @@ func (o *AgentOutput) Start(label, text string) {
 	o.stopLive()
 	o.stream.Flush()
 	o.beginRun()
-	if o.verbosity < 0 {
+	if o.quiet() {
 		return
 	}
 	label = strings.TrimSpace(label)
@@ -250,7 +302,7 @@ func (o *AgentOutput) Start(label, text string) {
 }
 
 func (o *AgentOutput) Empty() {
-	if o == nil || o.verbosity < 0 {
+	if o == nil || o.quiet() {
 		return
 	}
 	o.mu.Lock()
@@ -296,7 +348,7 @@ func (o *AgentOutput) SetInbox(items []string) {
 }
 
 func (o *AgentOutput) Stopping() {
-	if o == nil || o.verbosity < 0 {
+	if o == nil || o.quiet() {
 		return
 	}
 	o.mu.Lock()
@@ -306,7 +358,7 @@ func (o *AgentOutput) Stopping() {
 }
 
 func (o *AgentOutput) Stopped() {
-	if o == nil || o.verbosity < 0 {
+	if o == nil || o.quiet() {
 		return
 	}
 	o.mu.Lock()
@@ -410,7 +462,7 @@ func (o *AgentOutput) HandleEvent(event aop.Event) {
 		o.live.SetOutputEstimate(estimateStreamTokens(acc.text, acc.reasoning))
 		contentDelta := o.stream.WouldPrintContentDelta(&acc.text)
 		visible := o.stream.WouldPrintDelta(&acc.text, &acc.reasoning)
-		if o.verbosity >= 0 {
+		if !o.quiet() {
 			writeDelta := func() {
 				o.stream.Delta(&acc.text, &acc.reasoning)
 			}
@@ -457,6 +509,9 @@ func (o *AgentOutput) HandleEvent(event aop.Event) {
 		}
 		o.turnToolCalls++
 		o.live.SetTurnToolCalls(o.turnToolCalls)
+		if o.policy.ToolCalls == cfg.OutputCallsHidden || o.quiet() {
+			return
+		}
 		ev := &toolEvent{
 			id:        data.ToolCallID,
 			name:      data.ToolName,
@@ -472,14 +527,14 @@ func (o *AgentOutput) HandleEvent(event aop.Event) {
 		} else {
 			o.live.Stop()
 			o.stream.Flush()
-			if o.verbosity >= 0 {
+			if !o.quiet() {
 				name := toolNameOrDefault(ev)
 				w := o.Stderr()
 				fmt.Fprintln(w)
 				fmt.Fprintf(w, "%s%s\n", toolBlockIndent,
 					o.color.Wrap("▸", output.ANSICyan)+" "+o.bold(name)+"  "+
 						o.dim(truncate.Clip(summarizeToolArguments(name, ev.args), 80)))
-				if o.verbosity >= 1 {
+				if o.policy.ToolArguments != cfg.OutputDetailHidden {
 					o.printToolArgBlock(w, name, ev.args)
 				}
 				if o.debug {
@@ -499,6 +554,9 @@ func (o *AgentOutput) HandleEvent(event aop.Event) {
 		if data.IsError {
 			o.toolErrorCount++
 		}
+		if o.policy.ToolCalls == cfg.OutputCallsHidden || o.quiet() {
+			return
+		}
 		ev := &toolEvent{
 			id:      data.ToolCallID,
 			name:    data.ToolName,
@@ -513,11 +571,11 @@ func (o *AgentOutput) HandleEvent(event aop.Event) {
 			}
 		} else {
 			o.stopLive()
-			if o.verbosity >= 0 {
+			if !o.quiet() {
 				w := o.Stderr()
 				fmt.Fprintln(w)
 				fmt.Fprintln(w, o.renderToolLine(ev))
-				if o.verbosity >= 1 {
+				if o.policy.ToolResults != cfg.OutputDetailHidden {
 					o.printToolDetail(w, ev)
 				}
 			}
@@ -541,7 +599,9 @@ func (o *AgentOutput) HandleEvent(event aop.Event) {
 		o.totalUsage.TotalTokens += usage.TotalTokens
 		o.totalUsage.CacheReadTokens += usage.CacheReadTokens
 		o.totalUsage.CacheWriteTokens += usage.CacheWriteTokens
-		o.live.SetTurnUsage(usage)
+		if o.policy.Usage {
+			o.live.SetTurnUsage(usage)
+		}
 		if o.canAnimate() {
 			o.live.Render()
 		}
@@ -606,7 +666,7 @@ func estimateStreamTokens(parts ...string) int {
 // ---------------------------------------------------------------------------
 
 func (o *AgentOutput) canAnimate() bool {
-	if o == nil || o.mode != ModeInteractive || !o.tty || o.verbosity < 0 {
+	if o == nil || o.mode != ModeInteractive || !o.tty || o.quiet() || !o.policy.LiveStatus {
 		return false
 	}
 	if o.readline {
@@ -649,8 +709,11 @@ func (o *AgentOutput) printToolDetail(w io.Writer, ev *toolEvent) {
 	name := toolNameOrDefault(ev)
 	if ev.isError {
 		if errText := strings.TrimSpace(ev.result); errText != "" {
+			if o.policy.ToolResults != cfg.OutputDetailFull {
+				errText = truncate.Clip(errText, agentStatusPreviewLimit)
+			}
 			fmt.Fprintf(w, "%s%s\n", toolResultIndent,
-				o.color.Wrap(truncate.Clip(errText, agentStatusPreviewLimit), output.ANSIRed))
+				o.color.Wrap(errText, output.ANSIRed))
 		}
 		return
 	}
@@ -659,7 +722,7 @@ func (o *AgentOutput) printToolDetail(w io.Writer, ev *toolEvent) {
 		return
 	}
 	var preview toolResultPreview
-	if o.verbosity >= 2 {
+	if o.policy.ToolResults == cfg.OutputDetailFull {
 		preview = toolResultPreview{lines: normalizeToolResultLines(result)}
 	} else {
 		preview = buildToolResultPreview(name, result, o.debug)
@@ -687,6 +750,10 @@ func (o *AgentOutput) printToolDetail(w io.Writer, ev *toolEvent) {
 }
 
 func (o *AgentOutput) printToolArgBlock(w io.Writer, name, arguments string) {
+	if o.policy.ToolArguments == cfg.OutputDetailFull {
+		o.printFullToolArguments(w, arguments)
+		return
+	}
 	lines := formatToolArguments(name, arguments)
 	if len(lines) == 0 {
 		return
@@ -703,6 +770,22 @@ func (o *AgentOutput) printToolArgBlock(w io.Writer, name, arguments string) {
 	}
 }
 
+func (o *AgentOutput) printFullToolArguments(w io.Writer, arguments string) {
+	var decoded any
+	if err := json.Unmarshal([]byte(arguments), &decoded); err != nil {
+		fmt.Fprintf(w, "%s%s\n", toolArgIndent, arguments)
+		return
+	}
+	pretty, err := json.MarshalIndent(decoded, "", "  ")
+	if err != nil {
+		fmt.Fprintf(w, "%s%s\n", toolArgIndent, arguments)
+		return
+	}
+	for _, line := range strings.Split(string(pretty), "\n") {
+		fmt.Fprintf(w, "%s%s\n", toolArgIndent, line)
+	}
+}
+
 func (o *AgentOutput) printPermanentTools(events []*toolEvent) {
 	if len(events) == 0 {
 		return
@@ -711,7 +794,10 @@ func (o *AgentOutput) printPermanentTools(events []*toolEvent) {
 	fmt.Fprintln(w)
 	for _, event := range events {
 		fmt.Fprintln(w, o.renderToolLine(event))
-		if o.verbosity >= 1 {
+		if o.policy.ToolArguments != cfg.OutputDetailHidden {
+			o.printToolArgBlock(w, toolNameOrDefault(event), event.args)
+		}
+		if o.policy.ToolResults != cfg.OutputDetailHidden {
 			o.printToolDetail(w, event)
 		}
 	}
@@ -764,13 +850,13 @@ func (o *AgentOutput) coloredElapsed(started time.Time) string {
 // ---------------------------------------------------------------------------
 
 func (o *AgentOutput) turnEnd(turn int) {
-	if o.verbosity < 0 {
+	if o.quiet() {
 		return
 	}
 	o.stream.Flush()
 	w := o.Stderr()
 
-	if o.verbosity >= 2 && o.stream.ReasoningPrinted() == 0 {
+	if o.policy.ShowReasoning() && o.stream.ReasoningPrinted() == 0 {
 		if reasoning := strings.TrimSpace(messagePartText(o.lastAssistant, aop.PartReasoning)); reasoning != "" {
 			o.renderThinkingBlock(w, reasoning)
 		}
diff --git a/pkg/tui/output_test.go b/pkg/tui/output_test.go
index 553518e1..5889e3f8 100644
--- a/pkg/tui/output_test.go
+++ b/pkg/tui/output_test.go
@@ -118,14 +118,17 @@ func usageEvent(input, outputTok, total int) aop.Event {
 func testOutput(stderr io.Writer, verbosity int, debug bool) *AgentOutput {
 	stdout := &bytes.Buffer{}
 	color := output.NewColor(false)
+	policy := cfg.OutputPolicyForLevel(verbosity)
 	o := &AgentOutput{
 		color:     color,
 		debug:     debug,
 		verbosity: verbosity,
-		stream:    NewStreamWriter(stdout, stderr, true, false, color, verbosity),
+		policy:    policy,
+		stream:    NewStreamWriter(stdout, stderr, true, false, color, policy.ShowReasoning()),
 		deltas:    make(map[string]*deltaAccumulator),
 	}
 	o.live = NewLiveStatus(NewLiveView(stderr, ""), o.dim, o.renderToolLine)
+	o.live.SetUsageVisible(policy.Usage)
 	return o
 }
 
@@ -157,7 +160,8 @@ func TestAgentOutputFinalWritesPlainMarkdownWithoutWrapper(t *testing.T) {
 	color := output.NewColor(false)
 	o := &AgentOutput{
 		color:  color,
-		stream: NewStreamWriter(&stdout, &bytes.Buffer{}, true, false, color, 0),
+		policy: cfg.OutputPolicyForPreset(cfg.OutputPresetDefault),
+		stream: NewStreamWriter(&stdout, &bytes.Buffer{}, true, false, color, false),
 		deltas: make(map[string]*deltaAccumulator),
 	}
 	o.live = NewLiveStatus(NewLiveView(&bytes.Buffer{}, ""), o.dim, o.renderToolLine)
@@ -304,6 +308,31 @@ func TestThinkingLineShowsTurnUsage(t *testing.T) {
 	}
 }
 
+func TestLiveStatusCanHideUsageDetails(t *testing.T) {
+	var stdout bytes.Buffer
+	var stderr syncedBuffer
+	showUsage := false
+	o := NewAgentOutputWithWriters(&cfg.Option{
+		LLMOptions:    cfg.LLMOptions{Model: "gpt-4"},
+		OutputOptions: cfg.OutputOptions{Usage: &showUsage},
+	}, &stdout, &stderr, true)
+	defer o.live.Stop()
+
+	o.HandleEvent(turnStartEvent(1))
+	o.HandleEvent(usageEvent(4096, 50, 4146))
+	o.HandleEvent(textDeltaEvent("m-1", "12345678"))
+
+	got := stripANSI(stderr.String())
+	if !strings.Contains(got, "thinking") || !strings.Contains(got, "turn 1") {
+		t.Fatalf("live status itself was hidden: %q", got)
+	}
+	for _, hidden := range []string{"↑", "↓", "◐", "4,096/8,192"} {
+		if strings.Contains(got, hidden) {
+			t.Fatalf("usage detail %q leaked into live status: %q", hidden, got)
+		}
+	}
+}
+
 func TestThinkingLineShowsChangingStreamTokenEstimate(t *testing.T) {
 	var stdout bytes.Buffer
 	var stderr syncedBuffer
@@ -578,7 +607,7 @@ func TestThinkingVerboseStreamsReasoningWithoutTags(t *testing.T) {
 	var stdout bytes.Buffer
 	var stderr syncedBuffer
 	o := NewAgentOutputWithWriters(&cfg.Option{
-		MiscOptions: cfg.MiscOptions{Verbose: []bool{true, true}},
+		MiscOptions: cfg.MiscOptions{Verbose: []bool{true}},
 	}, &stdout, &stderr, true)
 	defer o.live.Stop()
 
@@ -605,7 +634,7 @@ func TestThinkingVerboseStreamsOnlyReasoningDelta(t *testing.T) {
 	var stdout bytes.Buffer
 	var stderr syncedBuffer
 	o := NewAgentOutputWithWriters(&cfg.Option{
-		MiscOptions: cfg.MiscOptions{Verbose: []bool{true, true}},
+		MiscOptions: cfg.MiscOptions{Verbose: []bool{true}},
 	}, &stdout, &stderr, true)
 	defer o.live.Stop()
 
@@ -636,7 +665,7 @@ func TestReadlineThinkingAppendsWithoutSyntheticNewlines(t *testing.T) {
 		redraw: func() {},
 	}
 	o := NewAgentOutputWithWriters(&cfg.Option{
-		MiscOptions: cfg.MiscOptions{Verbose: []bool{true, true}},
+		MiscOptions: cfg.MiscOptions{Verbose: []bool{true}},
 	}, &stdout, &stderr, true)
 	o.SetReadlineMode(bridge, bridge.UpdateStatus)
 	defer o.live.Stop()
@@ -751,7 +780,7 @@ func TestReadlineCommitsFinalTextForImageResponse(t *testing.T) {
 
 func TestThinkingBlockFinalRenderingHasNoTags(t *testing.T) {
 	var stderr syncedBuffer
-	o := testOutput(&stderr, 2, false)
+	o := testOutput(&stderr, 1, false)
 	reasoning := "checking target scope\nprobing admin route"
 
 	o.HandleEvent(turnStartEvent(1))
@@ -860,6 +889,151 @@ func TestAgentOutputMultiLineResult(t *testing.T) {
 	}
 }
 
+func TestAgentOutputFullResultIsNotTruncated(t *testing.T) {
+	var stderr syncedBuffer
+	o := testOutput(&stderr, 2, false)
+	result := strings.Join([]string{
+		"line1", "line2", "line3", "line4", "line5", "line6", "line7", "line8", "line9", "line10",
+		"line11", "line12", "line13", "line14", "line15", "line16", "line17", "line18", "line19", "line20",
+	}, "\n")
+
+	o.HandleEvent(toolResultEvent("call-1", "bash", result, false))
+	got := stripANSI(stderr.String())
+	if !strings.Contains(got, "line20") || strings.Contains(got, "lines hidden") {
+		t.Fatalf("full result was truncated: %q", got)
+	}
+}
+
+func TestAgentOutputDefaultKeepsToolOutputCompact(t *testing.T) {
+	var stderr syncedBuffer
+	o := testOutput(&stderr, 0, false)
+
+	o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo compact"}`))
+	o.HandleEvent(toolResultEvent("call-1", "bash", "sensitive result body", false))
+	got := stripANSI(stderr.String())
+	if !strings.Contains(got, "bash") || !strings.Contains(got, "echo compact") {
+		t.Fatalf("compact summary missing: %q", got)
+	}
+	if strings.Contains(got, "command  ") || strings.Contains(got, "sensitive result body") {
+		t.Fatalf("default output leaked tool detail: %q", got)
+	}
+}
+
+func TestAgentOutputWithoutLiveStatusKeepsStaticToolSummaries(t *testing.T) {
+	var stdout bytes.Buffer
+	var stderr syncedBuffer
+	showLive := false
+	o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{
+		LiveStatus: &showLive,
+	}}, &stdout, &stderr, true)
+
+	o.HandleEvent(turnStartEvent(1))
+	o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo compact"}`))
+	o.HandleEvent(toolResultEvent("call-1", "bash", "hidden result body", false))
+
+	got := stripANSI(stderr.String())
+	if o.canAnimate() || liveRunning(o.live) {
+		t.Fatal("live status remained active after output.live_status=false")
+	}
+	if !strings.Contains(got, "bash") || !strings.Contains(got, "echo compact") || !strings.Contains(got, "✓") {
+		t.Fatalf("static compact tool summary missing: %q", got)
+	}
+	if strings.Contains(got, "thinking") || strings.Contains(got, "hidden result body") {
+		t.Fatalf("disabled live status rendered transient or detailed output: %q", got)
+	}
+}
+
+func TestAgentOutputSeparatesReasoningAndFinalAnswerStreams(t *testing.T) {
+	var stdout bytes.Buffer
+	var stderr syncedBuffer
+	o := NewAgentOutputWithWriters(&cfg.Option{
+		MiscOptions: cfg.MiscOptions{Verbose: []bool{true}},
+	}, &stdout, &stderr, true)
+	defer o.live.Stop()
+
+	reasoning := "reasoning-stream-only"
+	answer := "final-answer-stream-only"
+	o.HandleEvent(turnStartEvent(1))
+	o.HandleEvent(reasoningDeltaEvent("m-1", reasoning))
+	o.HandleEvent(textDeltaEvent("m-1", answer+"\n\n"))
+	o.HandleEvent(messageEvent("m-1", "assistant",
+		aop.MessagePart{Type: aop.PartReasoning, Text: reasoning},
+		aop.MessagePart{Type: aop.PartText, Text: answer},
+	))
+	o.HandleEvent(turnEndEvent(1, 0))
+
+	stdoutText := stripANSI(stdout.String())
+	stderrText := stripANSI(stderr.String())
+	if !strings.Contains(stdoutText, answer) || strings.Contains(stdoutText, reasoning) {
+		t.Fatalf("stdout mixed agent streams: %q", stdoutText)
+	}
+	if !strings.Contains(stderrText, reasoning) || strings.Contains(stderrText, answer) {
+		t.Fatalf("stderr mixed agent streams: %q", stderrText)
+	}
+}
+
+func TestAgentOutputCustomPolicyControlsEachToolSection(t *testing.T) {
+	var stdout bytes.Buffer
+	var stderr syncedBuffer
+	show := false
+	o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{
+		Reasoning:     "full",
+		ToolCalls:     "compact",
+		ToolArguments: "full",
+		ToolResults:   "hidden",
+		LiveStatus:    &show,
+		Usage:         &show,
+	}}, &stdout, &stderr, true)
+
+	o.HandleEvent(turnStartEvent(1))
+	o.HandleEvent(reasoningDeltaEvent("m-1", "custom reasoning"))
+	o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo a very long custom command"}`))
+	o.HandleEvent(toolResultEvent("call-1", "bash", "hidden result", false))
+
+	got := stripANSI(stderr.String())
+	for _, want := range []string{"custom reasoning", "echo a very long custom command"} {
+		if !strings.Contains(got, want) {
+			t.Fatalf("custom output missing %q: %q", want, got)
+		}
+	}
+	if strings.Contains(got, "hidden result") {
+		t.Fatalf("custom output included hidden result: %q", got)
+	}
+	if o.VerbosityLabel() != "custom" || o.canAnimate() {
+		t.Fatalf("custom state label=%q animate=%v", o.VerbosityLabel(), o.canAnimate())
+	}
+}
+
+func TestAgentOutputHiddenToolsSuppressesArgumentsAndResults(t *testing.T) {
+	var stdout bytes.Buffer
+	var stderr syncedBuffer
+	o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{
+		ToolCalls: "hidden", ToolArguments: "full", ToolResults: "full",
+	}}, &stdout, &stderr, false)
+
+	o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"echo hidden"}`))
+	o.HandleEvent(toolResultEvent("call-1", "bash", "hidden result", false))
+	if got := stripANSI(stderr.String()); strings.Contains(got, "echo hidden") || strings.Contains(got, "hidden result") {
+		t.Fatalf("hidden tool output was rendered: %q", got)
+	}
+}
+
+func TestAgentOutputCustomPresetCycleStartsAtDefault(t *testing.T) {
+	show := false
+	o := NewAgentOutputWithWriters(&cfg.Option{OutputOptions: cfg.OutputOptions{
+		Reasoning: "full", LiveStatus: &show,
+	}}, &bytes.Buffer{}, &bytes.Buffer{}, false)
+
+	if got := o.VerbosityLabel(); got != "custom" {
+		t.Fatalf("initial label = %q, want custom", got)
+	}
+	for _, want := range []string{"default", "thinking", "full", "default"} {
+		if got := o.CycleOutputPreset(); got != want {
+			t.Fatalf("cycle label = %q, want %q", got, want)
+		}
+	}
+}
+
 func TestFormatToolArguments(t *testing.T) {
 	tests := []struct {
 		name      string
diff --git a/pkg/tui/stream.go b/pkg/tui/stream.go
index 5bb7c232..173c39c0 100644
--- a/pkg/tui/stream.go
+++ b/pkg/tui/stream.go
@@ -17,7 +17,7 @@ type StreamWriter struct {
 	enabled   bool
 	markdown  bool
 	color     output.Color
-	verbosity int
+	reasoning bool
 
 	printed    int    // content bytes flushed
 	buf        string // paragraph buffer
@@ -29,14 +29,20 @@ type StreamWriter struct {
 	streamed   bool   // any content was streamed this turn
 }
 
-func NewStreamWriter(stdout, stderr io.Writer, enabled, markdown bool, color output.Color, verbosity int) *StreamWriter {
+func NewStreamWriter(stdout, stderr io.Writer, enabled, markdown bool, color output.Color, reasoning bool) *StreamWriter {
 	return &StreamWriter{
 		stdout:    stdout,
 		stderr:    stderr,
 		enabled:   enabled,
 		markdown:  markdown,
 		color:     color,
-		verbosity: verbosity,
+		reasoning: reasoning,
+	}
+}
+
+func (w *StreamWriter) SetReasoning(enabled bool) {
+	if w != nil {
+		w.reasoning = enabled
 	}
 }
 
@@ -49,7 +55,7 @@ func (w *StreamWriter) Delta(content, reasoning *string) {
 	// Reasoning: stream incrementally to stderr in dim. This avoids repainting
 	// long wrapped lines in the live view, which terminals cannot erase reliably
 	// without width-aware row accounting.
-	if w.verbosity >= 2 && reasoning != nil {
+	if w.reasoning && reasoning != nil {
 		w.reasonFull = *reasoning
 		if len(w.reasonFull) > w.reasonPrt {
 			if !w.reasonOpen {
@@ -96,7 +102,7 @@ func (w *StreamWriter) WouldPrintDelta(content, reasoning *string) bool {
 	if w == nil || !w.enabled || w.stdout == nil {
 		return false
 	}
-	if w.verbosity >= 2 && reasoning != nil {
+	if w.reasoning && reasoning != nil {
 		if len(*reasoning) > w.reasonPrt {
 			return true
 		}

From b0806502aa312e856e10cafcdd38fdb92bc7e91c Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Sun, 26 Jul 2026 15:42:36 +0800
Subject: [PATCH 121/348] fix(ci): stabilize proxy and PTY timing tests

---
 pkg/commands/bash_test.go  | 4 +++-
 pkg/webagent/agent_test.go | 2 +-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/pkg/commands/bash_test.go b/pkg/commands/bash_test.go
index e447d65c..cdd41c81 100644
--- a/pkg/commands/bash_test.go
+++ b/pkg/commands/bash_test.go
@@ -194,7 +194,9 @@ func TestBashProxyEnvInjection(t *testing.T) {
 	proxy := "socks5://127.0.0.1:1080"
 	bash := commands.NewBashTool(t.TempDir(), 5).WithScannerProxy(proxy)
 
-	res, err := bash.Execute(context.Background(), bashArgs("env"))
+	res, err := bash.Execute(context.Background(), bashArgs(
+		`env | grep -E '^(ALL_PROXY|all_proxy|HTTP_PROXY|http_proxy|HTTPS_PROXY|https_proxy)='`,
+	))
 	if err != nil {
 		t.Fatalf("bash env: %v", err)
 	}
diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go
index defcdf1d..630acc9b 100644
--- a/pkg/webagent/agent_test.go
+++ b/pkg/webagent/agent_test.go
@@ -423,7 +423,7 @@ func TestRunConnectionPushesPTYSessionsOnManagerEvents(t *testing.T) {
 	}))
 	defer srv.Close()
 
-	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 	defer cancel()
 
 	reg := commands.NewRegistry()

From 40488b3a4108efe1cc7c90fe93890edbd0989516 Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Tue, 28 Jul 2026 14:06:42 +0800
Subject: [PATCH 122/348] fix(llm): converge provider configuration

---
 README.md                           |  4 +--
 README_CN.md                        |  4 +--
 core/config/options.go              |  2 +-
 docs/mechanisms.md                  | 14 +++++---
 docs/reference.md                   | 29 ++++++++-------
 pkg/agent/compact_test.go           |  4 +--
 pkg/agent/probe/llm.go              | 25 ++++++++-----
 pkg/agent/provider/provider.go      | 52 +++++++++++++++++++++------
 pkg/agent/provider/provider_test.go | 55 +++++++++++++++++++++++++++++
 pkg/agent/retry.go                  | 25 +++++++++----
 pkg/agent/retry_test.go             | 39 ++++++++++++++++++--
 pkg/web/config_profiles_test.go     | 33 ++++++++++++++++-
 pkg/web/llm_probe_test.go           | 47 ++++++++++++++++++++++++
 pkg/web/probe.go                    | 19 +++++++---
 pkg/web/validation.go               | 16 +++++++--
 15 files changed, 307 insertions(+), 61 deletions(-)

diff --git a/README.md b/README.md
index ec1edfc3..af3e91b6 100644
--- a/README.md
+++ b/README.md
@@ -187,7 +187,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \
 export OPENAI_API_KEY="sk-..."
 
 # CLI arguments
-aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat
+aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat
 ```
 
 Config file `aiscan.yaml`:
@@ -201,7 +201,7 @@ llm:
   max_tokens: 16384        # Maximum output per response
 ```
 
-The request output limit is dynamically clamped to the remaining context: `min(max_tokens, context_window - current_context - 4096)`. Automatic compaction starts as the context approaches the configured window.
+`context_window` is a literal token count: use `128000`, not `128K`. Values below 8192 are accepted, but the Web UI warns that they may be too small. The request output limit is dynamically clamped to the remaining context: `min(max_tokens, context_window - current_context - 4096)`. If no output space remains, AIScan returns a clear error instead of sending a one-token request. Automatic compaction starts as the context approaches the configured window.
 
 ---
 
diff --git a/README_CN.md b/README_CN.md
index 04ee07c1..034be639 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -186,7 +186,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \
 export OPENAI_API_KEY="sk-..."
 
 # CLI 参数
-aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat
+aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat
 ```
 
 配置文件 `aiscan.yaml`:
@@ -200,7 +200,7 @@ llm:
   max_tokens: 16384        # 单次最大输出
 ```
 
-实际请求的输出上限会按剩余上下文自动收紧:`min(max_tokens, context_window - 当前上下文 - 4096)`。上下文接近配置窗口时会自动压缩。
+`context_window` 填写真实 Token 数,例如 `128000`,不要写 `128K`。小于 8192 的值可以保存,但 Web 页面会提示窗口可能过小。实际请求的输出上限会按剩余上下文自动收紧:`min(max_tokens, context_window - 当前上下文 - 4096)`;如果已没有输出空间,AIScan 会返回明确错误,而不是发送只允许输出 1 Token 的请求。上下文接近配置窗口时会自动压缩。
 
 ---
 
diff --git a/core/config/options.go b/core/config/options.go
index fd280825..515ab2a9 100644
--- a/core/config/options.go
+++ b/core/config/options.go
@@ -27,7 +27,7 @@ type ScanConfigOptions struct {
 }
 
 type LLMOptions struct {
-	Provider      string             `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot"`
+	Provider      string             `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot, zhipu"`
 	BaseURL       string             `long:"base-url" config:"base_url" description:"LLM API base URL (leave empty to use provider default)"`
 	APIKey        string             `long:"api-key" config:"api_key" description:"LLM API key (or env: OPENAI_API_KEY, ANTHROPIC_API_KEY, AISCAN_API_KEY)"`
 	Model         string             `long:"model" config:"model" description:"LLM model name"`
diff --git a/docs/mechanisms.md b/docs/mechanisms.md
index a86cfbc6..e61538ac 100644
--- a/docs/mechanisms.md
+++ b/docs/mechanisms.md
@@ -113,12 +113,12 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事
 ### LLM 探活
 
 - `TestLLM`: 发 `maxTokens=16` 的 "ping" completion 验证连通性
-- `ListLLMModels`: 调用 provider 的 `GET /models` 返回 model picklist
+- `ListLLMModels`: 调用 provider 的 `GET /models` 返回 model picklist;404 作为“不支持目录”正常降级为手动输入
 
 ### 安全
 
 - `redactURLError`: 从 `*url.Error` 中剥离 query string(FOFA/Hunter API key 在 query 中)
-- 空 APIKey 回退到 stored config 中的值(Settings UI 留空表示保持不变)
+- 空 APIKey 按请求携带的 `profile_id` 回退到对应 stored config;缺省 ID 才使用 active profile
 
 **文件**: `pkg/probe/conn.go`, `pkg/probe/llm.go`, `pkg/web/probe.go`, `pkg/web/handler.go`
 
@@ -128,7 +128,11 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事
 
 ### ListModels
 
-两个 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。
+两个协议 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。
+
+### Provider presets
+
+品牌 preset 在协议归一化前解析,为 OpenAI、Anthropic、DeepSeek、OpenRouter、Groq、Moonshot、Ollama 和 Zhipu GLM 提供默认 Base URL。`glm`、`bigmodel` 映射到 `zhipu`;显式 Base URL 不会被覆盖。Ollama preset 不要求 API Key。
 
 ### hint404 协议提示
 
@@ -136,7 +140,7 @@ chat endpoint 返回 404 时包裹 actionable 建议(如"设置 `llm.provider=
 
 ### InferFromBaseURL
 
-检测 `anthropic.com` 域名自动推断 provider,其他默认 `openai`。
+这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。品牌默认地址由 preset 解析,不依赖域名猜测。
 
 **文件**: `pkg/agent/provider/anthropic.go`, `pkg/agent/provider/openai.go`, `pkg/agent/provider/http.go`, `pkg/agent/provider/provider.go`
 
@@ -186,7 +190,7 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。
 - `params`: 插值变量(如 `{"filename": "note.txt", "path": "/tmp/..."}`)
 - `fallback`: 英文文本,供非 i18n 消费者 / 日志 / 测试使用
 
-持久化时 code+params 存入 `ChatMessage.Metadata` JSON,前端从中渲染本地化文本。
+AOP error 事件把 code 保存在标准 data 中,并把 params 保存在 `ext["aiscan.web"]`。通用 reducer 会保留该扩展块,前端从中渲染本地化文本;因此实时流和重放使用同一参数来源。
 
 已定义的 code:
 
diff --git a/docs/reference.md b/docs/reference.md
index 9d429180..0e834342 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -52,11 +52,11 @@ aiscan -c /path/to/aiscan.yaml scan -i 192.168.1.0/24   # 指定配置文件
 ```yaml
 # LLM Provider
 llm:
-  provider: ""        # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic
+  provider: ""        # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic, zhipu
   base_url: ""        # API base URL(留空使用 provider 默认值)
   api_key: ""         # API key(建议使用环境变量)
   model: ""           # 模型名称
-  context_window: 0    # 模型上下文窗口;0 表示按模型推断,未知模型默认 128000
+  context_window: 0    # 真实 Token 数;0 表示按模型推断,未知模型默认 128000
   max_tokens: 0        # 单次最大输出;0 使用默认值 16384
   proxy: ""           # 访问 LLM API 的 HTTP proxy
 
@@ -161,7 +161,9 @@ misc:
 | `--timeout <秒>` | 整体超时(默认 3600) |
 | `-e, --eval` | 目标评估标准 — 独立 LLM 判断任务是否达成 |
 
-`max_tokens` 并非无条件发送:AIScan 会预估消息和工具 schema 的 token 数,并按 `context_window - 当前上下文 - 4096` 自动收紧。上下文接近窗口时会按 Pi 的默认策略自动压缩;服务端返回上下文溢出时会压缩并自动重试一次。
+`context_window` 使用真实整数,例如 128K 窗口填写 `128000`,不是 `128K`。所有正整数都可保存;Web 设置页会对小于 8192 的值显示非阻塞风险提示。
+
+`max_tokens` 并非无条件发送:AIScan 会预估消息和工具 schema 的 token 数,并按 `context_window - 当前上下文 - 4096` 自动收紧。若安全预留后没有输出空间,请求会在发送前返回包含窗口、预估输入和预留量的明确错误。上下文接近窗口时会按 Pi 的默认策略自动压缩;服务端返回上下文溢出时会压缩并自动重试一次。
 
 ### Scanner 参数
 
@@ -202,19 +204,22 @@ misc:
 
 | Provider | 默认 Base URL | 默认模型 | API Key 环境变量 |
 | --- | --- | --- | --- |
-| `openai` | `https://api.openai.com/v1` | `gpt-4o` | `OPENAI_API_KEY` |
-| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-chat` | `DEEPSEEK_API_KEY` |
-| `anthropic` | `https://api.anthropic.com/v1` | — | `ANTHROPIC_API_KEY` |
-| `openrouter` | `https://openrouter.ai/api/v1` | — | `OPENROUTER_API_KEY` |
-| `groq` | `https://api.groq.com/openai/v1` | — | `GROQ_API_KEY` |
-| `moonshot` | `https://api.moonshot.cn/v1` | — | `MOONSHOT_API_KEY` |
+| `openai` | `https://api.openai.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
+| `deepseek` | `https://api.deepseek.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
+| `anthropic` | `https://api.anthropic.com/v1` | — | `AISCAN_API_KEY` / `ANTHROPIC_API_KEY` |
+| `openrouter` | `https://openrouter.ai/api/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
+| `groq` | `https://api.groq.com/openai/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
+| `moonshot` | `https://api.moonshot.cn/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
 | `ollama` | `http://localhost:11434/v1` | — | 不需要 |
+| `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` |
 
-aiscan 可以从 `--base-url` 自动推断 provider(如 URL 包含 `deepseek.com` 自动识别为 `deepseek`)。
+`glm` 和 `bigmodel` 是 `zhipu` 的别名。已知 Provider 在 `base_url` 留空时使用上表地址;显式填写的地址始终优先。只提供 `base_url` 而不提供 Provider 时,Anthropic 官方域名会选择 Anthropic 协议,其他地址默认按 OpenAI 兼容协议处理。
 
 ### 多 Provider 配置
 
-配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。
+配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。`model` 必填,保存配置或激活 Profile 时都会拒绝空模型。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。
+
+Web 设置页拉取模型列表时使用当前编辑 Profile 的已保存密钥。若端点不提供 `GET /models`(返回 404),页面会保留手动模型输入,不把它显示为连接故障。
 
 Agent 只会重试当前 provider。重试耗尽后直接返回错误,不会自动切换到其他 profile,也不会把同一 turn 发给另一模型。
 
@@ -226,7 +231,7 @@ export OPENAI_API_KEY="sk-..."
 aiscan agent -p "检查目标" -i http://target.example
 
 # 指定 provider
-aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key "sk-..." --model deepseek-chat
+aiscan agent --provider deepseek --api-key "sk-..." --model deepseek-chat
 
 # Ollama 本地模型
 aiscan agent --provider ollama --model llama3 --base-url http://localhost:11434/v1
diff --git a/pkg/agent/compact_test.go b/pkg/agent/compact_test.go
index 373063f1..2dc33646 100644
--- a/pkg/agent/compact_test.go
+++ b/pkg/agent/compact_test.go
@@ -219,7 +219,7 @@ func TestEffectiveCompactionLimitsFitSmallContext(t *testing.T) {
 }
 
 func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) {
-	long := strings.Repeat("x", 240)
+	long := strings.Repeat("x", 9000)
 	llm := &scriptedProvider{responses: []*ChatCompletionResponse{
 		chatResponse(NewTextMessage("assistant", "history checkpoint")),
 		chatResponse(NewTextMessage("assistant", "turn-prefix checkpoint")),
@@ -230,7 +230,7 @@ func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) {
 		Tools:         commands.NewRegistry(),
 		Model:         "custom",
 		MaxTokens:     64,
-		ContextWindow: 180,
+		ContextWindow: 8192,
 		Compaction: CompactionSettings{
 			ReserveTokens:    40,
 			KeepRecentTokens: 20,
diff --git a/pkg/agent/probe/llm.go b/pkg/agent/probe/llm.go
index 0fc8c725..c1d0ffa0 100644
--- a/pkg/agent/probe/llm.go
+++ b/pkg/agent/probe/llm.go
@@ -2,6 +2,7 @@ package probe
 
 import (
 	"context"
+	"errors"
 	"strings"
 	"time"
 
@@ -15,11 +16,12 @@ import (
 // to keep it unchanged). Model is only required for TestLLM; ListLLMModels
 // ignores it.
 type LLMProbeRequest struct {
-	Provider string `json:"provider"`
-	BaseURL  string `json:"base_url"`
-	APIKey   string `json:"api_key"`
-	Model    string `json:"model,omitempty"`
-	Proxy    string `json:"proxy"`
+	ProfileID string `json:"profile_id,omitempty"`
+	Provider  string `json:"provider"`
+	BaseURL   string `json:"base_url"`
+	APIKey    string `json:"api_key"`
+	Model     string `json:"model,omitempty"`
+	Proxy     string `json:"proxy"`
 }
 
 // LLMTestResult reports whether a probe request reached the provider and
@@ -40,9 +42,10 @@ const llmProbeTimeout = 30 * time.Second
 // LLMModelsResult reports the model IDs discovered at the endpoint. ok=false
 // carries the reason (unsupported provider, auth failure, unreachable, …).
 type LLMModelsResult struct {
-	OK     bool     `json:"ok"`
-	Models []string `json:"models,omitempty"`
-	Error  string   `json:"error,omitempty"`
+	OK        bool     `json:"ok"`
+	Supported bool     `json:"supported"`
+	Models    []string `json:"models,omitempty"`
+	Error     string   `json:"error,omitempty"`
 }
 
 // modelLister is the optional capability a provider implements when its
@@ -89,11 +92,17 @@ func ListLLMModels(ctx context.Context, req LLMProbeRequest, storedAPIKey string
 
 	models, err := lister.ListModels(probeCtx)
 	if err != nil {
+		var apiErr *agent.APIError
+		if errors.As(err, &apiErr) && apiErr.StatusCode == 404 {
+			result.OK = true
+			return result, nil
+		}
 		result.Error = err.Error()
 		return result, nil
 	}
 
 	result.OK = true
+	result.Supported = true
 	result.Models = models
 	return result, nil
 }
diff --git a/pkg/agent/provider/provider.go b/pkg/agent/provider/provider.go
index 9b5122de..96ddf9b4 100644
--- a/pkg/agent/provider/provider.go
+++ b/pkg/agent/provider/provider.go
@@ -42,6 +42,28 @@ type ProviderConfig struct {
 	ContextWindow int    `yaml:"context_window,omitempty" config:"context_window"`
 }
 
+type providerPreset struct {
+	Protocol       string
+	BaseURL        string
+	APIKeyRequired bool
+}
+
+var providerPresets = map[string]providerPreset{
+	"openai":     {Protocol: "openai", BaseURL: "https://api.openai.com/v1", APIKeyRequired: true},
+	"anthropic":  {Protocol: "anthropic", BaseURL: "https://api.anthropic.com/v1", APIKeyRequired: true},
+	"deepseek":   {Protocol: "openai", BaseURL: "https://api.deepseek.com/v1", APIKeyRequired: true},
+	"openrouter": {Protocol: "openai", BaseURL: "https://openrouter.ai/api/v1", APIKeyRequired: true},
+	"groq":       {Protocol: "openai", BaseURL: "https://api.groq.com/openai/v1", APIKeyRequired: true},
+	"moonshot":   {Protocol: "openai", BaseURL: "https://api.moonshot.cn/v1", APIKeyRequired: true},
+	"ollama":     {Protocol: "openai", BaseURL: "http://localhost:11434/v1"},
+	"zhipu":      {Protocol: "openai", BaseURL: "https://open.bigmodel.cn/api/paas/v4", APIKeyRequired: true},
+}
+
+var providerAliases = map[string]string{
+	"bigmodel": "zhipu",
+	"glm":      "zhipu",
+}
+
 func NormalizeProvider(name string) string {
 	if strings.EqualFold(name, "anthropic") {
 		return "anthropic"
@@ -58,25 +80,33 @@ func Resolve(cfg *ProviderConfig) (*ProviderConfig, error) {
 		return nil, fmt.Errorf("context_window must be zero or positive")
 	}
 
-	if resolved.Provider == "" {
+	providerName := strings.ToLower(strings.TrimSpace(resolved.Provider))
+	if alias, ok := providerAliases[providerName]; ok {
+		providerName = alias
+	}
+
+	if providerName == "" {
 		if resolved.BaseURL != "" {
-			resolved.Provider = InferFromBaseURL(resolved.BaseURL)
+			providerName = InferFromBaseURL(resolved.BaseURL)
 		} else {
-			resolved.Provider = "openai"
+			providerName = "openai"
 		}
 	}
-	resolved.Provider = NormalizeProvider(resolved.Provider)
 
-	if resolved.BaseURL == "" {
-		switch resolved.Provider {
-		case "anthropic":
-			resolved.BaseURL = "https://api.anthropic.com/v1"
-		default:
-			resolved.BaseURL = "https://api.openai.com/v1"
+	preset, knownProvider := providerPresets[providerName]
+	if knownProvider {
+		if strings.TrimSpace(resolved.BaseURL) == "" {
+			resolved.BaseURL = preset.BaseURL
+		}
+		resolved.Provider = preset.Protocol
+	} else {
+		if strings.TrimSpace(resolved.BaseURL) == "" {
+			return nil, fmt.Errorf("unknown provider %q: set base_url for a custom OpenAI-compatible endpoint", providerName)
 		}
+		resolved.Provider = NormalizeProvider(providerName)
 	}
 
-	if resolved.APIKey == "" {
+	if strings.TrimSpace(resolved.APIKey) == "" && (!knownProvider || preset.APIKeyRequired) {
 		return nil, fmt.Errorf("no API key: set --api-key, llm.api_key, or AISCAN_API_KEY")
 	}
 
diff --git a/pkg/agent/provider/provider_test.go b/pkg/agent/provider/provider_test.go
index 54314bd4..9c0a271f 100644
--- a/pkg/agent/provider/provider_test.go
+++ b/pkg/agent/provider/provider_test.go
@@ -7,10 +7,65 @@ import (
 	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 	"time"
 )
 
+func TestResolveProviderPresets(t *testing.T) {
+	tests := []struct {
+		name         string
+		provider     string
+		apiKey       string
+		wantProtocol string
+		wantBaseURL  string
+	}{
+		{name: "openai", provider: "openai", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.openai.com/v1"},
+		{name: "anthropic", provider: "anthropic", apiKey: "key", wantProtocol: "anthropic", wantBaseURL: "https://api.anthropic.com/v1"},
+		{name: "deepseek", provider: "deepseek", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.deepseek.com/v1"},
+		{name: "openrouter", provider: "openrouter", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://openrouter.ai/api/v1"},
+		{name: "groq", provider: "groq", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.groq.com/openai/v1"},
+		{name: "moonshot", provider: "moonshot", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.moonshot.cn/v1"},
+		{name: "ollama", provider: "ollama", wantProtocol: "openai", wantBaseURL: "http://localhost:11434/v1"},
+		{name: "zhipu", provider: "zhipu", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"},
+		{name: "glm alias", provider: "glm", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"},
+		{name: "bigmodel alias", provider: "bigmodel", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			resolved, err := Resolve(&ProviderConfig{Provider: tt.provider, APIKey: tt.apiKey})
+			if err != nil {
+				t.Fatalf("Resolve() error = %v", err)
+			}
+			if resolved.Provider != tt.wantProtocol || resolved.BaseURL != tt.wantBaseURL {
+				t.Fatalf("Resolve() = provider %q, base_url %q; want %q, %q", resolved.Provider, resolved.BaseURL, tt.wantProtocol, tt.wantBaseURL)
+			}
+		})
+	}
+}
+
+func TestResolvePreservesExplicitDeepSeekBaseURL(t *testing.T) {
+	resolved, err := Resolve(&ProviderConfig{
+		Provider: "deepseek",
+		BaseURL:  "https://gateway.example/v1",
+		APIKey:   "key",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resolved.BaseURL != "https://gateway.example/v1" || resolved.Provider != "openai" {
+		t.Fatalf("Resolve() = %+v", resolved)
+	}
+}
+
+func TestResolveUnknownProviderRequiresBaseURL(t *testing.T) {
+	_, err := Resolve(&ProviderConfig{Provider: "custom", APIKey: "key"})
+	if err == nil || !strings.Contains(err.Error(), "base_url") {
+		t.Fatalf("Resolve() error = %v, want base_url guidance", err)
+	}
+}
+
 func TestResolveUsesBaseURL(t *testing.T) {
 	cfg, err := Resolve(&ProviderConfig{
 		Provider: "ollama",
diff --git a/pkg/agent/retry.go b/pkg/agent/retry.go
index e4411e23..d1e2eaf8 100644
--- a/pkg/agent/retry.go
+++ b/pkg/agent/retry.go
@@ -21,7 +21,10 @@ type imageDisabler interface {
 	DisableImages()
 }
 
-var errEmptyResponse = errors.New("empty response from LLM")
+var (
+	errEmptyResponse          = errors.New("empty response from LLM")
+	errContextWindowExhausted = errors.New("context window exhausted")
+)
 
 const (
 	baseRetryDelay    = 500 * time.Millisecond
@@ -214,7 +217,12 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm
 		CacheRetention: cfg.CacheRetention,
 		SessionID:      cfg.SessionID,
 	}
-	req.MaxTokens = clampMaxTokens(cfg.MaxTokens, cfg.ContextWindow, estimateRequestTokens(messages, tools))
+	estimatedInputTokens := estimateRequestTokens(messages, tools)
+	maxTokens, err := clampMaxTokens(cfg.MaxTokens, cfg.ContextWindow, estimatedInputTokens)
+	if err != nil {
+		return ChatMessage{}, nil, fmt.Errorf("cannot create LLM request at turn %d: %w", turn, err)
+	}
+	req.MaxTokens = maxTokens
 	em.status(aop.StatusLLMRequest, aop.NSAOP, aop.LLMRequest{Model: req.Model, Messages: len(req.Messages), MaxTokens: req.MaxTokens, Stream: cfg.Stream})
 	if cfg.Stream {
 		if streaming, ok := cfg.Provider.(StreamingProvider); ok {
@@ -238,21 +246,24 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm
 	return msg, resp.Usage, nil
 }
 
-func clampMaxTokens(configured, contextWindow, contextTokens int) int {
+func clampMaxTokens(configured, contextWindow, contextTokens int) (int, error) {
 	if configured <= 0 {
 		configured = DefaultMaxTokens
 	}
 	if contextWindow <= 0 {
-		return configured
+		return configured, nil
 	}
 	available := contextWindow - contextTokens - ContextSafetyTokens
 	if available < 1 {
-		available = 1
+		return 0, fmt.Errorf(
+			"%w: context_window=%d, estimated_input_tokens=%d, safety_reserve=%d; increase context_window or reduce the conversation history",
+			errContextWindowExhausted, contextWindow, contextTokens, ContextSafetyTokens,
+		)
 	}
 	if configured > available {
-		return available
+		return available, nil
 	}
-	return configured
+	return configured, nil
 }
 
 func estimateRequestTokens(messages []ChatMessage, tools []ToolDefinition) int {
diff --git a/pkg/agent/retry_test.go b/pkg/agent/retry_test.go
index 88c8a85f..d8fa2f27 100644
--- a/pkg/agent/retry_test.go
+++ b/pkg/agent/retry_test.go
@@ -2,6 +2,7 @@ package agent
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -50,22 +51,56 @@ func TestClampMaxTokens(t *testing.T) {
 		name                     string
 		configured, window, used int
 		want                     int
+		wantErr                  bool
 	}{
 		{name: "configured limit fits", configured: 16384, window: 128000, used: 10000, want: 16384},
 		{name: "remaining context clamps", configured: 32768, window: 100000, used: 80000, want: 15904},
-		{name: "safety margin exhausted", configured: 4096, window: 4096, used: 1, want: 1},
+		{name: "safety margin exhausted", configured: 4096, window: 4096, used: 1, wantErr: true},
 		{name: "default max tokens", configured: 0, window: 128000, used: 10000, want: DefaultMaxTokens},
 		{name: "unknown window leaves configured", configured: 12345, window: 0, used: 10000, want: 12345},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			if got := clampMaxTokens(tt.configured, tt.window, tt.used); got != tt.want {
+			got, err := clampMaxTokens(tt.configured, tt.window, tt.used)
+			if tt.wantErr {
+				if !errors.Is(err, errContextWindowExhausted) {
+					t.Fatalf("clampMaxTokens(%d, %d, %d) error = %v, want context window exhausted", tt.configured, tt.window, tt.used, err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("clampMaxTokens(%d, %d, %d) error = %v", tt.configured, tt.window, tt.used, err)
+			}
+			if got != tt.want {
 				t.Fatalf("clampMaxTokens(%d, %d, %d) = %d, want %d", tt.configured, tt.window, tt.used, got, tt.want)
 			}
 		})
 	}
 }
 
+func TestAgentRejectsExhaustedContextBeforeProviderCall(t *testing.T) {
+	callCount := 0
+	llm := &callbackProvider{
+		fn: func(_ context.Context, _ *ChatCompletionRequest) (*ChatCompletionResponse, error) {
+			callCount++
+			return chatResponse(NewTextMessage("assistant", "unexpected")), nil
+		},
+	}
+
+	_, err := NewAgent(Config{
+		Provider:      llm,
+		Model:         "test",
+		ContextWindow: ContextSafetyTokens,
+		MaxRetries:    -1,
+	}).Run(context.Background(), TextInput("hello"))
+	if !errors.Is(err, errContextWindowExhausted) {
+		t.Fatalf("Run() error = %v, want context window exhausted", err)
+	}
+	if callCount != 0 {
+		t.Fatalf("provider call count = %d, want 0", callCount)
+	}
+}
+
 func TestConfigInitUsesPiModelLimitDefaults(t *testing.T) {
 	cfg := (Config{Model: "unknown-custom-model"}).init()
 	if cfg.MaxTokens != DefaultMaxTokens || cfg.ContextWindow != DefaultContextWindow {
diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go
index a859e783..7a492acf 100644
--- a/pkg/web/config_profiles_test.go
+++ b/pkg/web/config_profiles_test.go
@@ -57,7 +57,7 @@ func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) {
 		func(p *webproto.LLMProviderConfig) { p.ContextWindow = -1 },
 	} {
 		var cfg webproto.DistributeConfig
-		profile := webproto.LLMProviderConfig{ID: "bad"}
+		profile := webproto.LLMProviderConfig{ID: "bad", Model: "test-model"}
 		mutate(&profile)
 		cfg.LLM.Providers = []webproto.LLMProviderConfig{profile}
 		if _, err := service.SaveConfig(context.Background(), cfg); err == nil {
@@ -68,3 +68,34 @@ func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) {
 		}
 	}
 }
+
+func TestSaveConfigRejectsEmptyProfileModel(t *testing.T) {
+	store := &fakeConfigStore{}
+	service := NewService(ServiceConfig{ConfigStore: store})
+	var cfg webproto.DistributeConfig
+	cfg.LLM.Providers = []webproto.LLMProviderConfig{{ID: "empty", Name: "Empty", Model: "  "}}
+
+	if _, err := service.SaveConfig(context.Background(), cfg); err == nil {
+		t.Fatal("SaveConfig() accepted an empty profile model")
+	}
+	if len(store.cfg.LLM.Providers) != 0 {
+		t.Fatal("invalid config was persisted")
+	}
+}
+
+func TestActivateLLMProfileRejectsEmptyModel(t *testing.T) {
+	store := &fakeConfigStore{}
+	store.cfg.LLM.ActiveProfile = "primary"
+	store.cfg.LLM.Providers = []webproto.LLMProviderConfig{
+		{ID: "primary", Model: "gpt-primary"},
+		{ID: "empty", Model: ""},
+	}
+	service := NewService(ServiceConfig{ConfigStore: store})
+
+	if _, err := service.ActivateLLMProfile(context.Background(), "empty"); err == nil {
+		t.Fatal("ActivateLLMProfile() accepted an empty model")
+	}
+	if store.cfg.LLM.ActiveProfile != "primary" {
+		t.Fatalf("active profile = %q, want primary", store.cfg.LLM.ActiveProfile)
+	}
+}
diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go
index 2a95919d..394d5e4a 100644
--- a/pkg/web/llm_probe_test.go
+++ b/pkg/web/llm_probe_test.go
@@ -200,6 +200,53 @@ func TestListLLMModelsFallsBackToStoredKey(t *testing.T) {
 	}
 }
 
+func TestListLLMModelsUsesSelectedProfileStoredKey(t *testing.T) {
+	var gotAuth string
+	srv := stubModelsServer(t, []string{"m1"}, &gotAuth)
+	defer srv.Close()
+
+	store := &fakeConfigStore{}
+	store.cfg.LLM.ActiveProfile = "primary"
+	store.cfg.LLM.Providers = []webproto.LLMProviderConfig{
+		{ID: "primary", Provider: "openai", APIKey: "sk-primary"},
+		{ID: "secondary", Provider: "openai", APIKey: "sk-secondary"},
+	}
+	svc := NewService(ServiceConfig{ConfigStore: store})
+
+	res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{
+		ProfileID: "secondary",
+		Provider:  "openai",
+		BaseURL:   srv.URL + "/v1",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !res.OK {
+		t.Fatalf("expected ok, got error: %q", res.Error)
+	}
+	if gotAuth != "Bearer sk-secondary" {
+		t.Fatalf("expected selected profile key, got %q", gotAuth)
+	}
+}
+
+func TestListLLMModelsTreatsNotFoundAsUnsupported(t *testing.T) {
+	srv := httptest.NewServer(http.NotFoundHandler())
+	defer srv.Close()
+
+	svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
+	res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{
+		Provider: "openai",
+		BaseURL:  srv.URL + "/v1",
+		APIKey:   "sk-test",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !res.OK || res.Supported || res.Error != "" {
+		t.Fatalf("result = %+v, want graceful unsupported response", res)
+	}
+}
+
 func TestListLLMModelsReportsTransportError(t *testing.T) {
 	svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
 	res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{
diff --git a/pkg/web/probe.go b/pkg/web/probe.go
index a016d84e..e8712689 100644
--- a/pkg/web/probe.go
+++ b/pkg/web/probe.go
@@ -31,23 +31,32 @@ func toProbeConfig(dc webproto.DistributeConfig) probe.ProbeConfig {
 // TestLLM probes the supplied LLM settings, falling back to the stored API key
 // when the request leaves it blank, then delegates to pkg/probe.
 func (s *Service) TestLLM(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMTestResult, error) {
-	return probe.TestLLM(ctx, req, s.storedLLMAPIKey(ctx))
+	return probe.TestLLM(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID))
 }
 
 // ListLLMModels enumerates the models the supplied LLM endpoint advertises,
 // falling back to the stored API key when the request leaves it blank, then
 // delegates to pkg/probe.
 func (s *Service) ListLLMModels(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMModelsResult, error) {
-	return probe.ListLLMModels(ctx, req, s.storedLLMAPIKey(ctx))
+	return probe.ListLLMModels(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID))
 }
 
-// storedLLMAPIKey returns the active profile's API key from the persisted
-// config, or "" when unavailable.
-func (s *Service) storedLLMAPIKey(ctx context.Context) string {
+// storedLLMAPIKey returns the requested profile's persisted API key. A blank
+// profileID keeps backward compatibility by selecting the active profile.
+func (s *Service) storedLLMAPIKey(ctx context.Context, profileID string) string {
 	if s.config == nil {
 		return ""
 	}
 	if dc, err := s.GetDistributeConfig(ctx); err == nil {
+		profileID = strings.TrimSpace(profileID)
+		if profileID != "" {
+			for _, profile := range dc.LLM.Providers {
+				if profile.ID == profileID {
+					return strings.TrimSpace(profile.APIKey)
+				}
+			}
+			return ""
+		}
 		return strings.TrimSpace(dc.LLM.Active().APIKey)
 	}
 	return ""
diff --git a/pkg/web/validation.go b/pkg/web/validation.go
index f1a1b9e8..250ae8fc 100644
--- a/pkg/web/validation.go
+++ b/pkg/web/validation.go
@@ -9,10 +9,20 @@ import (
 	"github.com/chainreactors/aiscan/pkg/webproto"
 )
 
-// ValidateLLMConfig accepts zero as "use the model default" and rejects
-// negative limits before an invalid configuration can be persisted.
+// ValidateLLMConfig accepts zero limits as "use the model default" and rejects
+// incomplete profiles before an invalid configuration can be persisted.
 func ValidateLLMConfig(cfg webproto.LLMConfig) error {
-	for _, profile := range cfg.Providers {
+	for i, profile := range cfg.Providers {
+		if strings.TrimSpace(profile.Model) == "" {
+			name := strings.TrimSpace(profile.Name)
+			if name == "" {
+				name = strings.TrimSpace(profile.ID)
+			}
+			if name == "" {
+				name = fmt.Sprintf("#%d", i+1)
+			}
+			return fmt.Errorf("LLM profile %q model is required", name)
+		}
 		if profile.MaxTokens < 0 {
 			return fmt.Errorf("LLM max_tokens must be zero or positive")
 		}

From 0bebf6f8dc92deee4a776c874378238e4b9b190a Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Tue, 28 Jul 2026 14:06:54 +0800
Subject: [PATCH 123/348] fix(build): select portable re2 tags

---
 Makefile | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index a33ffa39..3691d9f3 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,17 @@ WEB_ADDR ?= 127.0.0.1:8080
 WEB_TOKEN ?=
 BIN_DIR ?= bin
 
-RE2_TAGS := re2_cgo re2_static
+# The bundled static CRE2 archive only exists for linux/amd64 and
+# windows/amd64. Other native builds use go-re2's embedded WASM runtime.
+RE2_TAGS_DEFAULT :=
+ifeq ($(OS),Windows_NT)
+RE2_TAGS_DEFAULT := re2_cgo re2_static
+else ifeq ($(shell uname -s),Linux)
+ifeq ($(shell uname -m),x86_64)
+RE2_TAGS_DEFAULT := re2_cgo re2_static
+endif
+endif
+RE2_TAGS ?= $(RE2_TAGS_DEFAULT)
 
 ifeq ($(OS),Windows_NT)
 EXE := .exe

From 7d08affeda1f48404d1612d9dae019f5ec8eadad Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Tue, 28 Jul 2026 14:10:17 +0800
Subject: [PATCH 124/348] feat(web): harden LLM profile editor

---
 web/frontend/cyber-ui                       |   2 +-
 web/frontend/e2e/aiscan-web.spec.ts         |  59 ++++++++++-
 web/frontend/src/api.ts                     |   3 +
 web/frontend/src/components/ChatPanel.tsx   |   9 +-
 web/frontend/src/components/ConfigPanel.tsx | 112 +++++++++++++++-----
 web/frontend/src/i18n/locales/en/config.ts  |  11 +-
 web/frontend/src/i18n/locales/zh/config.ts  |  11 +-
 7 files changed, 172 insertions(+), 35 deletions(-)

diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui
index 4f6a18b0..1d1a29e1 160000
--- a/web/frontend/cyber-ui
+++ b/web/frontend/cyber-ui
@@ -1 +1 @@
-Subproject commit 4f6a18b09280c768667242078516a15a24313ec1
+Subproject commit 1d1a29e17ecd3838880b193660df98c01cb1fc26
diff --git a/web/frontend/e2e/aiscan-web.spec.ts b/web/frontend/e2e/aiscan-web.spec.ts
index 84f547ec..58c71cf0 100644
--- a/web/frontend/e2e/aiscan-web.spec.ts
+++ b/web/frontend/e2e/aiscan-web.spec.ts
@@ -162,7 +162,7 @@ test.describe('Config Panel', () => {
     await expect(dialog).toBeVisible({ timeout: 5_000 });
     await expect(dialog).toContainText('Settings');
     // Should have LLM and other tabs
-    await expect(dialog.locator('button:has-text("LLM")')).toBeVisible();
+    await expect(dialog.getByRole('button', { name: 'LLM', exact: true })).toBeVisible();
   });
 
   test('closes settings dialog', async ({ page }) => {
@@ -181,7 +181,7 @@ test.describe('Config Panel', () => {
     const dialog = page.locator('[role="dialog"]');
     await expect(dialog).toBeVisible();
     // Click LLM tab
-    const llmTab = dialog.locator('button:has-text("LLM")');
+    const llmTab = dialog.getByRole('button', { name: 'LLM', exact: true });
     if (await llmTab.isVisible()) {
       await llmTab.click();
     }
@@ -192,6 +192,61 @@ test.describe('Config Panel', () => {
     await expect(dialog).toContainText('Maximum output');
     await expect(dialog).toContainText('API Key');
   });
+
+  test('keeps dialog geometry stable when switching tabs', async ({ page }) => {
+    await openAuthenticatedApp(page);
+    await page.locator('button[aria-label="Open settings"]').click();
+    const dialog = page.locator('[role="dialog"]');
+    await expect(dialog).toBeVisible();
+    await dialog.evaluate((element) =>
+      Promise.all(element.getAnimations().map((animation) => animation.finished)),
+    );
+
+    const before = await dialog.boundingBox();
+    await dialog.getByRole('button', { name: 'Cyberhub', exact: true }).click();
+    const after = await dialog.boundingBox();
+
+    expect(before).not.toBeNull();
+    expect(after).not.toBeNull();
+    expect(Math.abs(after!.y - before!.y)).toBeLessThanOrEqual(1);
+    expect(Math.abs(after!.height - before!.height)).toBeLessThanOrEqual(1);
+  });
+
+  test('warns for a small context window and rejects an empty model', async ({ page }) => {
+    await openAuthenticatedApp(page);
+    await page.locator('button[aria-label="Open settings"]').click();
+    const dialog = page.locator('[role="dialog"]');
+    await expect(dialog).toBeVisible();
+
+    await dialog.getByLabel('Context window (tokens)').fill('4096');
+    await expect(dialog).toContainText('Below 8192 tokens');
+
+    await dialog.getByLabel('Model').fill('');
+    await dialog.getByRole('button', { name: 'Save', exact: true }).click();
+    await expect(dialog).toBeVisible();
+    await expect(dialog).toContainText('requires a model');
+  });
+
+  test('closes after a successful save', async ({ page }) => {
+    let saved = false;
+    await page.route('**/api/config', async (route) => {
+      if (route.request().method() !== 'PUT') {
+        await route.continue();
+        return;
+      }
+      saved = true;
+      await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
+    });
+
+    await openAuthenticatedApp(page);
+    await page.locator('button[aria-label="Open settings"]').click();
+    const dialog = page.locator('[role="dialog"]');
+    await expect(dialog).toBeVisible();
+    await dialog.getByRole('button', { name: 'Save', exact: true }).click();
+
+    await expect(dialog).not.toBeVisible();
+    expect(saved).toBe(true);
+  });
 });
 
 // ---------------------------------------------------------------------------
diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts
index 9f2136a4..ba8f6ac5 100644
--- a/web/frontend/src/api.ts
+++ b/web/frontend/src/api.ts
@@ -355,6 +355,7 @@ export async function activateLLMProfile(id: string): Promise {
 // LLMTestRequest — POST /api/config/llm/test body. Leave api_key blank to
 // reuse the key already stored on the server.
 export interface LLMTestRequest {
+  profile_id?: string;
   provider: string;
   base_url: string;
   api_key: string;
@@ -385,6 +386,7 @@ export async function testLLM(req: LLMTestRequest): Promise {
 // without a model (listing is what fills the model field). Leave api_key blank
 // to reuse the key already stored on the server.
 export interface LLMModelsRequest {
+  profile_id?: string;
   provider: string;
   base_url: string;
   api_key: string;
@@ -395,6 +397,7 @@ export interface LLMModelsRequest {
 // GET /models route. ok=false carries the reason in `error`.
 export interface LLMModelsResult {
   ok: boolean;
+  supported: boolean;
   models?: string[];
   error?: string;
 }
diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx
index bba50c86..7bdf8e18 100644
--- a/web/frontend/src/components/ChatPanel.tsx
+++ b/web/frontend/src/components/ChatPanel.tsx
@@ -838,7 +838,14 @@ function systemCode(metadata?: Record): string {
 
 function systemParams(metadata: Record): Record {
   const p = metadata.params
-  return p && typeof p === 'object' ? (p as Record) : {}
+  if (p && typeof p === 'object') return p as Record
+
+  const ext = metadata.ext
+  if (!ext || typeof ext !== 'object') return {}
+  const webExt = (ext as Record)[webUserAgent]
+  if (!webExt || typeof webExt !== 'object') return {}
+  const params = (webExt as Record).params
+  return params && typeof params === 'object' ? params as Record : {}
 }
 
 function SystemMessageContent({ metadata, fallback }: { metadata: Record; fallback: string }) {
diff --git a/web/frontend/src/components/ConfigPanel.tsx b/web/frontend/src/components/ConfigPanel.tsx
index d33091cd..87d7c777 100644
--- a/web/frontend/src/components/ConfigPanel.tsx
+++ b/web/frontend/src/components/ConfigPanel.tsx
@@ -1,6 +1,6 @@
 import { useEffect, useState, type FormEvent } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Check, CheckCircle, Plus, Settings, Trash2, Zap } from 'lucide-react'
+import { Check, Plus, Settings, Trash2, Zap } from 'lucide-react'
 import { getConfigStatus, saveConfig, testLLM, testConn, listLLMModels } from '../api'
 import type { ConfigStatus, ConnCheck, DistributeConfig, LLMProviderProfile, LLMTestResult, ServerStatus } from '../api'
 import { Button, Input, Select, SelectTrigger, SelectContent, SelectItem, SelectValue, Badge, Spinner, Callout, Field, Switch, Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, ResultLine } from '@cyber/ui'
@@ -26,6 +26,17 @@ const TABS: { key: TabKey; label: string }[] = [
   { key: 'agent', label: 'Agent' },
 ]
 
+const LLM_PROVIDERS = [
+  { value: 'deepseek', label: 'DeepSeek' },
+  { value: 'openai', label: 'OpenAI' },
+  { value: 'openrouter', label: 'OpenRouter' },
+  { value: 'ollama', label: 'Ollama' },
+  { value: 'groq', label: 'Groq' },
+  { value: 'moonshot', label: 'Moonshot' },
+  { value: 'anthropic', label: 'Anthropic' },
+  { value: 'zhipu', label: 'Zhipu GLM' },
+]
+
 function emptyForm(): DistributeConfig {
   const profile = blankLLMProfile('default')
   return {
@@ -99,7 +110,8 @@ function sectionStatus(
   const tag = (name: string, ok: boolean) => ({ key: name, label: `${name} ${ok ? t('configured') : t('notConfigured')}`, ok })
   switch (tab) {
     case 'llm':
-      return [{ key: 'llm', label: status?.llm_available ? t('llmReady') : t('llmOffline'), ok: !!status?.llm_available }]
+      const configured = !!(status?.llm_available && status.llm_model?.trim())
+      return [{ key: 'llm', label: configured ? t('llmConfigured') : t('llmNotConfigured'), ok: configured }]
     case 'cyberhub':
       return [tag('Cyberhub', !!(cs?.cyberhub.url && cs?.cyberhub.key_configured))]
     case 'recon':
@@ -126,15 +138,15 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa
   const [loading, setLoading] = useState(false)
   const [saving, setSaving] = useState(false)
   const [error, setError] = useState('')
-  const [saved, setSaved] = useState(false)
   const [activeTab, setActiveTab] = useState('llm')
   const [selectedLLMProfileID, setSelectedLLMProfileID] = useState('default')
+  const [invalidModelProfileID, setInvalidModelProfileID] = useState('')
 
   useEffect(() => {
     if (!open) return
     setLoading(true)
     setError('')
-    setSaved(false)
+    setInvalidModelProfileID('')
     getConfigStatus()
       .then((s) => {
         const next = statusToForm(s)
@@ -148,15 +160,22 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa
 
   const handleSave = async (event: FormEvent) => {
     event.preventDefault()
+
+    const invalidProfile = form.llm.providers.find(profile => !profile.model.trim())
+    if (invalidProfile) {
+      setActiveTab('llm')
+      setSelectedLLMProfileID(invalidProfile.id)
+      setInvalidModelProfileID(invalidProfile.id)
+      setError(t('modelRequiredProfile', { name: invalidProfile.name || invalidProfile.id || t('unnamedProfile') }))
+      return
+    }
+
     setSaving(true)
     setError('')
-    setSaved(false)
     try {
-      const next = await saveConfig(form)
-      setCs(next)
-      setForm(statusToForm(next))
-      setSaved(true)
+      await saveConfig(form)
       onSaved()
+      onClose()
     } catch (err: unknown) {
       const message = err instanceof Error ? err.message : String(err)
       setError(message || t('failedSave'))
@@ -169,10 +188,10 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa
      { if (!next) onClose() }}>
        e.preventDefault()}
-        className="block max-h-[85vh] w-full max-w-3xl gap-0 overflow-y-auto overflow-x-hidden rounded-2xl border-border/70 bg-card p-0 sm:rounded-2xl"
+        className="flex h-[85dvh] max-h-[42rem] w-full max-w-3xl gap-0 overflow-hidden rounded-lg border-border/70 bg-card p-0"
       >
-        
- + +
@@ -182,7 +201,7 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa
-
+
{TABS.map((tab) => ( @@ -248,13 +277,23 @@ function LLMTab({ cs, selectedProfileID, onSelectProfile, -}: TabProps & { selectedProfileID: string; onSelectProfile: (id: string) => void }) { + invalidModelProfileID, + onInvalidModel, + onModelChange, +}: TabProps & { + selectedProfileID: string + onSelectProfile: (id: string) => void + invalidModelProfileID: string + onInvalidModel: (profile: LLMProviderProfile) => void + onModelChange: (profileID: string) => void +}) { const { t } = useTranslation('config') const [testing, setTesting] = useState(false) const [result, setResult] = useState(null) const [models, setModels] = useState([]) const [fetchingModels, setFetchingModels] = useState(false) const [modelsError, setModelsError] = useState(null) + const [modelsNotice, setModelsNotice] = useState(null) const profiles = form.llm.providers const profile = profiles.find(item => item.id === selectedProfileID) || profiles[0] @@ -276,6 +315,8 @@ function LLMTab({ setForm(current => ({ ...current, llm: { ...current.llm, providers: [...current.llm.providers, next] } })) onSelectProfile(next.id) setModels([]) + setModelsError(null) + setModelsNotice(null) setResult(null) } @@ -289,11 +330,17 @@ function LLMTab({ })) onSelectProfile(remaining[0].id) setModels([]) + setModelsError(null) + setModelsNotice(null) setResult(null) } const setActiveProfile = () => { if (!profile) return + if (!profile.model.trim()) { + onInvalidModel(profile) + return + } setForm(current => ({ ...current, llm: { ...current.llm, active_profile: profile.id } })) } @@ -301,8 +348,10 @@ function LLMTab({ if (!profile) return setFetchingModels(true) setModelsError(null) + setModelsNotice(null) try { const res = await listLLMModels({ + profile_id: profile.id, provider: profile.provider, base_url: profile.base_url, api_key: profile.api_key, @@ -310,7 +359,8 @@ function LLMTab({ }) if (res.ok) { setModels(res.models ?? []) - if ((res.models ?? []).length === 0) setModelsError(t('modelsEmpty')) + if (!res.supported) setModelsNotice(t('modelsUnsupported')) + else if ((res.models ?? []).length === 0) setModelsNotice(t('modelsEmpty')) } else { setModelsError(res.error || t('modelsFailed')) } @@ -328,6 +378,7 @@ function LLMTab({ setResult(null) try { const res = await testLLM({ + profile_id: profile.id, provider: profile.provider, base_url: profile.base_url, api_key: profile.api_key, @@ -345,6 +396,9 @@ function LLMTab({ if (!profile) return null + const modelRequired = invalidModelProfileID === profile.id && !profile.model.trim() + const lowContextWindow = profile.context_window !== undefined && profile.context_window < 8192 + return (
@@ -352,7 +406,7 @@ function LLMTab({ const active = item.id === form.llm.active_profile const selected = item.id === profile.id return ( -
- diff --git a/web/frontend/src/i18n/locales/en/config.ts b/web/frontend/src/i18n/locales/en/config.ts index 8060516f..b144b1a9 100644 --- a/web/frontend/src/i18n/locales/en/config.ts +++ b/web/frontend/src/i18n/locales/en/config.ts @@ -1,7 +1,7 @@ export default { settings: 'Settings', - llmReady: 'LLM Ready', - llmOffline: 'LLM Offline', + llmConfigured: 'LLM configured', + llmNotConfigured: 'LLM not configured', configLoaded: 'Config Loaded', configMissing: 'Config Missing', configured: 'Configured', @@ -25,6 +25,7 @@ export default { // model list auto-fetch fetchModels: 'Fetch model list', modelsEmpty: 'Endpoint returned no models — enter one manually', + modelsUnsupported: 'This endpoint does not expose a model list; enter the model manually', modelsFailed: 'Failed to fetch model list', modelsCount: '{{count}} models', modelsLoading: 'Fetching models…', @@ -32,7 +33,9 @@ export default { modelSearchNoMatch: 'No model matches “{{query}}”', modelUseCustom: 'Press Enter to use “{{query}}”', baseUrl: 'Base URL', - contextWindow: 'Context window', + contextWindow: 'Context window (tokens)', + contextWindowLow: 'Below 8192 tokens, requests may not have enough room for both input and output', + contextWindowAuto: 'Leave empty to infer from the model', maxTokens: 'Maximum output', proxy: 'Proxy', apiKey: 'API Key', @@ -57,6 +60,8 @@ export default { // placeholder hints configuredKeep: 'configured; leave blank to keep', requiredUnlessOllama: 'required unless ollama', + modelRequired: 'Model is required', + modelRequiredProfile: 'Profile “{{name}}” requires a model', providerDefault: 'leave empty for provider default', modelDefault: 'leave empty for model default', cyberhubApiKey: 'cyberhub API key', diff --git a/web/frontend/src/i18n/locales/zh/config.ts b/web/frontend/src/i18n/locales/zh/config.ts index 311ddc55..5de3a2cd 100644 --- a/web/frontend/src/i18n/locales/zh/config.ts +++ b/web/frontend/src/i18n/locales/zh/config.ts @@ -1,7 +1,7 @@ export default { settings: '设置', - llmReady: 'LLM 就绪', - llmOffline: 'LLM 离线', + llmConfigured: 'LLM 已配置', + llmNotConfigured: 'LLM 未配置', configLoaded: '配置已加载', configMissing: '配置缺失', configured: '已配置', @@ -25,6 +25,7 @@ export default { // model list auto-fetch fetchModels: '拉取模型列表', modelsEmpty: '该端点未返回任何模型,请手动填写', + modelsUnsupported: '该端点不提供模型列表,请手动填写模型', modelsFailed: '拉取模型列表失败', modelsCount: '{{count}} 个模型', modelsLoading: '正在拉取模型…', @@ -32,7 +33,9 @@ export default { modelSearchNoMatch: '没有匹配 “{{query}}” 的模型', modelUseCustom: '按回车使用 “{{query}}”', baseUrl: 'Base URL', - contextWindow: '上下文窗口', + contextWindow: '上下文窗口(Token)', + contextWindowLow: '低于 8192 Token,实际请求可能没有足够的输入和输出空间', + contextWindowAuto: '留空则根据模型自动推断', maxTokens: '最大输出', proxy: '代理', apiKey: 'API Key', @@ -57,6 +60,8 @@ export default { // placeholder hints configuredKeep: '已配置;留空则保持不变', requiredUnlessOllama: '必填(ollama 除外)', + modelRequired: '模型不能为空', + modelRequiredProfile: '配置 “{{name}}” 的模型不能为空', providerDefault: '留空则使用 Provider 默认值', modelDefault: '留空则使用模型默认值', cyberhubApiKey: 'Cyberhub API Key', From 47be64c0cd1770c132bb2f530460cae11c0b4545 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 14:10:58 +0800 Subject: [PATCH 125/348] chore(ci): validate stacked pull requests --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec57644..f76d78ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - master pull_request: - branches: - - master workflow_dispatch: permissions: From 505174d8db754ca49db09f12ed68522467f87668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:11:53 +0800 Subject: [PATCH 126/348] fix(web): make scan cancellation terminal-safe --- pkg/web/agents.go | 29 ++-- pkg/web/agents_test.go | 3 +- pkg/web/handler.go | 10 +- pkg/web/scan_lifecycle_test.go | 288 +++++++++++++++++++++++++++++++++ pkg/web/service.go | 201 +++++++++++++++++------ pkg/web/store_sqlite.go | 34 ++++ pkg/web/store_sqlite_test.go | 39 +++++ pkg/web/types.go | 6 + 8 files changed, 551 insertions(+), 59 deletions(-) create mode 100644 pkg/web/scan_lifecycle_test.go diff --git a/pkg/web/agents.go b/pkg/web/agents.go index b1c0bfcc..54a6bed0 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -442,10 +442,10 @@ func (p *AgentPool) SendAgentMessage(agentID string, msg webproto.Message) error } } -func (p *AgentPool) CancelTask(agentID, taskID string) { +func (p *AgentPool) CancelTask(agentID, taskID string) error { a := p.get(agentID) if a == nil { - return + return fmt.Errorf("agent %s not connected", agentID) } a.mu.Lock() resultCh, pending := a.tasks[taskID] @@ -457,18 +457,25 @@ func (p *AgentPool) CancelTask(agentID, taskID string) { delete(a.childSessions, taskID) } a.mu.Unlock() + if !pending { + return nil + } + cancelMessage := webproto.Message{Type: webproto.TypeRunCancel, TurnID: taskID} + if isToolCall { + cancelMessage = webproto.Message{Type: "cancel", TaskID: taskID} + } + var sendErr error select { - case a.sendCh <- func() webproto.Message { - if isToolCall { - return webproto.Message{Type: "cancel", TaskID: taskID} - } - return webproto.Message{Type: webproto.TypeRunCancel, TurnID: taskID} - }(): - default: + case a.controlCh <- cancelMessage: + case <-a.done: + sendErr = fmt.Errorf("agent %s disconnected before cancellation", agentID) + case <-time.After(time.Second): + sendErr = fmt.Errorf("agent %s control channel full", agentID) } - if pending && resultCh != nil { + if resultCh != nil { close(resultCh) } + return sendErr } // HandleTerminalWS bridges one browser terminal WebSocket to one remote agent. @@ -692,7 +699,7 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { commandsMenu: info.CommandsMenu, conn: conn, sendCh: make(chan webproto.Message, 32), - controlCh: make(chan webproto.Message, 1), + controlCh: make(chan webproto.Message, 32), connectAt: time.Now(), node: info.Node, runtime: info.Runtime, diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index 6da788dc..0adda18e 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -1053,6 +1053,7 @@ func TestCancelTaskConvergesPendingTaskImmediately(t *testing.T) { remote := &remoteAgent{ id: "agent-1", sendCh: make(chan WSMessage, 1), + controlCh: make(chan WSMessage, 1), tasks: map[string]chan taskResult{"task-1": resultCh}, turns: map[string]int{"task-1": 1}, toolCalls: make(map[string]struct{}), @@ -1063,7 +1064,7 @@ func TestCancelTaskConvergesPendingTaskImmediately(t *testing.T) { pool.CancelTask(remote.id, "task-1") select { - case frame := <-remote.sendCh: + case frame := <-remote.controlCh: if frame.Type != webproto.TypeRunCancel || frame.TurnID != "task-1" { t.Fatalf("cancel frame = %+v", frame) } diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 4011ec61..90d8dd2c 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -2,6 +2,7 @@ package web import ( "encoding/json" + "errors" "io" "net/http" "strconv" @@ -256,7 +257,14 @@ func (h *handlerImpl) getScan(w http.ResponseWriter, r *http.Request) { func (h *handlerImpl) cancelScan(w http.ResponseWriter, r *http.Request) { if err := h.service.CancelScan(r.PathValue("id")); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + switch { + case errors.Is(err, ErrScanNotFound): + writeError(w, http.StatusNotFound, ErrScanNotFound.Error()) + case errors.Is(err, ErrScanNotCancelable): + writeError(w, http.StatusConflict, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } return } writeJSON(w, http.StatusOK, map[string]string{"status": "canceled"}) diff --git a/pkg/web/scan_lifecycle_test.go b/pkg/web/scan_lifecycle_test.go new file mode 100644 index 00000000..2dc0ea2f --- /dev/null +++ b/pkg/web/scan_lifecycle_test.go @@ -0,0 +1,288 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func waitScanStatus(t *testing.T, store *SQLiteStore, id string, want ScanStatus) *ScanJob { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + job, err := store.Get(context.Background(), id) + if err == nil && job.Status == want { + return job + } + time.Sleep(10 * time.Millisecond) + } + job, err := store.Get(context.Background(), id) + t.Fatalf("scan %s status = %+v, err = %v; want %s", id, job, err, want) + return nil +} + +func TestCancelRemoteScanStopsAgentAndPreservesCanceledStatus(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + svc := NewService(ServiceConfig{Store: store, MaxConcurrent: 1, ScanTimeout: time.Minute}) + pool := NewAgentPool(svc.Hub()) + svc.SetAgentPool(pool) + + srv, _ := setupTestServerWithPool(t, pool) + conn := dialAgent(t, srv, "scan-agent", []string{"scan"}) + t.Cleanup(func() { _ = conn.Close() }) + waitAgents(t, pool, 1) + + job, err := svc.SubmitScan(context.Background(), "127.0.0.1", "quick", false, false, false) + if err != nil { + t.Fatal(err) + } + + var call webproto.Message + if err := conn.ReadJSON(&call); err != nil { + t.Fatal(err) + } + if call.Type != webproto.TypeAOP || call.TaskID != job.ID { + t.Fatalf("scan dispatch = %+v", call) + } + waitScanStatus(t, store, job.ID, StatusRunning) + + if err := svc.CancelScan(job.ID); err != nil { + t.Fatal(err) + } + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + var cancel webproto.Message + if err := conn.ReadJSON(&cancel); err != nil { + t.Fatalf("agent did not receive scan cancellation: %v", err) + } + if cancel.Type != "cancel" || cancel.TaskID != job.ID { + t.Fatalf("cancel frame = %+v", cancel) + } + + waitScanStatus(t, store, job.ID, StatusCanceled) + deadline := time.Now().Add(time.Second) + for len(svc.sem) != 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := len(svc.sem); got != 0 { + t.Fatalf("scan concurrency slot still occupied after cancellation: %d", got) + } + + // A result that races with cancellation must not resurrect the scan. + resultJSON, _ := json.Marshal(&output.Result{}) + pool.handleAgentMessage(pool.Pick(), webproto.Message{ + Type: "complete", TaskID: job.ID, Payload: resultJSON, + }) + time.Sleep(20 * time.Millisecond) + if got, err := store.Get(context.Background(), job.ID); err != nil || got.Status != StatusCanceled { + t.Fatalf("late result changed canceled scan: job=%+v err=%v", got, err) + } +} + +func TestCancelQueuedScanDoesNotWaitForConcurrencySlot(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + svc := NewService(ServiceConfig{Store: store, MaxConcurrent: 1, ScanTimeout: time.Minute}) + pool := NewAgentPool(svc.Hub()) + svc.SetAgentPool(pool) + srv, _ := setupTestServerWithPool(t, pool) + conn := dialAgent(t, srv, "queue-agent", []string{"scan"}) + t.Cleanup(func() { _ = conn.Close() }) + waitAgents(t, pool, 1) + + running, err := svc.SubmitScan(context.Background(), "127.0.0.1", "quick", false, false, false) + if err != nil { + t.Fatal(err) + } + var call webproto.Message + if err := conn.ReadJSON(&call); err != nil { + t.Fatal(err) + } + waitScanStatus(t, store, running.ID, StatusRunning) + + queued, err := svc.SubmitScan(context.Background(), "127.0.0.2", "quick", false, false, false) + if err != nil { + t.Fatal(err) + } + waitScanStatus(t, store, queued.ID, StatusQueued) + if err := svc.CancelScan(queued.ID); err != nil { + t.Fatal(err) + } + waitScanStatus(t, store, queued.ID, StatusCanceled) + + if err := svc.CancelScan(running.ID); err != nil { + t.Fatal(err) + } + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + var cancel webproto.Message + if err := conn.ReadJSON(&cancel); err != nil { + t.Fatal(err) + } + waitScanStatus(t, store, running.ID, StatusCanceled) +} + +func TestRemoteScanTimeoutCancelsAgentAndFailsScan(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + svc := NewService(ServiceConfig{Store: store, MaxConcurrent: 1, ScanTimeout: 50 * time.Millisecond}) + pool := NewAgentPool(svc.Hub()) + svc.SetAgentPool(pool) + srv, _ := setupTestServerWithPool(t, pool) + conn := dialAgent(t, srv, "timeout-agent", []string{"scan"}) + t.Cleanup(func() { _ = conn.Close() }) + waitAgents(t, pool, 1) + + job, err := svc.SubmitScan(context.Background(), "127.0.0.1", "quick", false, false, false) + if err != nil { + t.Fatal(err) + } + var call webproto.Message + if err := conn.ReadJSON(&call); err != nil { + t.Fatal(err) + } + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + var cancel webproto.Message + if err := conn.ReadJSON(&cancel); err != nil { + t.Fatalf("agent did not receive timeout cancellation: %v", err) + } + if cancel.Type != "cancel" || cancel.TaskID != job.ID { + t.Fatalf("timeout cancel frame = %+v", cancel) + } + failed := waitScanStatus(t, store, job.ID, StatusFailed) + if failed.Error != "scan timed out" { + t.Fatalf("timeout error = %q", failed.Error) + } +} + +func setupTestServerWithPool(t *testing.T, pool *AgentPool) (*httptest.Server, *AgentPool) { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/agent/ws", pool.HandleWS) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, pool +} + +func TestCancelTaskUsesControlChannelWhenTaskQueueIsFull(t *testing.T) { + pool := NewAgentPool(NewHub()) + remote := newFakeAgent("agent-1", 1) + remote.toolCalls = map[string]struct{}{"scan-1": {}} + remote.tasks["scan-1"] = make(chan taskResult, 1) + remote.sendCh <- webproto.Message{Type: "busy"} + pool.agents[remote.id] = remote + + if err := pool.CancelTask(remote.id, "scan-1"); err != nil { + t.Fatal(err) + } + select { + case msg := <-remote.controlCh: + if msg.Type != "cancel" || msg.TaskID != "scan-1" { + t.Fatalf("control cancellation = %+v", msg) + } + default: + t.Fatal("cancellation was not queued on the control channel") + } +} + +func TestCompleteJobCannotOverwriteCanceledScan(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + now := time.Now() + job := &ScanJob{ID: "scan-canceled", Target: "127.0.0.1", Mode: "quick", Status: StatusCanceled, CreatedAt: now, UpdatedAt: now} + if err := store.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + + svc := NewService(ServiceConfig{Store: store}) + changed, err := svc.completeJob(context.Background(), job, "", &output.Result{}) + if err != nil { + t.Fatal(err) + } + if changed { + t.Fatal("completeJob() completed a canceled scan") + } + stored, err := store.Get(context.Background(), job.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != StatusCanceled || strings.TrimSpace(stored.Report) != "" { + t.Fatalf("canceled scan was mutated: %+v", stored) + } +} + +func TestCancelCompletedScanReturnsConflictAndPreservesStatus(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + now := time.Now() + job := &ScanJob{ + ID: "scan-completed", + Target: "127.0.0.1", + Mode: "quick", + Status: StatusCompleted, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + + handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodDelete, "/api/scans/"+job.ID, nil) + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusConflict { + t.Fatalf("DELETE completed scan status = %d, body = %s; want %d", recorder.Code, recorder.Body.String(), http.StatusConflict) + } + stored, err := store.Get(context.Background(), job.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != StatusCompleted { + t.Fatalf("completed scan status = %s; want %s", stored.Status, StatusCompleted) + } +} + +func TestCancelMissingScanReturnsNotFound(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodDelete, "/api/scans/missing", nil) + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("DELETE missing scan status = %d, body = %s; want %d", recorder.Code, recorder.Body.String(), http.StatusNotFound) + } +} diff --git a/pkg/web/service.go b/pkg/web/service.go index 368e25fc..02b77f38 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -4,9 +4,11 @@ import ( "bytes" "context" "crypto/rand" + "database/sql" "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -58,6 +60,7 @@ type Service struct { mu sync.Mutex cancels map[string]context.CancelFunc + scanAgents map[string]string taskSessions map[string]string // taskID → sessionID taskAgents map[string]string // taskID → agentID taskCanceled map[string]bool @@ -82,6 +85,7 @@ func NewService(cfg ServiceConfig) *Service { sem: make(chan struct{}, maxConcurrent), timeout: timeout, cancels: make(map[string]context.CancelFunc), + scanAgents: make(map[string]string), taskSessions: make(map[string]string), taskAgents: make(map[string]string), taskCanceled: make(map[string]bool), @@ -103,6 +107,15 @@ func (s *Service) Close() { if s == nil { return } + s.mu.Lock() + cancels := make([]context.CancelFunc, 0, len(s.cancels)) + for _, cancel := range s.cancels { + cancels = append(cancels, cancel) + } + s.mu.Unlock() + for _, cancel := range cancels { + cancel() + } s.appMu.Lock() app := s.app s.app = nil @@ -240,7 +253,11 @@ func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, s return nil, fmt.Errorf("store create: %w", err) } - go s.runScan(job.ID) //nolint:gosec // G118: background scan outlives the request + runCtx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + s.cancels[job.ID] = cancel + s.mu.Unlock() + go s.runScan(runCtx, job.ID) //nolint:gosec // G118: background scan outlives the request return job, nil } @@ -272,21 +289,56 @@ func refreshStructuredAssets(job *ScanJob) { } func (s *Service) CancelScan(id string) error { - s.mu.Lock() - cancel, ok := s.cancels[id] - s.mu.Unlock() - if ok { - cancel() - } ctx := context.Background() job, err := s.store.Get(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %s", ErrScanNotFound, id) + } + return err + } + if job.Status == StatusCanceled { + return nil + } + if job.Status != StatusRunning && job.Status != StatusQueued { + return fmt.Errorf("%w: scan %s is %s", ErrScanNotCancelable, id, job.Status) + } + job.Status = StatusCanceled + job.UpdatedAt = time.Now() + changed, err := s.store.TransitionScan(ctx, job, StatusRunning, StatusQueued) if err != nil { return err } - if job.Status == StatusRunning || job.Status == StatusQueued { - job.Status = StatusCanceled - job.UpdatedAt = time.Now() - return s.store.Update(ctx, job) + if !changed { + current, err := s.store.Get(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %s", ErrScanNotFound, id) + } + return err + } + if current.Status == StatusCanceled { + return nil + } + return fmt.Errorf("%w: scan %s is %s", ErrScanNotCancelable, id, current.Status) + } + + s.mu.Lock() + cancel := s.cancels[id] + agentID := s.scanAgents[id] + s.mu.Unlock() + if cancel != nil { + cancel() + } + s.hub.Broadcast(id, HubEvent{ + Type: "error", + Data: mustJSON(map[string]string{"scan_id": id, "status": string(StatusCanceled), "error": "scan canceled"}), + Reliable: true, + }) + if agentID != "" && s.agents != nil { + if err := s.agents.CancelTask(agentID, id); err != nil { + return err + } } return nil } @@ -306,33 +358,34 @@ func (s *Service) GetReport(ctx context.Context, id, lang string) (string, error return job.Report, nil } -func (s *Service) runScan(jobID string) { - s.sem <- struct{}{} - defer func() { <-s.sem }() - - ctx, cancel := context.WithTimeout(context.Background(), s.timeout) - defer cancel() - - s.mu.Lock() - s.cancels[jobID] = cancel - s.mu.Unlock() +func (s *Service) runScan(runCtx context.Context, jobID string) { defer func() { s.mu.Lock() delete(s.cancels, jobID) + delete(s.scanAgents, jobID) s.mu.Unlock() }() - job, err := s.store.Get(ctx, jobID) - if err != nil { + select { + case s.sem <- struct{}{}: + case <-runCtx.Done(): return } - if job.Status == StatusCanceled { + defer func() { <-s.sem }() + + ctx, cancel := context.WithTimeout(runCtx, s.timeout) + defer cancel() + + job, err := s.store.Get(ctx, jobID) + if err != nil { return } - job.Status = StatusRunning job.UpdatedAt = time.Now() - _ = s.store.Update(ctx, job) + changed, err := s.store.TransitionScan(context.Background(), job, StatusQueued) + if err != nil || !changed { + return + } s.hub.Broadcast(jobID, HubEvent{ Type: "status", @@ -350,7 +403,13 @@ func (s *Service) runScan(jobID string) { func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { agent := s.agents.Pick() if agent == nil { - s.failJob(job, "no agents available") + _, _ = s.failJob(job, "no agents available") + return + } + s.mu.Lock() + s.scanAgents[job.ID] = agent.id + s.mu.Unlock() + if ctx.Err() != nil { return } @@ -361,20 +420,33 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { Args: map[string]any{"command": cmd}, }) if err != nil { - s.failJob(job, err.Error()) + _, _ = s.failJob(job, err.Error()) return } // Progress lines stream to the SSE hub as tool.data events while the scan // runs; the terminal tool.result carries the full text and the structured // scan result in its details. - res, ok := <-resultCh + var res taskResult + var ok bool + select { + case <-ctx.Done(): + _ = s.agents.CancelTask(agent.id, job.ID) + s.finishScanContext(job, ctx.Err()) + return + case res, ok = <-resultCh: + } + if ctx.Err() != nil { + _ = s.agents.CancelTask(agent.id, job.ID) + s.finishScanContext(job, ctx.Err()) + return + } if !ok { - s.failJob(job, "agent disconnected") + _, _ = s.failJob(job, "agent disconnected") return } if res.Err != "" { - s.failJob(job, res.Err) + _, _ = s.failJob(job, res.Err) return } if progress := lastOutputLine(res.Output); progress != "" { @@ -387,7 +459,7 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { _ = json.Unmarshal(res.Result, result) } - s.completeJob(ctx, job, agent.id, result) + _, _ = s.completeJob(context.Background(), job, agent.id, result) } func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) { @@ -402,14 +474,31 @@ func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) { args := scanArgsForJob(job) _, result, err := s.executeScan(ctx, args, streamWriter) if err != nil { - s.failJob(job, err.Error()) + s.finishScanContext(job, ctx.Err()) + if ctx.Err() == nil { + _, _ = s.failJob(job, err.Error()) + } return } if streamWriter.job != nil { job = streamWriter.job } - s.completeJob(ctx, job, "", result) + _, _ = s.completeJob(context.Background(), job, "", result) +} + +func (s *Service) finishScanContext(job *ScanJob, err error) { + if err == nil { + return + } + if err == context.DeadlineExceeded { + _, _ = s.failJob(job, "scan timed out") + return + } + next := *job + next.Status = StatusCanceled + next.UpdatedAt = time.Now() + _, _ = s.store.TransitionScan(context.Background(), &next, StatusQueued, StatusRunning) } func (s *Service) persistResultRecords(scanID, agentID string, result *output.Result) { @@ -419,12 +508,21 @@ func (s *Service) persistResultRecords(scanID, agentID string, result *output.Re } } -func (s *Service) completeJob(ctx context.Context, job *ScanJob, agentID string, result *output.Result) { - job.Status = StatusCompleted - job.Report = buildMarkdownReport(job.Target, job.Mode, result, defaultReportLang) - job.Result = result - job.UpdatedAt = time.Now() - _ = s.store.Update(ctx, job) +func (s *Service) completeJob(ctx context.Context, job *ScanJob, agentID string, result *output.Result) (bool, error) { + if result == nil { + return false, fmt.Errorf("scan result is required") + } + next := *job + next.Status = StatusCompleted + next.Report = buildMarkdownReport(job.Target, job.Mode, result, defaultReportLang) + next.Result = result + next.Error = "" + next.UpdatedAt = time.Now() + changed, err := s.store.TransitionScan(ctx, &next, StatusRunning) + if err != nil || !changed { + return changed, err + } + *job = next s.persistResultRecords(job.ID, agentID, result) if len(result.Nodes) > 0 { _ = s.store.UpsertSCONodes(ctx, job.ID, result.Nodes) @@ -435,18 +533,25 @@ func (s *Service) completeJob(ctx context.Context, job *ScanJob, agentID string, Reliable: true, }) s.broadcastScanComplete(job.ID, result) + return true, nil } -func (s *Service) failJob(job *ScanJob, errMsg string) { - job.Status = StatusFailed - job.Error = errMsg - job.UpdatedAt = time.Now() - _ = s.store.Update(context.Background(), job) +func (s *Service) failJob(job *ScanJob, errMsg string) (bool, error) { + next := *job + next.Status = StatusFailed + next.Error = errMsg + next.UpdatedAt = time.Now() + changed, err := s.store.TransitionScan(context.Background(), &next, StatusQueued, StatusRunning) + if err != nil || !changed { + return changed, err + } + *job = next s.hub.Broadcast(job.ID, HubEvent{ Type: "error", Data: mustJSON(map[string]string{"scan_id": job.ID, "error": errMsg}), Reliable: true, }) + return true, nil } func (s *Service) aiAvailable() bool { @@ -564,9 +669,13 @@ func (w *sseStreamWriter) Write(p []byte) (int, error) { } current.Progress = line current.UpdatedAt = time.Now() - if err := w.store.Update(context.Background(), current); err != nil { + changed, err := w.store.TransitionScan(context.Background(), current, StatusRunning) + if err != nil { return 0, err } + if !changed { + return 0, context.Canceled + } w.job = current w.hub.Broadcast(w.scanID, HubEvent{ diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index 3db98f7c..88bf05fa 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -337,6 +337,40 @@ func (s *SQLiteStore) Update(ctx context.Context, job *ScanJob) error { return err } +// TransitionScan updates a scan only while it is in one of the expected +// states. Callers use the affected-row result to make terminal states +// immutable when cancellation and completion race. +func (s *SQLiteStore) TransitionScan(ctx context.Context, job *ScanJob, expected ...ScanStatus) (bool, error) { + if job == nil { + return false, fmt.Errorf("scan job is required") + } + if len(expected) == 0 { + return false, fmt.Errorf("at least one expected scan status is required") + } + + placeholders := make([]string, len(expected)) + args := []any{ + boolToInt(job.Verify || job.Sniper), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep), + string(job.Status), job.Progress, job.Report, marshalResult(job), job.Error, + job.UpdatedAt.Format(time.RFC3339Nano), job.ID, + } + for i, status := range expected { + placeholders[i] = "?" + args = append(args, string(status)) + } + result, err := s.db.ExecContext(ctx, + `UPDATE scans SET ai=?, verify=?, sniper=?, deep=?, status=?, progress=?, report=?, result=?, error=?, updated_at=? + WHERE id=? AND status IN (`+strings.Join(placeholders, ",")+`)`, args...) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + if err != nil { + return false, err + } + return rows == 1, nil +} + func (s *SQLiteStore) Delete(ctx context.Context, id string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM scans WHERE id=?`, id) return err diff --git a/pkg/web/store_sqlite_test.go b/pkg/web/store_sqlite_test.go index e8e0c67d..83a985e5 100644 --- a/pkg/web/store_sqlite_test.go +++ b/pkg/web/store_sqlite_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/aop" ) @@ -144,3 +145,41 @@ func TestSQLiteStorePersistsAnalysisOptions(t *testing.T) { t.Fatalf("stored options = verify:%v sniper:%v deep:%v", got.Verify, got.Sniper, got.Deep) } } + +func TestSQLiteStoreTransitionScanRequiresExpectedStatus(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "transitions.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + now := time.Now() + job := &ScanJob{ + ID: "scan-transition", Target: "127.0.0.1", Mode: "quick", + Status: StatusQueued, CreatedAt: now, UpdatedAt: now, + } + if err := store.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + + job.Status = StatusCanceled + job.UpdatedAt = time.Now() + changed, err := store.TransitionScan(context.Background(), job, StatusQueued, StatusRunning) + if err != nil || !changed { + t.Fatalf("queued -> canceled = %v, %v; want true, nil", changed, err) + } + + job.Status = StatusCompleted + job.Result = &output.Result{} + changed, err = store.TransitionScan(context.Background(), job, StatusRunning) + if err != nil { + t.Fatal(err) + } + if changed { + t.Fatal("terminal canceled status was overwritten") + } + stored, err := store.Get(context.Background(), job.ID) + if err != nil || stored.Status != StatusCanceled { + t.Fatalf("stored scan = %+v, %v", stored, err) + } +} diff --git a/pkg/web/types.go b/pkg/web/types.go index 5643c3c5..c261cdef 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -2,12 +2,18 @@ package web import ( "encoding/json" + "errors" "time" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/webproto" ) +var ( + ErrScanNotFound = errors.New("scan not found") + ErrScanNotCancelable = errors.New("scan cannot be canceled") +) + type ScanStatus string const ( From 26cac10cbdbd810a6a2467b3253a1baadc7c343e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:04:19 +0800 Subject: [PATCH 127/348] fix(web): reject invalid remote scan results --- pkg/web/scan_lifecycle_test.go | 22 ++++++++++++++++++++++ pkg/web/service.go | 33 +++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/pkg/web/scan_lifecycle_test.go b/pkg/web/scan_lifecycle_test.go index 2dc0ea2f..66db87e7 100644 --- a/pkg/web/scan_lifecycle_test.go +++ b/pkg/web/scan_lifecycle_test.go @@ -204,6 +204,28 @@ func TestCancelTaskUsesControlChannelWhenTaskQueueIsFull(t *testing.T) { } } +func TestDecodeScanResultRejectsInvalidEnvelopes(t *testing.T) { + for _, tc := range []struct { + name string + raw json.RawMessage + }{ + {name: "empty"}, + {name: "null", raw: json.RawMessage("null")}, + {name: "malformed", raw: json.RawMessage("{")}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := decodeScanResult(tc.raw); err == nil { + t.Fatal("decodeScanResult() accepted an invalid result") + } + }) + } + + result, err := decodeScanResult(json.RawMessage("{}")) + if err != nil || result == nil { + t.Fatalf("decodeScanResult({}) = %+v, %v", result, err) + } +} + func TestCompleteJobCannotOverwriteCanceledScan(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) if err != nil { diff --git a/pkg/web/service.go b/pkg/web/service.go index 02b77f38..af2ac041 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -365,6 +365,13 @@ func (s *Service) runScan(runCtx context.Context, jobID string) { delete(s.scanAgents, jobID) s.mu.Unlock() }() + defer func() { + if recovered := recover(); recovered != nil { + if job, err := s.store.Get(context.Background(), jobID); err == nil { + _, _ = s.failJob(job, fmt.Sprintf("scan runtime panic: %v", recovered)) + } + } + }() select { case s.sem <- struct{}{}: @@ -453,10 +460,10 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { job.Progress = progress } - var result *output.Result - if len(res.Result) > 0 { - result = &output.Result{} - _ = json.Unmarshal(res.Result, result) + result, err := decodeScanResult(res.Result) + if err != nil { + _, _ = s.failJob(job, err.Error()) + return } _, _ = s.completeJob(context.Background(), job, agent.id, result) @@ -483,10 +490,28 @@ func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) { if streamWriter.job != nil { job = streamWriter.job } + if ctx.Err() != nil { + s.finishScanContext(job, ctx.Err()) + return + } _, _ = s.completeJob(context.Background(), job, "", result) } +func decodeScanResult(raw json.RawMessage) (*output.Result, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, fmt.Errorf("agent scan returned an empty result envelope") + } + var result *output.Result + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("decode agent scan result: %w", err) + } + if result == nil { + return nil, fmt.Errorf("agent scan returned a null result envelope") + } + return result, nil +} + func (s *Service) finishScanContext(job *ScanJob, err error) { if err == nil { return From 64ecf22b4fb187cdb5c380f715f0e24bb1dd4e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:14:32 +0800 Subject: [PATCH 128/348] fix(web): enforce session referential integrity --- pkg/web/agents.go | 2 - pkg/web/command_test.go | 1 + pkg/web/eval_forward_test.go | 21 ++++- pkg/web/handler.go | 8 ++ pkg/web/service.go | 29 ++++--- pkg/web/service_test.go | 42 ++++++++++ pkg/web/sse_test.go | 3 + pkg/web/store_sqlite.go | 136 ++++++++++++++++++++++++++++++- pkg/web/store_sqlite_test.go | 154 +++++++++++++++++++++++++++++++++++ pkg/web/types.go | 1 + 10 files changed, 381 insertions(+), 16 deletions(-) diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 54a6bed0..8ddc8172 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -1017,8 +1017,6 @@ func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) { } if sid, ok := p.sessions.TaskSession(correlationID); ok { p.sessions.BroadcastAOPEvent(sid, aopEv) - } else if aopEv.SessionID != "" { - p.sessions.BroadcastAOPEvent(aopEv.SessionID, aopEv) } } diff --git a/pkg/web/command_test.go b/pkg/web/command_test.go index 75f54d23..3b2468ef 100644 --- a/pkg/web/command_test.go +++ b/pkg/web/command_test.go @@ -81,6 +81,7 @@ func TestClearCommandWipesTranscript(t *testing.T) { svc := newMenuTestService(t) ctx := context.Background() sid := "sess-clear" + createStoredSession(t, svc.store, sid) for _, role := range []string{"user", "assistant", "user"} { err := svc.store.AddMessage(ctx, &ChatMessage{ ID: generateID(), SessionID: sid, Role: role, Content: "x", CreatedAt: time.Now(), diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go index 78ca8d11..59f97f74 100644 --- a/pkg/web/eval_forward_test.go +++ b/pkg/web/eval_forward_test.go @@ -7,15 +7,17 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" + "github.com/chainreactors/aiscan/pkg/webproto" ) type evalSink struct { sid string + found bool chatEvents []DomainEvent aopEvents []aop.Event } -func (s *evalSink) TaskSession(string) (string, bool) { return s.sid, true } +func (s *evalSink) TaskSession(string) (string, bool) { return s.sid, s.found } func (s *evalSink) BroadcastDomainEvent(_ string, event DomainEvent) { s.chatEvents = append(s.chatEvents, event) } @@ -24,7 +26,7 @@ func (s *evalSink) BroadcastAOPEvent(_ string, event aop.Event) { } func TestForwardAgentEventKeepsEvalOnlyInAOP(t *testing.T) { - sink := &evalSink{sid: "sess-eval"} + sink := &evalSink{sid: "sess-eval", found: true} pool := NewAgentPool(NewHub()) pool.SetSessionLookup(sink) remote := &remoteAgent{id: "agent-1", name: "worker", tasks: map[string]chan taskResult{}, turns: map[string]int{}} @@ -59,3 +61,18 @@ func TestForwardAgentEventKeepsEvalOnlyInAOP(t *testing.T) { t.Fatalf("compact detail = %#v, %v, %v", compactDetail, ok, err) } } + +func TestForwardStandaloneScanAOPDoesNotCreateChatHistory(t *testing.T) { + sink := &evalSink{} + pool := NewAgentPool(NewHub()) + pool.SetSessionLookup(sink) + event := aop.Event{ + Type: aop.TypeStatus, TS: "2026-07-19T00:00:00Z", + SessionID: "scan-not-chat", Agent: "worker", Data: mustJSON(aop.StatusData{State: "running"}), + } + payload, _ := json.Marshal(event) + pool.forwardAOPEvent(&remoteAgent{}, WSMessage{Type: webproto.TypeAOP, TaskID: "scan-not-chat", Payload: payload}) + if len(sink.aopEvents) != 0 { + t.Fatalf("standalone scan AOP was forwarded to chat history: %+v", sink.aopEvents) + } +} diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 90d8dd2c..e3a153c9 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -359,6 +359,10 @@ func (h *handlerImpl) sendMessage(w http.ResponseWriter, r *http.Request) { } msg, err := h.service.HandleUserMessage(r.Context(), r.PathValue("id"), req.Content, opts) if err != nil { + if errors.Is(err, ErrSessionNotFound) { + writeError(w, http.StatusNotFound, ErrSessionNotFound.Error()) + return + } writeError(w, http.StatusInternalServerError, err.Error()) return } @@ -407,6 +411,10 @@ func (h *handlerImpl) uploadFile(w http.ResponseWriter, r *http.Request) { result, err := h.service.HandleFileUpload(r.Context(), r.PathValue("id"), header.Filename, data) if err != nil { + if errors.Is(err, ErrSessionNotFound) { + writeError(w, http.StatusNotFound, ErrSessionNotFound.Error()) + return + } writeError(w, http.StatusInternalServerError, err.Error()) return } diff --git a/pkg/web/service.go b/pkg/web/service.go index af2ac041..a82fe97f 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -1169,7 +1169,10 @@ func (s *Service) CancelSession(ctx context.Context, sessionID string) error { func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename string, data []byte) (*webproto.FileUploadResult, error) { session, err := s.store.GetSession(ctx, sessionID) if err != nil { - return nil, fmt.Errorf("session not found: %w", err) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) + } + return nil, fmt.Errorf("get upload session: %w", err) } if s.agents == nil { return nil, fmt.Errorf("no agent pool available") @@ -1384,6 +1387,13 @@ func (s *Service) persistRuntimeDomainEvent(sessionID string, event DomainEvent) func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content string, opts webproto.GoalExt) (*ChatMessage, error) { now := time.Now() + session, err := s.store.GetSession(ctx, sessionID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) + } + return nil, fmt.Errorf("get message session: %w", err) + } msg := &ChatMessage{ ID: generateID(), SessionID: sessionID, @@ -1400,18 +1410,15 @@ func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content stri } // Update session timestamp and auto-title from first message. - session, err := s.store.GetSession(ctx, sessionID) - if err == nil { - session.UpdatedAt = now - if session.Title == "" { - title := content - if len(title) > 60 { - title = title[:60] + "..." - } - session.Title = title + session.UpdatedAt = now + if session.Title == "" { + title := content + if len(title) > 60 { + title = title[:60] + "..." } - _ = s.store.UpdateSession(ctx, session) + session.Title = title } + _ = s.store.UpdateSession(ctx, session) //nolint:gosec // Agent dispatch must continue after the HTTP request returns. go s.dispatchUserMessage(sessionID, msg, opts) diff --git a/pkg/web/service_test.go b/pkg/web/service_test.go index cd2f3552..992158ca 100644 --- a/pkg/web/service_test.go +++ b/pkg/web/service_test.go @@ -1,7 +1,10 @@ package web import ( + "bytes" "context" + "net/http" + "net/http/httptest" "path/filepath" "reflect" "strings" @@ -9,6 +12,7 @@ import ( "time" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/utils/parsers" ) @@ -42,6 +46,44 @@ func TestServiceStatusReportsLLMAvailability(t *testing.T) { } } +func TestHandleUserMessageRejectsMissingSessionBeforePersisting(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + svc := NewService(ServiceConfig{Store: store}) + + if _, err := svc.HandleUserMessage(context.Background(), "missing", "hello", webproto.GoalExt{}); err == nil { + t.Fatal("HandleUserMessage() accepted a missing session") + } + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM chat_aop_events WHERE session_id = 'missing'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("missing session retained %d events", count) + } +} + +func TestSendMessageReturnsNotFoundForMissingSession(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + svc := NewService(ServiceConfig{Store: store}) + handler := NewHandler(svc, nil, nil, nil, nil, "") + req := httptest.NewRequest(http.MethodPost, "/api/chat/sessions/missing/messages", bytes.NewBufferString(`{"content":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s; want 404", recorder.Code, recorder.Body.String()) + } +} + func TestGetScanRebuildsLegacyMergedAssets(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) if err != nil { diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index c14efd4e..e202db37 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -79,6 +79,7 @@ func TestBroadcastAOPEventPersistsRawEnvelope(t *testing.T) { svc := NewService(ServiceConfig{Store: store}) const sid = "sess-aop" + createStoredSession(t, store, sid) event := aop.Event{ Type: aop.TypeMessage, TS: "2026-07-19T00:00:00Z", @@ -109,6 +110,7 @@ func TestEvalMetadataPersistsOnlyInAOP(t *testing.T) { } defer store.Close() svc := NewService(ServiceConfig{Store: store}) + createStoredSession(t, store, "sess-eval") event := aop.Event{ Type: "turn.end", TS: time.Now().UTC().Format(time.RFC3339Nano), @@ -139,6 +141,7 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) { } defer store.Close() svc := NewService(ServiceConfig{Store: store}) + createStoredSession(t, store, "sess-scan") // A completed scan must leave a durable marker so its inline card survives a // timeline rebuild (reload / session switch). The heavy Result is intentionally diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index 88bf05fa..8dc78a1e 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -19,7 +19,7 @@ type SQLiteStore struct { } func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { - db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000&_pragma=foreign_keys(1)") if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } @@ -29,6 +29,14 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { db.Close() return nil, fmt.Errorf("migrate sqlite: %w", err) } + var foreignKeys int + if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&foreignKeys); err != nil || foreignKeys != 1 { + db.Close() + if err != nil { + return nil, fmt.Errorf("verify sqlite foreign keys: %w", err) + } + return nil, fmt.Errorf("verify sqlite foreign keys: disabled") + } return &SQLiteStore{db: db}, nil } @@ -134,6 +142,9 @@ func migrate(db *sql.DB) error { `); err != nil { return err } + if err := ensureSessionForeignKeys(db); err != nil { + return err + } if _, err := db.Exec(` CREATE INDEX IF NOT EXISTS idx_scans_created ON scans(created_at DESC); @@ -148,6 +159,129 @@ func migrate(db *sql.DB) error { return wipeLegacyAOPEvents(db) } +func ensureSessionForeignKeys(db *sql.DB) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + aopConstrained, err := hasCascadeForeignKey(tx, "chat_aop_events", "session_id", "chat_sessions", "id") + if err != nil { + return err + } + if aopConstrained { + if _, err := tx.Exec(` + DELETE FROM chat_aop_events + WHERE NOT EXISTS ( + SELECT 1 FROM chat_sessions WHERE chat_sessions.id = chat_aop_events.session_id + ) + `); err != nil { + return err + } + } else if err := rebuildAOPEventsWithForeignKey(tx); err != nil { + return err + } + + scansConstrained, err := hasCascadeForeignKey(tx, "session_scans", "session_id", "chat_sessions", "id") + if err != nil { + return err + } + if scansConstrained { + if _, err := tx.Exec(` + DELETE FROM session_scans + WHERE NOT EXISTS ( + SELECT 1 FROM chat_sessions WHERE chat_sessions.id = session_scans.session_id + ) + `); err != nil { + return err + } + } else if err := rebuildSessionScansWithForeignKey(tx); err != nil { + return err + } + + rows, err := tx.Query(`PRAGMA foreign_key_check`) + if err != nil { + return err + } + violated := rows.Next() + rowsErr := rows.Err() + if err := rows.Close(); err != nil { + return err + } + if rowsErr != nil { + return rowsErr + } + if violated { + return fmt.Errorf("sqlite foreign key check failed after migration") + } + return tx.Commit() +} + +func hasCascadeForeignKey(tx *sql.Tx, table, from, parent, to string) (bool, error) { + rows, err := tx.Query(`PRAGMA foreign_key_list(` + quoteSQLiteIdent(table) + `)`) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var ( + id, seq int + parentTable, fromColumn, toColumn string + onUpdate, onDelete, match string + ) + if err := rows.Scan(&id, &seq, &parentTable, &fromColumn, &toColumn, &onUpdate, &onDelete, &match); err != nil { + return false, err + } + if parentTable == parent && fromColumn == from && toColumn == to && strings.EqualFold(onDelete, "CASCADE") { + return true, nil + } + } + return false, rows.Err() +} + +func rebuildAOPEventsWithForeignKey(tx *sql.Tx) error { + _, err := tx.Exec(` + DROP TABLE IF EXISTS chat_aop_events_fk_migration; + CREATE TABLE chat_aop_events_fk_migration ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + event_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + INSERT INTO chat_aop_events_fk_migration (rowid, id, session_id, event_json, created_at) + SELECT events.rowid, events.id, events.session_id, events.event_json, events.created_at + FROM chat_aop_events AS events + WHERE EXISTS ( + SELECT 1 FROM chat_sessions WHERE chat_sessions.id = events.session_id + ) + ORDER BY events.rowid; + DROP TABLE chat_aop_events; + ALTER TABLE chat_aop_events_fk_migration RENAME TO chat_aop_events; + `) + return err +} + +func rebuildSessionScansWithForeignKey(tx *sql.Tx) error { + _, err := tx.Exec(` + DROP TABLE IF EXISTS session_scans_fk_migration; + CREATE TABLE session_scans_fk_migration ( + session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + scan_id TEXT NOT NULL, + PRIMARY KEY (session_id, scan_id) + ); + INSERT INTO session_scans_fk_migration (session_id, scan_id) + SELECT links.session_id, links.scan_id + FROM session_scans AS links + WHERE EXISTS ( + SELECT 1 FROM chat_sessions WHERE chat_sessions.id = links.session_id + ); + DROP TABLE session_scans; + ALTER TABLE session_scans_fk_migration RENAME TO session_scans; + `) + return err +} + type sqliteColumnMigration struct { table string name string diff --git a/pkg/web/store_sqlite_test.go b/pkg/web/store_sqlite_test.go index 83a985e5..e5ffcc84 100644 --- a/pkg/web/store_sqlite_test.go +++ b/pkg/web/store_sqlite_test.go @@ -12,6 +12,16 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" ) +func createStoredSession(t *testing.T, store *SQLiteStore, id string) { + t.Helper() + now := time.Now() + if err := store.CreateSession(context.Background(), &ChatSession{ + ID: id, Status: SessionActive, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateSession(%q): %v", id, err) + } +} + func TestSQLiteStoreWipesLegacyTextEvents(t *testing.T) { path := filepath.Join(t.TempDir(), "legacy.db") db, err := sql.Open("sqlite", path) @@ -51,6 +61,7 @@ func TestSQLiteStoreMessageRoundTrip(t *testing.T) { } defer store.Close() ctx := context.Background() + createStoredSession(t, store, "s1") created := time.Date(2026, 7, 19, 1, 2, 3, 0, time.UTC) if err := store.AddMessage(ctx, &ChatMessage{ @@ -183,3 +194,146 @@ func TestSQLiteStoreTransitionScanRequiresExpectedStatus(t *testing.T) { t.Fatalf("stored scan = %+v, %v", stored, err) } } + +func TestSQLiteStoreEnablesForeignKeysAndCascadesSessionData(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "foreign-keys.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + var enabled int + if err := store.db.QueryRow(`PRAGMA foreign_keys`).Scan(&enabled); err != nil { + t.Fatal(err) + } + if enabled != 1 { + t.Fatalf("PRAGMA foreign_keys = %d, want 1", enabled) + } + + ctx := context.Background() + now := time.Now() + session := &ChatSession{ + ID: "session-cascade", Status: SessionActive, + CreatedAt: now, UpdatedAt: now, + } + if err := store.CreateSession(ctx, session); err != nil { + t.Fatal(err) + } + if err := store.AddMessage(ctx, &ChatMessage{ + ID: "message-cascade", SessionID: session.ID, Role: "user", Content: "hello", CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + if err := store.LinkScanToSession(ctx, session.ID, "scan-cascade"); err != nil { + t.Fatal(err) + } + if err := store.DeleteSession(ctx, session.ID); err != nil { + t.Fatal(err) + } + + for _, table := range []string{"chat_aop_events", "session_scans"} { + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE session_id = ?`, session.ID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("%s retained %d rows after session deletion", table, count) + } + } +} + +func TestSQLiteStoreRejectsMessageForMissingSession(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "foreign-keys.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + err = store.AddMessage(context.Background(), &ChatMessage{ + ID: "orphan-message", SessionID: "missing", Role: "user", Content: "hello", CreatedAt: time.Now(), + }) + if err == nil { + t.Fatal("AddMessage() created an orphan event") + } +} + +func TestSQLiteStoreMigratesLegacySessionForeignKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy-foreign-keys.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE chat_sessions ( + id TEXT PRIMARY KEY, agent_id TEXT, agent_name TEXT, title TEXT, + status TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE chat_aop_events ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + event_json TEXT NOT NULL, created_at TEXT NOT NULL + ); + CREATE TABLE session_scans ( + session_id TEXT NOT NULL, scan_id TEXT NOT NULL, + PRIMARY KEY (session_id, scan_id) + ); + INSERT INTO chat_sessions VALUES ( + 'kept-session','','','','active','2026-07-27T00:00:00Z','2026-07-27T00:00:00Z' + ); + INSERT INTO chat_aop_events VALUES + ('kept-event','kept-session','{}','2026-07-27T00:00:01Z'), + ('orphan-event','missing-session','{}','2026-07-27T00:00:02Z'); + INSERT INTO session_scans VALUES + ('kept-session','kept-scan'), + ('missing-session','orphan-scan'); + PRAGMA user_version = 2; + `) + if err != nil { + _ = db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + store, err := NewSQLiteStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + for _, table := range []string{"chat_aop_events", "session_scans"} { + var keptCount int + if err := store.db.QueryRow( + `SELECT COUNT(*) FROM ` + table + ` WHERE session_id = 'kept-session'`, + ).Scan(&keptCount); err != nil { + t.Fatal(err) + } + if keptCount != 1 { + t.Fatalf("%s retained %d valid legacy rows, want 1", table, keptCount) + } + var orphanCount int + if err := store.db.QueryRow( + `SELECT COUNT(*) FROM ` + table + ` WHERE session_id = 'missing-session'`, + ).Scan(&orphanCount); err != nil { + t.Fatal(err) + } + if orphanCount != 0 { + t.Fatalf("%s retained %d legacy orphan rows", table, orphanCount) + } + } + + if err := store.DeleteSession(context.Background(), "kept-session"); err != nil { + t.Fatal(err) + } + for _, table := range []string{"chat_aop_events", "session_scans"} { + var count int + if err := store.db.QueryRow( + `SELECT COUNT(*) FROM ` + table + ` WHERE session_id = 'kept-session'`, + ).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("%s did not cascade after legacy migration", table) + } + } +} diff --git a/pkg/web/types.go b/pkg/web/types.go index c261cdef..4b9df04e 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -12,6 +12,7 @@ import ( var ( ErrScanNotFound = errors.New("scan not found") ErrScanNotCancelable = errors.New("scan cannot be canceled") + ErrSessionNotFound = errors.New("session not found") ) type ScanStatus string From 401ec49113490ae55820130c08ea47c4bd3405ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:15:00 +0800 Subject: [PATCH 129/348] fix(web): close SSE snapshot subscription gaps --- pkg/web/handler.go | 53 ++++++++++++++++++++++++++------- pkg/web/sse.go | 30 +++++++++++++++++-- pkg/web/sse_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 12 deletions(-) diff --git a/pkg/web/handler.go b/pkg/web/handler.go index e3a153c9..0f5f21c2 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -272,11 +272,40 @@ func (h *handlerImpl) cancelScan(w http.ResponseWriter, r *http.Request) { func (h *handlerImpl) scanEvents(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if _, err := h.service.GetScan(r.Context(), id); err != nil { + err := ServeSSEWithSnapshot(w, r, h.service.Hub(), id, func() ([]HubEvent, error) { + job, err := h.service.GetScan(r.Context(), id) + if err != nil { + return nil, err + } + return []HubEvent{scanSnapshotEvent(job)}, nil + }, "complete", "error") + if err != nil { writeError(w, http.StatusNotFound, "scan not found") - return } - ServeSSE(w, r, h.service.Hub(), id, "complete", "error") +} + +func scanSnapshotEvent(job *ScanJob) HubEvent { + switch job.Status { + case StatusCompleted: + return HubEvent{ + Type: "complete", + Data: mustJSON(map[string]any{"scan_id": job.ID, "status": string(job.Status), "result": job.Result}), + } + case StatusFailed, StatusCanceled: + errMsg := job.Error + if errMsg == "" && job.Status == StatusCanceled { + errMsg = "scan canceled" + } + return HubEvent{ + Type: "error", + Data: mustJSON(map[string]string{"scan_id": job.ID, "status": string(job.Status), "error": errMsg}), + } + default: + return HubEvent{ + Type: "status", + Data: mustJSON(map[string]string{"scan_id": job.ID, "status": string(job.Status), "progress": job.Progress}), + } + } } func (h *handlerImpl) scanReport(w http.ResponseWriter, r *http.Request) { @@ -439,16 +468,20 @@ func (h *handlerImpl) sessionEvents(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "session not found") return } - events, err := h.service.GetAOPEvents(r.Context(), id) + err := ServeSSEWithSnapshot(w, r, h.service.Hub(), sessionTopic(id), func() ([]HubEvent, error) { + events, err := h.service.GetAOPEvents(r.Context(), id) + if err != nil { + return nil, err + } + initial := make([]HubEvent, 0, len(events)) + for _, event := range events { + initial = append(initial, HubEvent{Type: "aop", Data: mustJSON(event)}) + } + return initial, nil + }, "_never") if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) - return - } - initial := make([]HubEvent, 0, len(events)) - for _, event := range events { - initial = append(initial, HubEvent{Type: "aop", Data: mustJSON(event)}) } - ServeSSEWithInitial(w, r, h.service.Hub(), sessionTopic(id), initial, "_never") } // ── SCO Nodes ── diff --git a/pkg/web/sse.go b/pkg/web/sse.go index 49737eb2..0a023b81 100644 --- a/pkg/web/sse.go +++ b/pkg/web/sse.go @@ -91,7 +91,31 @@ func ServeSSEWithInitial(w http.ResponseWriter, r *http.Request, hub *Hub, id st serveSSE(w, r, hub, id, initial, terminalEvents...) } +func ServeSSEWithSnapshot( + w http.ResponseWriter, + r *http.Request, + hub *Hub, + id string, + snapshot func() ([]HubEvent, error), + terminalEvents ...string, +) error { + ch, unsubscribe := hub.Subscribe(id) + defer unsubscribe() + initial, err := snapshot() + if err != nil { + return err + } + serveSSEChannel(w, r, ch, initial, terminalEvents...) + return nil +} + func serveSSE(w http.ResponseWriter, r *http.Request, hub *Hub, id string, initial []HubEvent, terminalEvents ...string) { + ch, unsubscribe := hub.Subscribe(id) + defer unsubscribe() + serveSSEChannel(w, r, ch, initial, terminalEvents...) +} + +func serveSSEChannel(w http.ResponseWriter, r *http.Request, ch <-chan HubEvent, initial []HubEvent, terminalEvents ...string) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) @@ -105,10 +129,12 @@ func serveSSE(w http.ResponseWriter, r *http.Request, hub *Hub, id string, initi w.WriteHeader(http.StatusOK) flusher.Flush() - ch, unsubscribe := hub.Subscribe(id) - defer unsubscribe() for _, event := range initial { fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, event.Data) + if isTerminalEvent(event.Type, terminalEvents) { + flusher.Flush() + return + } } flusher.Flush() diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index e202db37..300b4e1e 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -3,10 +3,13 @@ package web import ( "context" "encoding/json" + "net/http/httptest" "path/filepath" + "strings" "testing" "time" + "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/aop" xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" ) @@ -173,3 +176,72 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) { t.Fatalf("empty-scanID persisted messages = %d, want 0", len(empty)) } } + +func TestScanEventsImmediatelyReplaysStoredTerminalState(t *testing.T) { + for _, tc := range []struct { + name string + status ScanStatus + want string + }{ + {name: "completed", status: StatusCompleted, want: "event: complete"}, + {name: "failed", status: StatusFailed, want: "event: error"}, + {name: "canceled", status: StatusCanceled, want: "\"status\":\"canceled\""}, + } { + t.Run(tc.name, func(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Now() + job := &ScanJob{ + ID: "terminal-scan", Target: "127.0.0.1", Mode: "quick", Status: tc.status, + Error: "scan failed", CreatedAt: now, UpdatedAt: now, + } + if tc.status == StatusCompleted { + job.Result = &output.Result{} + } + if err := store.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + + svc := NewService(ServiceConfig{Store: store}) + h := &handlerImpl{service: svc} + req := httptest.NewRequest("GET", "/api/scans/terminal-scan/events", nil) + req.SetPathValue("id", job.ID) + recorder := newLockedResponseRecorder() + done := make(chan struct{}) + go func() { + h.scanEvents(recorder, req) + close(done) + }() + select { + case <-done: + case <-time.After(200 * time.Millisecond): + t.Fatal("terminal scan SSE did not return immediately") + } + if body := recorder.BodyString(); !strings.Contains(body, tc.want) { + t.Fatalf("SSE body = %q, want %q", body, tc.want) + } + }) + } +} + +func TestServeSSEWithSnapshotSubscribesBeforeReadingSnapshot(t *testing.T) { + hub := NewHub() + req := httptest.NewRequest("GET", "/events", nil) + recorder := newLockedResponseRecorder() + + err := ServeSSEWithSnapshot(recorder, req, hub, "session-topic", func() ([]HubEvent, error) { + hub.Broadcast("session-topic", HubEvent{ + Type: "turn.end", Data: mustJSON(map[string]string{"stop": "completed"}), Reliable: true, + }) + return nil, nil + }, "turn.end") + if err != nil { + t.Fatal(err) + } + if body := recorder.BodyString(); !strings.Contains(body, "event: turn.end") { + t.Fatalf("SSE body = %q; event broadcast during snapshot was lost", body) + } +} From b2d619d7579769679908c2da59ed7708e846a2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:07:30 +0800 Subject: [PATCH 130/348] fix(web): reject oversized multipart uploads --- pkg/web/handler.go | 53 ++++++++++++---- pkg/web/service.go | 1 + pkg/web/upload_test.go | 138 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 pkg/web/upload_test.go diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 0f5f21c2..3c898cfb 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "errors" + "fmt" "io" "net/http" "strconv" @@ -416,29 +417,55 @@ func (h *handlerImpl) cancelSession(w http.ResponseWriter, r *http.Request) { const maxUploadSize = 50 << 20 // 50 MB -func (h *handlerImpl) uploadFile(w http.ResponseWriter, r *http.Request) { - // Bound the total request body before parsing so an oversized multipart - // upload can't exhaust memory/disk (gosec G120). Allow modest headroom over - // maxUploadSize for the multipart envelope (boundaries and part headers). - r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+(1<<20)) - if err := r.ParseMultipartForm(maxUploadSize); err != nil { //nolint:gosec // G120: body bounded by http.MaxBytesReader above - writeError(w, http.StatusBadRequest, "file too large or invalid multipart form") - return +var ErrUploadTooLarge = errors.New("uploaded file exceeds the size limit") + +func readMultipartUpload(w http.ResponseWriter, r *http.Request, maxSize int64) (string, []byte, error) { + if maxSize <= 0 { + return "", nil, fmt.Errorf("upload size limit must be positive") + } + r.Body = http.MaxBytesReader(w, r.Body, maxSize+(1<<20)) + if err := r.ParseMultipartForm(maxSize); err != nil { //nolint:gosec // G120: body is bounded above + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return "", nil, ErrUploadTooLarge + } + return "", nil, fmt.Errorf("parse multipart form: %w", err) } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + file, header, err := r.FormFile("file") if err != nil { - writeError(w, http.StatusBadRequest, "missing file field") - return + return "", nil, fmt.Errorf("missing file field: %w", err) } defer file.Close() + if header.Size > maxSize { + return "", nil, ErrUploadTooLarge + } - data, err := io.ReadAll(io.LimitReader(file, maxUploadSize)) + data, err := io.ReadAll(io.LimitReader(file, maxSize+1)) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to read file") + return "", nil, fmt.Errorf("read uploaded file: %w", err) + } + if int64(len(data)) > maxSize { + return "", nil, ErrUploadTooLarge + } + return header.Filename, data, nil +} + +func (h *handlerImpl) uploadFile(w http.ResponseWriter, r *http.Request) { + filename, data, err := readMultipartUpload(w, r, maxUploadSize) + if err != nil { + if errors.Is(err, ErrUploadTooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, ErrUploadTooLarge.Error()) + return + } + writeError(w, http.StatusBadRequest, err.Error()) return } - result, err := h.service.HandleFileUpload(r.Context(), r.PathValue("id"), header.Filename, data) + result, err := h.service.HandleFileUpload(r.Context(), r.PathValue("id"), filename, data) if err != nil { if errors.Is(err, ErrSessionNotFound) { writeError(w, http.StatusNotFound, ErrSessionNotFound.Error()) diff --git a/pkg/web/service.go b/pkg/web/service.go index a82fe97f..7082353a 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -1220,6 +1220,7 @@ func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename stri map[string]any{"filename": filename, "path": result.Path}) return &result, nil case <-ctx.Done(): + _ = s.agents.CancelTask(agentID, taskID) return nil, ctx.Err() } } diff --git a/pkg/web/upload_test.go b/pkg/web/upload_test.go new file mode 100644 index 00000000..d6a403d6 --- /dev/null +++ b/pkg/web/upload_test.go @@ -0,0 +1,138 @@ +package web + +import ( + "bytes" + "context" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func newMultipartUploadRequest(t *testing.T, filename string, data []byte) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/upload", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func TestReadMultipartUploadEnforcesExactFileLimit(t *testing.T) { + for _, size := range []int{7, 8} { + req := newMultipartUploadRequest(t, "note.txt", bytes.Repeat([]byte("x"), size)) + filename, data, err := readMultipartUpload(httptest.NewRecorder(), req, 8) + if err != nil { + t.Fatalf("size %d: %v", size, err) + } + if filename != "note.txt" || len(data) != size { + t.Fatalf("size %d: filename=%q bytes=%d", size, filename, len(data)) + } + } + + req := newMultipartUploadRequest(t, "large.txt", bytes.Repeat([]byte("x"), 9)) + if _, _, err := readMultipartUpload(httptest.NewRecorder(), req, 8); !errors.Is(err, ErrUploadTooLarge) { + t.Fatalf("size 9 error = %v, want ErrUploadTooLarge", err) + } +} + +func TestReadMultipartUploadRejectsMalformedBody(t *testing.T) { + req := httptest.NewRequest("POST", "/upload", bytes.NewBufferString("not multipart")) + req.Header.Set("Content-Type", "multipart/form-data; boundary=missing") + if _, _, err := readMultipartUpload(httptest.NewRecorder(), req, 8); err == nil || errors.Is(err, ErrUploadTooLarge) { + t.Fatalf("malformed multipart error = %v", err) + } +} + +func TestUploadReturnsNotFoundForMissingSession(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "upload.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") + req := newMultipartUploadRequest(t, "note.txt", []byte("hello")) + req.URL.Path = "/api/chat/sessions/missing/upload" + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s; want 404", recorder.Code, recorder.Body.String()) + } +} + +func TestHandleFileUploadCancellationRemovesPendingAgentTask(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "upload.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + createStoredSession(t, store, "upload-session") + session, err := store.GetSession(context.Background(), "upload-session") + if err != nil { + t.Fatal(err) + } + session.AgentID = "upload-agent" + if _, err := store.db.Exec(`UPDATE chat_sessions SET agent_id = ? WHERE id = ?`, session.AgentID, session.ID); err != nil { + t.Fatal(err) + } + + pool := NewAgentPool(NewHub()) + remote := newFakeAgent(session.AgentID, 1) + pool.register(remote) + svc := NewService(ServiceConfig{Store: store, AgentPool: pool}) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := svc.HandleFileUpload(ctx, session.ID, "note.txt", []byte("hello")) + done <- err + }() + + var upload webproto.Message + select { + case upload = <-remote.sendCh: + case <-time.After(time.Second): + t.Fatal("upload was not dispatched") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("upload cancellation error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("upload did not return after request cancellation") + } + + remote.mu.Lock() + _, pending := remote.tasks[upload.TaskID] + remote.mu.Unlock() + if pending { + t.Fatal("canceled upload remained in the agent task map") + } + select { + case msg := <-remote.controlCh: + if msg.Type != webproto.TypeRunCancel || msg.TurnID != upload.TaskID { + t.Fatalf("upload cancel frame = %+v", msg) + } + default: + t.Fatal("upload cancellation was not sent to the agent") + } +} From 44a836c00fdeca8039fdebe17a6c3b8b7b85fe5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:07:51 +0800 Subject: [PATCH 131/348] fix(web): make config reload transactional --- cmd/aiscan/config_replace_unix.go | 9 ++ cmd/aiscan/config_replace_windows.go | 21 +++ cmd/aiscan/web_full.go | 90 +++++++++-- cmd/aiscan/web_full_test.go | 74 +++++++++ docs/mechanisms.md | 13 +- pkg/web/agents.go | 61 ++++++-- pkg/web/config_reload_test.go | 23 +++ pkg/web/config_transaction_test.go | 220 +++++++++++++++++++++++++++ pkg/web/llm_probe_test.go | 10 +- pkg/web/service.go | 138 ++++++++++++++--- 10 files changed, 612 insertions(+), 47 deletions(-) create mode 100644 cmd/aiscan/config_replace_unix.go create mode 100644 cmd/aiscan/config_replace_windows.go create mode 100644 cmd/aiscan/web_full_test.go create mode 100644 pkg/web/config_transaction_test.go diff --git a/cmd/aiscan/config_replace_unix.go b/cmd/aiscan/config_replace_unix.go new file mode 100644 index 00000000..891537b4 --- /dev/null +++ b/cmd/aiscan/config_replace_unix.go @@ -0,0 +1,9 @@ +//go:build full && !windows + +package main + +import "os" + +func replaceConfigFile(source, target string) error { + return os.Rename(source, target) +} diff --git a/cmd/aiscan/config_replace_windows.go b/cmd/aiscan/config_replace_windows.go new file mode 100644 index 00000000..8532e473 --- /dev/null +++ b/cmd/aiscan/config_replace_windows.go @@ -0,0 +1,21 @@ +//go:build full && windows + +package main + +import "golang.org/x/sys/windows" + +func replaceConfigFile(source, target string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + return windows.MoveFileEx( + from, + to, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH, + ) +} diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 8b09e043..fdd24591 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -50,10 +50,14 @@ func runWeb(ctx context.Context, option *cfg.Option, opts webCommand, logger tel configFile := option.ConfigFile appOption := *option service := web.NewService(web.ServiceConfig{ - Store: store, - App: application, - ConfigStore: &webConfigStore{explicit: configFile}, - AppFactory: func(ctx context.Context) (*runner.App, error) { return initWebApp(ctx, &appOption, logger) }, + Store: store, + App: application, + ConfigStore: &webConfigStore{explicit: configFile}, + AppFactory: func(ctx context.Context, prepared *web.PreparedConfig) (*runner.App, error) { + candidateOption := appOption + candidateOption.ConfigFile = prepared.RuntimePath + return initWebApp(ctx, &candidateOption, logger) + }, MaxConcurrent: opts.MaxScans, ScanTimeout: time.Duration(opts.ScanTimeout) * time.Second, }) @@ -242,9 +246,9 @@ func parseDistributeConfig(data []byte) webproto.DistributeConfig { return dc } -func (s *webConfigStore) SaveDistributeConfig(ctx context.Context, incoming webproto.DistributeConfig) error { +func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming webproto.DistributeConfig) (*web.PreparedConfig, error) { if err := ctx.Err(); err != nil { - return err + return nil, err } s.mu.Lock() defer s.mu.Unlock() @@ -252,9 +256,11 @@ func (s *webConfigStore) SaveDistributeConfig(ctx context.Context, incoming webp p, loaded := s.resolveConfigPath() var current webproto.DistributeConfig if loaded { - if data, err := os.ReadFile(p); err == nil { - current = parseDistributeConfig(data) + data, err := os.ReadFile(p) + if err != nil { + return nil, err } + current = parseDistributeConfig(data) } // Preserve existing secrets when incoming value is empty. @@ -266,13 +272,75 @@ func (s *webConfigStore) SaveDistributeConfig(ctx context.Context, incoming webp preserveSecret(&incoming.Search.TavilyKeys, current.Search.TavilyKeys) preserveSecret(&incoming.IOA.Token, current.IOA.Token) - next, _ := yaml.Marshal(&incoming) + next, err := yaml.Marshal(&incoming) + if err != nil { + return nil, err + } if dir := filepath.Dir(p); dir != "." && dir != "" { if err := os.MkdirAll(dir, 0755); err != nil { - return err + return nil, err } } - return os.WriteFile(p, next, 0600) + dir := filepath.Dir(p) + if dir == "" { + dir = "." + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(p)+".tmp-*") + if err != nil { + return nil, err + } + tmpPath := tmp.Name() + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + if err := tmp.Chmod(0600); err != nil { + cleanup() + return nil, err + } + if _, err := tmp.Write(next); err != nil { + cleanup() + return nil, err + } + if err := tmp.Sync(); err != nil { + cleanup() + return nil, err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return nil, err + } + return &web.PreparedConfig{ + Config: incoming, RuntimePath: tmpPath, TargetPath: p, + }, nil +} + +func (s *webConfigStore) CommitDistributeConfig(ctx context.Context, prepared *web.PreparedConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if prepared == nil || prepared.RuntimePath == "" || prepared.TargetPath == "" { + return fmt.Errorf("prepared config is incomplete") + } + s.mu.Lock() + defer s.mu.Unlock() + if err := replaceConfigFile(prepared.RuntimePath, prepared.TargetPath); err != nil { + return err + } + prepared.RuntimePath = "" + if dir, err := os.Open(filepath.Dir(prepared.TargetPath)); err == nil { + _ = dir.Sync() + _ = dir.Close() + } + return nil +} + +func (s *webConfigStore) DiscardDistributeConfig(prepared *web.PreparedConfig) { + if prepared == nil || prepared.RuntimePath == "" { + return + } + _ = os.Remove(prepared.RuntimePath) + prepared.RuntimePath = "" } func preserveSecret(incoming *string, existing string) { diff --git a/cmd/aiscan/web_full_test.go b/cmd/aiscan/web_full_test.go new file mode 100644 index 00000000..c0684add --- /dev/null +++ b/cmd/aiscan/web_full_test.go @@ -0,0 +1,74 @@ +//go:build full + +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/chainreactors/aiscan/pkg/webproto" + "gopkg.in/yaml.v3" +) + +func TestWebConfigStoreStagesBeforeAtomicCommit(t *testing.T) { + path := filepath.Join(t.TempDir(), "aiscan.yaml") + old := configForWebStore("old-model", "secret-key") + oldBytes, err := yaml.Marshal(&old) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, oldBytes, 0600); err != nil { + t.Fatal(err) + } + + store := &webConfigStore{explicit: path} + incoming := configForWebStore("new-model", "") + prepared, err := store.PrepareDistributeConfig(context.Background(), incoming) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.DiscardDistributeConfig(prepared) }) + + committedBytes, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(committedBytes) != string(oldBytes) { + t.Fatal("PrepareDistributeConfig() changed the committed file") + } + if prepared.RuntimePath == "" || prepared.RuntimePath == path { + t.Fatalf("runtime candidate path = %q", prepared.RuntimePath) + } + info, err := os.Stat(prepared.RuntimePath) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Fatalf("candidate permissions = %o, want 600", perm) + } + if got := prepared.Config.LLM.Active().APIKey; got != "secret-key" { + t.Fatalf("prepared API key = %q, want preserved secret", got) + } + + if err := store.CommitDistributeConfig(context.Background(), prepared); err != nil { + t.Fatal(err) + } + _, loaded, committed, err := store.GetDistributeConfig(context.Background()) + if err != nil { + t.Fatal(err) + } + if !loaded || committed.LLM.Active().Model != "new-model" || committed.LLM.Active().APIKey != "secret-key" { + t.Fatalf("committed config = %+v", committed.LLM) + } +} + +func configForWebStore(model, apiKey string) webproto.DistributeConfig { + var cfg webproto.DistributeConfig + cfg.LLM.ActiveProfile = "primary" + cfg.LLM.Providers = []webproto.LLMProviderConfig{{ + ID: "primary", Provider: "openai", Model: model, APIKey: apiKey, + }} + return cfg +} diff --git a/docs/mechanisms.md b/docs/mechanisms.md index e61538ac..cd6ce37d 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -39,7 +39,12 @@ ``` Settings UI 保存 - → Service.SaveConfig() 重建 hub 的 App (同步) + → Service.SaveConfig() 串行化保存请求 + → PrepareDistributeConfig() 同目录写 0600 临时文件并 fsync + → AppFactory 从临时文件完整构建候选 App + → CommitDistributeConfig() 原子替换正式配置 + → swapApp() 将新请求切到候选 App + └─ 旧 App 标记 retired,最后一个活动租约释放后才 Close → BroadcastConfigReload() 向所有 agent 推 "config" 消息 (非阻塞) → agent 收到后异步: FetchRemoteConfig(hubURL) 拉取最新配置 @@ -51,11 +56,11 @@ Settings UI 保存 → hub 合并 identity → UI 徽章实时更新 ``` -**失败隔离**: 重建 provider 失败时旧 provider 不变,日志记录原因。channel 满则跳过,agent 下次重连自然拉取。 +**失败隔离**: 候选 App 构建失败时删除临时文件,正式配置和旧 App 都不变;原子提交失败时同时关闭候选 App。只有配置落盘成功后才交换 App 和通知 agent。agent 重建 provider 失败时保留旧 provider;reload 已排队或正等待控制 channel 空间时,后续请求会合并,agent 拉取的仍是最新正式配置。 -**并发模型**: `Agent.SetProvider()` / `SetMaxTurns()` 在 `mu.Lock` 下修改 `Cfg`。`Run`/`Continue` 开始时 `configSnapshot()` 在锁下拷贝,已在飞的 run 不受影响。 +**并发模型**: hub 的 `saveMu` 防止多个配置事务交错;本地扫描通过 managed App 租约继续使用旧运行时,不会被保存设置中断。agent 侧 `Agent.SetProvider()` / `SetMaxTurns()` 在 `mu.Lock` 下修改 `Cfg`,`Run`/`Continue` 开始时 `configSnapshot()` 在锁下拷贝,已在飞的 run 不受影响。 -**文件**: `pkg/web/agents.go`, `pkg/webagent/agent.go`, `core/runner/runner.go`, `pkg/agent/agent.go` +**文件**: `pkg/web/service.go`, `cmd/aiscan/web_full.go`, `pkg/web/agents.go`, `pkg/webagent/agent.go`, `core/runner/runner.go`, `pkg/agent/agent.go` --- diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 8ddc8172..eb574d92 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -64,6 +64,7 @@ type remoteAgent struct { // session.start's parent_session_id. Only a ROOT session.end converges the // task; child ends are lifecycle noise. childSessions map[string]map[string]struct{} + reloadPending bool done chan struct{} } @@ -405,9 +406,9 @@ func (p *AgentPool) dispatchMessage(agentID, taskID string, msg webproto.Message // BroadcastConfigReload notifies every connected agent that the hub config // changed so each re-fetches and hot-swaps its LLM provider without a restart. -// Config notifications use a dedicated control channel so task output cannot -// starve or silently drop a provider change. A full channel already contains a -// pending reload, so the latest persisted config will still be fetched. +// Config notifications use the control channel so task output cannot starve a +// provider change. Repeated reloads are coalesced while one is queued or waiting +// for control-channel capacity; the agent always fetches the latest config. func (p *AgentPool) BroadcastConfigReload() int { p.mu.RLock() agents := make([]*remoteAgent, 0, len(p.agents)) @@ -417,18 +418,54 @@ func (p *AgentPool) BroadcastConfigReload() int { p.mu.RUnlock() n := 0 for _, a := range agents { - select { - case a.controlCh <- webproto.Message{Type: "config"}: - n++ - default: - // A pending config control frame already causes the agent to fetch the - // newest persisted config, so this update is effectively coalesced. + if a.queueConfigReload() { n++ } } return n } +func (a *remoteAgent) queueConfigReload() bool { + if a == nil || a.controlCh == nil { + return false + } + a.mu.Lock() + if a.reloadPending { + a.mu.Unlock() + return true + } + a.reloadPending = true + a.mu.Unlock() + + msg := webproto.Message{Type: "config"} + select { + case a.controlCh <- msg: + return true + default: + } + + go func() { + if a.done == nil { + a.controlCh <- msg + return + } + select { + case a.controlCh <- msg: + case <-a.done: + a.mu.Lock() + a.reloadPending = false + a.mu.Unlock() + } + }() + return true +} + +func (a *remoteAgent) finishConfigReload() { + a.mu.Lock() + a.reloadPending = false + a.mu.Unlock() +} + func (p *AgentPool) SendAgentMessage(agentID string, msg webproto.Message) error { a := p.get(agentID) if a == nil { @@ -739,6 +776,9 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { // Give control frames priority over task/output traffic. select { case msg := <-agent.controlCh: + if msg.Type == "config" { + agent.finishConfigReload() + } if err := conn.WriteJSON(msg); err != nil { closeBrokenConnection() return @@ -748,6 +788,9 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { } select { case msg := <-agent.controlCh: + if msg.Type == "config" { + agent.finishConfigReload() + } if err := conn.WriteJSON(msg); err != nil { closeBrokenConnection() return diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go index 782541a0..5fd0b76a 100644 --- a/pkg/web/config_reload_test.go +++ b/pkg/web/config_reload_test.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "testing" + "time" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -50,6 +51,28 @@ func TestBroadcastConfigReload(t *testing.T) { } } +func TestBroadcastConfigReloadWaitsBehindCancellationFrames(t *testing.T) { + pool := NewAgentPool(nil) + agent := newFakeAgent("busy-control", 1) + agent.controlCh <- WSMessage{Type: webproto.TypeRunCancel, TurnID: "task-1"} + pool.register(agent) + + if n := pool.BroadcastConfigReload(); n != 1 { + t.Fatalf("notified = %d, want 1", n) + } + if msg := <-agent.controlCh; msg.Type != webproto.TypeRunCancel { + t.Fatalf("first control frame = %q, want %q", msg.Type, webproto.TypeRunCancel) + } + select { + case msg := <-agent.controlCh: + if msg.Type != "config" { + t.Fatalf("queued control frame = %q, want config", msg.Type) + } + case <-time.After(time.Second): + t.Fatal("config reload was dropped behind a full cancellation queue") + } +} + func TestHandleAgentStatusUpdate(t *testing.T) { pool := NewAgentPool(nil) a := newFakeAgent("n1", 1) diff --git a/pkg/web/config_transaction_test.go b/pkg/web/config_transaction_test.go new file mode 100644 index 00000000..9a5dd9e3 --- /dev/null +++ b/pkg/web/config_transaction_test.go @@ -0,0 +1,220 @@ +package web + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/runner" + "github.com/chainreactors/aiscan/pkg/webproto" +) + +type transactionalConfigStore struct { + mu sync.Mutex + cfg webproto.DistributeConfig + commitErr error + discarded int + prepareLog []string +} + +func (s *transactionalConfigStore) GetDistributeConfig(context.Context) (string, bool, webproto.DistributeConfig, error) { + s.mu.Lock() + defer s.mu.Unlock() + return "config.yaml", true, s.cfg, nil +} + +func (s *transactionalConfigStore) PrepareDistributeConfig(_ context.Context, cfg webproto.DistributeConfig) (*PreparedConfig, error) { + s.mu.Lock() + s.prepareLog = append(s.prepareLog, cfg.LLM.Active().Model) + s.mu.Unlock() + return &PreparedConfig{Config: cfg, TargetPath: "config.yaml"}, nil +} + +func (s *transactionalConfigStore) CommitDistributeConfig(_ context.Context, prepared *PreparedConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.commitErr != nil { + return s.commitErr + } + s.cfg = prepared.Config + return nil +} + +func (s *transactionalConfigStore) DiscardDistributeConfig(*PreparedConfig) { + s.mu.Lock() + s.discarded++ + s.mu.Unlock() +} + +type recordingCloser struct { + once sync.Once + done chan struct{} +} + +func newRecordingApp() (*runner.App, <-chan struct{}) { + closer := &recordingCloser{done: make(chan struct{})} + return &runner.App{Engines: closer}, closer.done +} + +func (c *recordingCloser) Close() { + c.once.Do(func() { close(c.done) }) +} + +func configForModel(model string) webproto.DistributeConfig { + var cfg webproto.DistributeConfig + cfg.LLM.ActiveProfile = "primary" + cfg.LLM.Providers = []webproto.LLMProviderConfig{{ID: "primary", Provider: "openai", Model: model}} + return cfg +} + +func TestSaveConfigBuildFailureKeepsCommittedConfigAndCurrentApp(t *testing.T) { + store := &transactionalConfigStore{cfg: configForModel("old-model")} + oldApp, oldClosed := newRecordingApp() + svc := NewService(ServiceConfig{ + App: oldApp, ConfigStore: store, + AppFactory: func(_ context.Context, prepared *PreparedConfig) (*runner.App, error) { + if got := prepared.Config.LLM.Active().Model; got != "new-model" { + t.Fatalf("candidate model = %q", got) + } + return nil, errors.New("candidate build failed") + }, + }) + + if _, err := svc.SaveConfig(context.Background(), configForModel("new-model")); err == nil { + t.Fatal("SaveConfig() succeeded despite candidate build failure") + } + _, _, committed, err := store.GetDistributeConfig(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := committed.LLM.Active().Model; got != "old-model" { + t.Fatalf("committed model = %q, want old-model", got) + } + app, release := svc.acquireApp() + defer release() + if app != oldApp { + t.Fatal("build failure replaced the current app") + } + select { + case <-oldClosed: + t.Fatal("build failure closed the current app") + default: + } + if store.discarded != 1 { + t.Fatalf("discarded candidates = %d, want 1", store.discarded) + } +} + +func TestSaveConfigCommitFailureClosesCandidateAndKeepsCurrentApp(t *testing.T) { + store := &transactionalConfigStore{cfg: configForModel("old-model"), commitErr: errors.New("disk full")} + oldApp, oldClosed := newRecordingApp() + candidateApp, candidateClosed := newRecordingApp() + svc := NewService(ServiceConfig{ + App: oldApp, ConfigStore: store, + AppFactory: func(context.Context, *PreparedConfig) (*runner.App, error) { + return candidateApp, nil + }, + }) + + if _, err := svc.SaveConfig(context.Background(), configForModel("new-model")); err == nil { + t.Fatal("SaveConfig() succeeded despite commit failure") + } + select { + case <-candidateClosed: + default: + t.Fatal("candidate app was not closed after commit failure") + } + select { + case <-oldClosed: + t.Fatal("commit failure closed the current app") + default: + } + app, release := svc.acquireApp() + defer release() + if app != oldApp { + t.Fatal("commit failure replaced the current app") + } +} + +func TestSwapAppDefersOldCloseUntilActiveLeaseReleases(t *testing.T) { + oldApp, oldClosed := newRecordingApp() + nextApp, _ := newRecordingApp() + svc := NewService(ServiceConfig{App: oldApp}) + + leased, release := svc.acquireApp() + if leased != oldApp { + t.Fatal("acquireApp() returned the wrong app") + } + svc.swapApp(nextApp) + select { + case <-oldClosed: + t.Fatal("old app closed while a scan still held a lease") + default: + } + release() + select { + case <-oldClosed: + default: + t.Fatal("old app remained open after the final lease released") + } +} + +func TestSaveConfigSerializesConcurrentCandidates(t *testing.T) { + store := &transactionalConfigStore{cfg: configForModel("old-model")} + oldApp, _ := newRecordingApp() + entered := make(chan string, 2) + releaseFirst := make(chan struct{}) + svc := NewService(ServiceConfig{ + App: oldApp, ConfigStore: store, + AppFactory: func(_ context.Context, prepared *PreparedConfig) (*runner.App, error) { + model := prepared.Config.LLM.Active().Model + entered <- model + if model == "first-model" { + <-releaseFirst + } + app, _ := newRecordingApp() + return app, nil + }, + }) + t.Cleanup(svc.Close) + + firstDone := make(chan error, 1) + go func() { + _, err := svc.SaveConfig(context.Background(), configForModel("first-model")) + firstDone <- err + }() + if got := <-entered; got != "first-model" { + t.Fatalf("first candidate = %q", got) + } + + secondDone := make(chan error, 1) + go func() { + _, err := svc.SaveConfig(context.Background(), configForModel("second-model")) + secondDone <- err + }() + select { + case model := <-entered: + t.Fatalf("second candidate %q entered before first commit", model) + case <-time.After(50 * time.Millisecond): + } + + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + if got := <-entered; got != "second-model" { + t.Fatalf("second candidate = %q", got) + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } + _, _, committed, err := store.GetDistributeConfig(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := committed.LLM.Active().Model; got != "second-model" { + t.Fatalf("final committed model = %q", got) + } +} diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go index 394d5e4a..5f1c012d 100644 --- a/pkg/web/llm_probe_test.go +++ b/pkg/web/llm_probe_test.go @@ -21,11 +21,17 @@ func (f *fakeConfigStore) GetDistributeConfig(ctx context.Context) (string, bool return "config.yaml", true, f.cfg, nil } -func (f *fakeConfigStore) SaveDistributeConfig(ctx context.Context, cfg webproto.DistributeConfig) error { - f.cfg = cfg +func (f *fakeConfigStore) PrepareDistributeConfig(_ context.Context, cfg webproto.DistributeConfig) (*PreparedConfig, error) { + return &PreparedConfig{Config: cfg, TargetPath: "config.yaml"}, nil +} + +func (f *fakeConfigStore) CommitDistributeConfig(_ context.Context, prepared *PreparedConfig) error { + f.cfg = prepared.Config return nil } +func (f *fakeConfigStore) DiscardDistributeConfig(*PreparedConfig) {} + // stubLLMServer emulates an OpenAI-compatible /chat/completions endpoint and // records the Authorization header it received. func stubLLMServer(t *testing.T, reply string, gotAuth *string) *httptest.Server { diff --git a/pkg/web/service.go b/pkg/web/service.go index 7082353a..9ce4e409 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -34,14 +34,22 @@ var hubCommands = map[string]bool{"scan": true, "agents": true, "help": true} type ConfigStore interface { GetDistributeConfig(ctx context.Context) (path string, loaded bool, cfg webproto.DistributeConfig, err error) - SaveDistributeConfig(ctx context.Context, cfg webproto.DistributeConfig) error + PrepareDistributeConfig(ctx context.Context, cfg webproto.DistributeConfig) (*PreparedConfig, error) + CommitDistributeConfig(ctx context.Context, prepared *PreparedConfig) error + DiscardDistributeConfig(prepared *PreparedConfig) +} + +type PreparedConfig struct { + Config webproto.DistributeConfig + RuntimePath string + TargetPath string } type ServiceConfig struct { Store *SQLiteStore App *runner.App ConfigStore ConfigStore - AppFactory func(ctx context.Context) (*runner.App, error) + AppFactory func(ctx context.Context, prepared *PreparedConfig) (*runner.App, error) AgentPool *AgentPool MaxConcurrent int ScanTimeout time.Duration @@ -49,10 +57,11 @@ type ServiceConfig struct { type Service struct { store *SQLiteStore - appMu sync.RWMutex - app *runner.App + appMu sync.Mutex + app *managedApp + saveMu sync.Mutex config ConfigStore - reload func(ctx context.Context) (*runner.App, error) + reload func(ctx context.Context, prepared *PreparedConfig) (*runner.App, error) agents *AgentPool hub *Hub sem chan struct{} @@ -66,6 +75,13 @@ type Service struct { taskCanceled map[string]bool } +type managedApp struct { + app *runner.App + refs int + retired bool + closed bool +} + func NewService(cfg ServiceConfig) *Service { maxConcurrent := cfg.MaxConcurrent if maxConcurrent <= 0 { @@ -77,7 +93,7 @@ func NewService(cfg ServiceConfig) *Service { } svc := &Service{ store: cfg.Store, - app: cfg.App, + app: wrapManagedApp(cfg.App), config: cfg.ConfigStore, reload: cfg.AppFactory, agents: cfg.AgentPool, @@ -117,8 +133,9 @@ func (s *Service) Close() { cancel() } s.appMu.Lock() - app := s.app + current := s.app s.app = nil + app := retireManagedApp(current) s.appMu.Unlock() if app != nil { app.Close() @@ -126,7 +143,7 @@ func (s *Service) Close() { } func (s *Service) Status() ServiceStatus { - app := s.appSnapshot() + app, release := s.acquireApp() status := ServiceStatus{ Version: config.Version, LLMAvailable: app != nil && app.Provider != nil, @@ -136,6 +153,7 @@ func (s *Service) Status() ServiceStatus { status.LLMModel = app.ProviderConfig.Model status.LLMAPIKeyConfigured = strings.TrimSpace(app.ProviderConfig.APIKey) != "" } + release() if s.config != nil { if path, loaded, dc, err := s.config.GetDistributeConfig(context.Background()); err == nil { status.ConfigPath = path @@ -165,22 +183,51 @@ func (s *Service) GetConfigStatus(ctx context.Context) (ConfigStatus, error) { } func (s *Service) SaveConfig(ctx context.Context, cfg webproto.DistributeConfig) (ConfigStatus, error) { + s.saveMu.Lock() + defer s.saveMu.Unlock() if s.config == nil { return ConfigStatus{}, fmt.Errorf("config store is not configured") } if err := ValidateLLMConfig(cfg.LLM); err != nil { return ConfigStatus{}, err } - if err := s.config.SaveDistributeConfig(ctx, cfg); err != nil { + prepared, err := s.config.PrepareDistributeConfig(ctx, cfg) + if err != nil { + return ConfigStatus{}, err + } + committed := false + defer func() { + if !committed { + s.config.DiscardDistributeConfig(prepared) + } + }() + if prepared == nil { + return ConfigStatus{}, fmt.Errorf("config store returned no prepared config") + } + if err := ValidateLLMConfig(prepared.Config.LLM); err != nil { return ConfigStatus{}, err } + + var nextApp *runner.App if s.reload != nil { - app, err := s.reload(ctx) + nextApp, err = s.reload(ctx, prepared) if err != nil { cs, _ := s.GetConfigStatus(ctx) return cs, fmt.Errorf("reload aiscan runtime: %w", err) } - s.swapApp(app) + if nextApp == nil { + return ConfigStatus{}, fmt.Errorf("reload aiscan runtime returned no app") + } + } + if err := s.config.CommitDistributeConfig(ctx, prepared); err != nil { + if nextApp != nil { + nextApp.Close() + } + return ConfigStatus{}, err + } + committed = true + if nextApp != nil { + s.swapApp(nextApp) } // Tell connected agents to hot-swap their own provider too — the hub reload // above only refreshes the hub's in-process runtime, not the agent subprocesses. @@ -580,17 +627,60 @@ func (s *Service) failJob(job *ScanJob, errMsg string) (bool, error) { } func (s *Service) aiAvailable() bool { - app := s.appSnapshot() + app, release := s.acquireApp() + defer release() return app != nil && app.Provider != nil } -func (s *Service) appSnapshot() *runner.App { - if s == nil { +func wrapManagedApp(app *runner.App) *managedApp { + if app == nil { return nil } - s.appMu.RLock() - defer s.appMu.RUnlock() - return s.app + return &managedApp{app: app} +} + +func retireManagedApp(ref *managedApp) *runner.App { + if ref == nil || ref.closed { + return nil + } + ref.retired = true + if ref.refs != 0 { + return nil + } + ref.closed = true + return ref.app +} + +func (s *Service) acquireApp() (*runner.App, func()) { + if s == nil { + return nil, func() {} + } + s.appMu.Lock() + ref := s.app + if ref != nil && !ref.closed { + ref.refs++ + } + s.appMu.Unlock() + if ref == nil || ref.closed { + return nil, func() {} + } + + var once sync.Once + return ref.app, func() { + once.Do(func() { + var closeApp *runner.App + s.appMu.Lock() + ref.refs-- + if ref.refs == 0 && ref.retired && !ref.closed { + ref.closed = true + closeApp = ref.app + } + s.appMu.Unlock() + if closeApp != nil { + closeApp.Close() + } + }) + } } func (s *Service) swapApp(next *runner.App) { @@ -599,10 +689,15 @@ func (s *Service) swapApp(next *runner.App) { } s.appMu.Lock() prev := s.app - s.app = next + if prev != nil && prev.app == next { + s.appMu.Unlock() + return + } + s.app = wrapManagedApp(next) + closeApp := retireManagedApp(prev) s.appMu.Unlock() - if prev != nil && prev != next { - prev.Close() + if closeApp != nil { + closeApp.Close() } } @@ -621,7 +716,8 @@ func scanArgsForJob(job *ScanJob) []string { } func (s *Service) executeScan(ctx context.Context, args []string, stream io.Writer) (string, *output.Result, error) { - app := s.appSnapshot() + app, release := s.acquireApp() + defer release() if app == nil || app.Commands == nil { return "", nil, fmt.Errorf("aiscan runtime is not ready") } From 72b7f57fa78e142bf54f2e4b683932284043f2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:08:34 +0800 Subject: [PATCH 132/348] chore(deps): restore tidy module metadata --- go.mod | 5 +---- go.sum | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 23da8b1b..c20e276e 100644 --- a/go.mod +++ b/go.mod @@ -51,10 +51,7 @@ require ( modernc.org/sqlite v1.40.1 ) -require ( - go.yaml.in/yaml/v2 v2.4.2 // indirect - sigs.k8s.io/yaml v1.6.0 -) +require sigs.k8s.io/yaml v1.6.0 // indirect require ( aead.dev/minisign v0.2.0 // indirect diff --git a/go.sum b/go.sum index c8534106..c8599085 100644 --- a/go.sum +++ b/go.sum @@ -1029,8 +1029,6 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= From 7878c8fe193e4030b91f12e7203529fa1dd7156d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E6=AD=A2?= <68958533+h3zh1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:08:41 +0800 Subject: [PATCH 133/348] test(web): run real browser terminal e2e --- .github/workflows/ci.yml | 24 +++-- pkg/web/agents_e2e_test.go | 13 +++ pkg/web/agents_test.go | 210 ++++++++++++++++++++++--------------- 3 files changed, 153 insertions(+), 94 deletions(-) create mode 100644 pkg/web/agents_e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f76d78ff..16baa277 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,17 +227,25 @@ jobs: go-version-file: go.mod cache: true + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/frontend/package-lock.json + + - name: Build embedded frontend + run: | + npm --prefix web/frontend ci + npm --prefix web/frontend run build + test -s web/static/index.html - name: Run e2e tests run: | - if [ -d pkg/e2e ]; then - go test -race -count=1 -timeout 10m \ - -tags "e2e re2_cgo re2_static" \ - -v \ - ./pkg/e2e/ - else - echo "pkg/e2e not found, skipping" - fi + go test -race -count=1 -timeout 10m \ + -tags "e2e re2_cgo re2_static" \ + -v \ + ./pkg/web # ── Build (depends on test, 3 parallel profiles) ────────────── diff --git a/pkg/web/agents_e2e_test.go b/pkg/web/agents_e2e_test.go new file mode 100644 index 00000000..afa7b018 --- /dev/null +++ b/pkg/web/agents_e2e_test.go @@ -0,0 +1,13 @@ +//go:build e2e + +package web + +import "testing" + +func TestE2ETerminalOpenAndType(t *testing.T) { + runE2ETerminalOpenAndType(t) +} + +func TestE2ETerminalResize(t *testing.T) { + runE2ETerminalResize(t) +} diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index 0adda18e..8e91e346 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -798,29 +798,22 @@ func TestWSTerminalBufferPressure(t *testing.T) { func setupE2EServer(t *testing.T) (*httptest.Server, *AgentPool) { t.Helper() - hub := NewHub() - pool := NewAgentPool(hub) - mux := http.NewServeMux() - - mux.HandleFunc("/api/agent/ws", pool.HandleWS) - mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(pool.List()) - }) - mux.HandleFunc("GET /api/agents/{id}/terminal/ws", func(w http.ResponseWriter, r *http.Request) { - pool.HandleTerminalWS(r.PathValue("id"), w, r) - }) - mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"agents": len(pool.List()), "llm_available": false}) - }) + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "e2e.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + svc := NewService(ServiceConfig{Store: store}) + pool := NewAgentPool(svc.Hub()) + svc.SetAgentPool(pool) + t.Cleanup(svc.Close) staticSub, err := fs.Sub(webstatic.FS, "static") if err != nil { t.Fatal(err) } fileServer := http.FileServer(http.FS(staticSub)) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + static := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { http.NotFound(w, r) return @@ -835,12 +828,26 @@ func setupE2EServer(t *testing.T) (*httptest.Server, *AgentPool) { } }) - srv := httptest.NewServer(mux) + srv := httptest.NewServer(NewHandler(svc, pool, nil, nil, static, "")) t.Cleanup(srv.Close) + resp, err := http.Get(srv.URL + "/api/auth/session") //nolint:gosec // test-only local server + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("auth session route returned %d", resp.StatusCode) + } return srv, pool } -func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *websocket.Conn { +type mockBrowserAgent struct { + conn *websocket.Conn + messages chan WSMessage + errors chan error +} + +func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *mockBrowserAgent { t.Helper() wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agent/ws" conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) @@ -860,13 +867,34 @@ func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *websocket.C if ack.Type != "connected" { t.Fatalf("expected connected, got %s", ack.Type) } - return conn + agent := &mockBrowserAgent{ + conn: conn, messages: make(chan WSMessage, 64), errors: make(chan error, 1), + } + go func() { + defer close(agent.messages) + for { + var msg WSMessage + if err := conn.ReadJSON(&msg); err != nil { + agent.errors <- err + return + } + agent.messages <- msg + } + }() + return agent +} + +func (a *mockBrowserAgent) Close() error { + return a.conn.Close() } func launchBrowser(t *testing.T) *rod.Browser { t.Helper() path, ok := launcher.LookPath() if !ok { + if os.Getenv("CI") != "" { + t.Fatal("chromium not found in CI e2e environment") + } t.Skip("chromium not found, skipping browser e2e test") } u := launcher.New().Bin(path).Headless(true).Leakless(false). @@ -877,51 +905,70 @@ func launchBrowser(t *testing.T) *rod.Browser { return browser } -func drainAgentMessages(conn *websocket.Conn, timeout time.Duration) []WSMessage { +func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []WSMessage { var msgs []WSMessage - conn.SetReadDeadline(time.Now().Add(timeout)) + timer := time.NewTimer(timeout) + defer timer.Stop() for { - var m WSMessage - if err := conn.ReadJSON(&m); err != nil { - break + select { + case msg, ok := <-agent.messages: + if !ok { + return msgs + } + msgs = append(msgs, msg) + case <-timer.C: + return msgs } - msgs = append(msgs, m) } - conn.SetReadDeadline(time.Time{}) - return msgs } -func findPTYFrame(msgs []WSMessage, typ pty.FrameType) (pty.Frame, bool) { - for _, m := range msgs { - if m.Type != webproto.TypePTY { - continue - } - frame, err := webproto.DecodePTYMessage(m) - if err == nil && frame.Type == typ { - return frame, true +func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) pty.Frame { + t.Helper() + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case msg, ok := <-agent.messages: + if !ok { + t.Fatalf("agent connection closed while waiting for %s", want) + } + frame, err := webproto.DecodePTYMessage(msg) + if err == nil && frame.Type == want { + return frame + } + case err := <-agent.errors: + t.Fatalf("agent read PTY %s: %v", want, err) + case <-timer.C: + t.Fatalf("timed out waiting for agent PTY %s", want) } } - return pty.Frame{}, false +} + +func writeMockAgentPTY(t *testing.T, agent *mockBrowserAgent, frame pty.Frame) { + t.Helper() + if err := agent.conn.WriteJSON(webproto.NewPTYMessage(frame)); err != nil { + t.Fatalf("agent write PTY %s: %v", frame.Type, err) + } } func openFirstAgentTerminal(t *testing.T, page *rod.Page) { t.Helper() - if _, err := page.Timeout(500*time.Millisecond).ElementR("button", "Terminal"); err != nil { - if toggle, err := page.Timeout(500 * time.Millisecond).Element("button[aria-label='Expand sidebar']"); err == nil { + terminal, err := page.Timeout(5*time.Second).ElementR("button", "Terminal") + if err != nil { + if toggle, toggleErr := page.Timeout(5 * time.Second).Element("button[aria-label='Expand sidebar']"); toggleErr == nil { toggle.MustClick() - time.Sleep(200 * time.Millisecond) - page.MustWaitStable() + page.Timeout(5 * time.Second).MustWaitStable() } + terminal, err = page.Timeout(5*time.Second).ElementR("button", "Terminal") } - page.MustElementR("button", "Terminal").MustClick() - time.Sleep(500 * time.Millisecond) - page.MustWaitStable() + if err != nil { + t.Fatalf("terminal button not available: %v", err) + } + terminal.MustClick() + page.Timeout(5 * time.Second).MustWaitStable() } -func TestE2ETerminalOpenAndType(t *testing.T) { - if testing.Short() || os.Getenv("CI") != "" { - t.Skip("skipping e2e browser test (requires interactive terminal)") - } +func runE2ETerminalOpenAndType(t *testing.T) { srv, pool := setupE2EServer(t) agentConn := dialMockAgent(t, srv, "e2e-agent") defer agentConn.Close() @@ -932,23 +979,19 @@ func TestE2ETerminalOpenAndType(t *testing.T) { } browser := launchBrowser(t) - page := browser.MustPage(srv.URL).MustWaitStable() + page := browser.MustPage(srv.URL) + page.Timeout(5 * time.Second).MustWaitStable() openFirstAgentTerminal(t, page) // The terminal discovers the Runtime-owned REPL through pty.list; the browser // never creates it. - initial := drainAgentMessages(agentConn, time.Second) - - listMsg, ok := findPTYFrame(initial, pty.FrameList) - if !ok { - t.Fatalf("no pty.list received, got: %v", initial) - } - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: listMsg.StreamID, + listMsg := readMockAgentPTY(t, agentConn, pty.FrameList) + writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: listMsg.StreamID, Sessions: []pty.Info{{ID: "e2e-sess-1", Kind: "repl", Name: "main-repl", State: pty.StateRunning}}}) - attach := readAgentPTY(t, agentConn, pty.FrameAttach) + attach := readMockAgentPTY(t, agentConn, pty.FrameAttach) replStreamID := attach.StreamID - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameAttached, StreamID: attach.StreamID, + writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameAttached, StreamID: attach.StreamID, SessionID: "e2e-sess-1", Kind: "repl"}) time.Sleep(300 * time.Millisecond) @@ -981,32 +1024,22 @@ func TestE2ETerminalOpenAndType(t *testing.T) { } // Agent sends output back — verify the output path works - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOutput, StreamID: replStreamID, Data: []byte("hello\r\n")}) + writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOutput, StreamID: replStreamID, Data: []byte("hello\r\n")}) time.Sleep(300 * time.Millisecond) // Agent sends pty.closed - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameClosed, StreamID: replStreamID, + writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameClosed, StreamID: replStreamID, SessionID: "e2e-sess-1", State: pty.StateCompleted}) - time.Sleep(500 * time.Millisecond) - - // Verify xterm rendered "[session closed]" - termText := page.MustEval(`() => { - const rows = document.querySelectorAll('.xterm-rows > div'); - let text = ''; - rows.forEach(r => { text += r.textContent + '\\n'; }); - return text; - }`).Str() - if !strings.Contains(termText, "session closed") { - t.Logf("terminal content: %q", termText) + refresh := readMockAgentPTY(t, agentConn, pty.FrameList) + writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: refresh.StreamID}) + if _, err := page.Timeout(5 * time.Second).Element(`[title='Console'], [title='控制台']`); err != nil { + t.Fatalf("terminal did not return to its idle console after close: %v", err) } - t.Log("e2e terminal test: open → type → output → close verified") + t.Log("e2e terminal test: open → attach → input/output → close verified") } -func TestE2ETerminalResize(t *testing.T) { - if testing.Short() || os.Getenv("CI") != "" { - t.Skip("skipping e2e browser test (requires interactive terminal)") - } +func runE2ETerminalResize(t *testing.T) { srv, pool := setupE2EServer(t) agentConn := dialMockAgent(t, srv, "resize-agent") defer agentConn.Close() @@ -1017,18 +1050,21 @@ func TestE2ETerminalResize(t *testing.T) { } browser := launchBrowser(t) - page := browser.MustPage(srv.URL).MustWaitStable() + page := browser.MustPage(srv.URL) + page.Timeout(5 * time.Second).MustWaitStable() openFirstAgentTerminal(t, page) - // Drain initial messages and reply - initial := drainAgentMessages(agentConn, time.Second) - if open, ok := findPTYFrame(initial, pty.FrameOpen); ok { - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: open.StreamID, SessionID: "resize-sess"}) - } - if list, ok := findPTYFrame(initial, pty.FrameList); ok { - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: list.StreamID}) - } + list := readMockAgentPTY(t, agentConn, pty.FrameList) + writeMockAgentPTY(t, agentConn, pty.Frame{ + Type: pty.FrameSessions, StreamID: list.StreamID, + Sessions: []pty.Info{{ID: "resize-sess", Kind: "repl", Name: "resize-repl", State: pty.StateRunning}}, + }) + attach := readMockAgentPTY(t, agentConn, pty.FrameAttach) + writeMockAgentPTY(t, agentConn, pty.Frame{ + Type: pty.FrameAttached, StreamID: attach.StreamID, SessionID: "resize-sess", Kind: "repl", + }) + _ = drainAgentMessages(agentConn, 200*time.Millisecond) // Trigger resize by changing viewport page.MustSetViewport(1024, 768, 1, false) @@ -1044,7 +1080,9 @@ func TestE2ETerminalResize(t *testing.T) { break } } - t.Logf("resize message received: %v", resizeReceived) + if !resizeReceived { + t.Fatal("terminal resize did not reach the agent") + } } func TestCancelTaskConvergesPendingTaskImmediately(t *testing.T) { From 189570d702c34a4f08642d5ce4d8b7e8a8d84e92 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 00:02:24 +0800 Subject: [PATCH 134/348] fix(web): rebuild reload candidates from explicit config --- cmd/aiscan/cli.go | 5 ++- cmd/aiscan/web_full.go | 49 ++++++++++++---------- cmd/aiscan/web_full_test.go | 31 +++++++++++++- core/config/app_config.go | 26 ++++++------ core/config/config_gen.go | 7 +--- core/config/env.go | 13 +++++- core/config/loader.go | 19 +-------- core/config/loader_test.go | 81 ++++++++++++++++++++++++++++--------- core/config/options.go | 7 +++- pkg/webagent/remote.go | 2 +- 10 files changed, 158 insertions(+), 82 deletions(-) diff --git a/cmd/aiscan/cli.go b/cmd/aiscan/cli.go index f6ba2807..4a51d43d 100644 --- a/cmd/aiscan/cli.go +++ b/cmd/aiscan/cli.go @@ -24,7 +24,7 @@ import ( const runModeWeb cfg.RunMode = "web" // webServeFunc is set via init() in web_full.go (full build only). -var webServeFunc func(ctx context.Context, option *cfg.Option, web webCommand, logger telemetry.Logger) error +var webServeFunc func(ctx context.Context, option, explicitOption *cfg.Option, web webCommand, logger telemetry.Logger) error type webCommand struct { Addr string `long:"addr" default:"127.0.0.1:8080" description:"HTTP listen address"` @@ -109,6 +109,7 @@ func aiscan() { } option := parsed.Option + explicitOption := option if option.Version { fmt.Printf("aiscan v%s\n", cfg.Version) return @@ -175,7 +176,7 @@ func aiscan() { fmt.Fprintln(os.Stderr, "error: web server not available (requires full build)") os.Exit(1) } - if err := webServeFunc(ctx, &option, parsed.WebOpts, logger); err != nil { + if err := webServeFunc(ctx, &option, &explicitOption, parsed.WebOpts, logger); err != nil { logger.Errorf("web server failed: %s", err) os.Exit(1) } diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index fdd24591..4c75da50 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -31,7 +31,7 @@ func init() { webServeFunc = runWeb } -func runWeb(ctx context.Context, option *cfg.Option, opts webCommand, logger telemetry.Logger) error { +func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCommand, logger telemetry.Logger) error { store, err := web.NewSQLiteStore(opts.DB) if err != nil { return fmt.Errorf("open database: %s", err) @@ -48,30 +48,32 @@ func runWeb(ctx context.Context, option *cfg.Option, opts webCommand, logger tel } configFile := option.ConfigFile - appOption := *option service := web.NewService(web.ServiceConfig{ Store: store, App: application, ConfigStore: &webConfigStore{explicit: configFile}, AppFactory: func(ctx context.Context, prepared *web.PreparedConfig) (*runner.App, error) { - candidateOption := appOption + candidateOption := cfg.Option{} + if explicitOption != nil { + candidateOption = *explicitOption + } candidateOption.ConfigFile = prepared.RuntimePath - return initWebApp(ctx, &candidateOption, logger) + if _, err := cfg.ResolveRuntimeConfigCandidate(&candidateOption); err != nil { + return nil, err + } + candidate, err := initWebApp(ctx, &candidateOption, logger) + if err != nil { + return nil, err + } + wireWebApp(candidate, store) + return candidate, nil }, MaxConcurrent: opts.MaxScans, ScanTimeout: time.Duration(opts.ScanTimeout) * time.Second, }) defer service.Close() - if application.SCOSidecar != nil { - application.SCOSidecar.OnNodes = func(callID string, nodes []json.RawMessage) { - scanID := callID - if scanID == "" { - scanID = "standalone" - } - _ = store.UpsertSCONodes(context.Background(), scanID, nodes) - } - } + wireWebApp(application, store) var pool *web.AgentPool if option.Debug { @@ -141,6 +143,19 @@ func runWeb(ctx context.Context, option *cfg.Option, opts webCommand, logger tel return nil } +func wireWebApp(application *runner.App, store *web.SQLiteStore) { + if application == nil || store == nil || application.SCOSidecar == nil { + return + } + application.SCOSidecar.OnNodes = func(callID string, nodes []json.RawMessage) { + scanID := callID + if scanID == "" { + scanID = "standalone" + } + _ = store.UpsertSCONodes(context.Background(), scanID, nodes) + } +} + func newSPAFileServer(fsys fs.FS) http.HandlerFunc { indexBytes, _ := fs.ReadFile(fsys, "index.html") fileServer := http.FileServer(http.FS(fsys)) @@ -179,14 +194,6 @@ func initWebApp(ctx context.Context, baseOption *cfg.Option, logger telemetry.Lo if baseOption != nil { option = *baseOption } - cfgPath, err := cfg.ResolveRuntimeConfig(&option) - if err != nil { - return nil, err - } - if cfgPath != "" { - logger.Infof("loaded config: %s", cfgPath) - } - appCfg := cfg.AppConfig(&option, cfg.RuntimeFeatures{ ProviderEnabled: true, ProviderOptional: true, diff --git a/cmd/aiscan/web_full_test.go b/cmd/aiscan/web_full_test.go index c0684add..abe52acf 100644 --- a/cmd/aiscan/web_full_test.go +++ b/cmd/aiscan/web_full_test.go @@ -4,10 +4,15 @@ package main import ( "context" + "encoding/json" "os" "path/filepath" + "runtime" "testing" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/runner" + "github.com/chainreactors/aiscan/pkg/web" "github.com/chainreactors/aiscan/pkg/webproto" "gopkg.in/yaml.v3" ) @@ -45,7 +50,7 @@ func TestWebConfigStoreStagesBeforeAtomicCommit(t *testing.T) { if err != nil { t.Fatal(err) } - if perm := info.Mode().Perm(); perm != 0600 { + if perm := info.Mode().Perm(); runtime.GOOS != "windows" && perm != 0600 { t.Fatalf("candidate permissions = %o, want 600", perm) } if got := prepared.Config.LLM.Active().APIKey; got != "secret-key" { @@ -64,6 +69,30 @@ func TestWebConfigStoreStagesBeforeAtomicCommit(t *testing.T) { } } +func TestWireWebAppBindsSCONodesForReloadedApp(t *testing.T) { + store, err := web.NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + application := &runner.App{SCOSidecar: &output.SCOSidecar{}} + + wireWebApp(application, store) + if application.SCOSidecar.OnNodes == nil { + t.Fatal("reloaded app SCO sidecar callback was not bound") + } + application.SCOSidecar.OnNodes("scan-1", []json.RawMessage{ + json.RawMessage(`{"cstx_id":"node-1","cstx_type":"asset","data":{}}`), + }) + nodes, err := store.ListSCONodesByScanID(context.Background(), "scan-1", "", 10) + if err != nil { + t.Fatal(err) + } + if len(nodes) != 1 { + t.Fatalf("persisted SCO nodes = %d, want 1", len(nodes)) + } +} + func configForWebStore(model, apiKey string) webproto.DistributeConfig { var cfg webproto.DistributeConfig cfg.LLM.ActiveProfile = "primary" diff --git a/core/config/app_config.go b/core/config/app_config.go index 1edbb219..7f21298e 100644 --- a/core/config/app_config.go +++ b/core/config/app_config.go @@ -24,23 +24,23 @@ func AppConfig(option *Option, features RuntimeFeatures, logger telemetry.Logger Optional: features.ProviderOptional, }, Scanner: ScannerConfig{ - CyberhubURL: option.CyberhubURL, - CyberhubKey: option.CyberhubKey, - CyberhubMode: option.CyberhubMode, - AIEnabled: features.AIEnabled, - VerifyMode: DefaultVerify, - Proxy: option.Proxy, - FofaEmail: option.FofaEmail, - FofaKey: option.FofaKey, - HunterToken: option.HunterToken, - HunterAPIKey: option.HunterAPIKey, - ReconProxy: option.ReconProxy, - ReconLimit: intOptionValue(option.ReconLimit), + CyberhubURL: option.CyberhubURL, + CyberhubKey: option.CyberhubKey, + CyberhubMode: option.CyberhubMode, + AIEnabled: features.AIEnabled, + VerifyMode: ResolveString(option.ScanConfig.Verify, DefaultVerify), + Proxy: option.Proxy, + FofaEmail: option.FofaEmail, + FofaKey: option.FofaKey, + HunterToken: option.HunterToken, + HunterAPIKey: option.HunterAPIKey, + ReconProxy: option.ReconProxy, + ReconLimit: intOptionValue(option.ReconLimit), }, Tools: ToolConfig{ Enabled: features.ToolsEnabled, BashTimeout: 300, - TavilyKeys: resolveTavilyKeys(option.TavilyKey, DefaultTavilyKeys), + TavilyKeys: resolveTavilyKeys(option.TavilyKey, ResolveString(option.SearchConfig.TavilyKeys, DefaultTavilyKeys)), OptionalTools: option.Tools, }, Logger: logger, diff --git a/core/config/config_gen.go b/core/config/config_gen.go index 1ce485c1..045646b7 100644 --- a/core/config/config_gen.go +++ b/core/config/config_gen.go @@ -38,12 +38,7 @@ const configFileHeader = `# aiscan 配置文件 ` -const configFileTail = `# 搜索 -search: - # Tavily API keys (逗号分隔,留空则 fallback 到 DuckDuckGo) - tavily_keys: "" - -# 以下仅 build.sh 使用 +const configFileTail = `# 以下仅 build.sh 使用 build: osarch: "" tags: "" diff --git a/core/config/env.go b/core/config/env.go index 709d1198..0dde5095 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -10,6 +10,17 @@ import ( type envLookup func(string) (string, bool) func ResolveRuntimeConfig(option *Option) (string, error) { + return resolveRuntimeConfig(option, true) +} + +// ResolveRuntimeConfigCandidate resolves a staged configuration without +// mutating process-wide state. It is used to validate a Web reload candidate +// before the staged file is committed. +func ResolveRuntimeConfigCandidate(option *Option) (string, error) { + return resolveRuntimeConfig(option, false) +} + +func resolveRuntimeConfig(option *Option, applyProcessState bool) (string, error) { explicit := *option configPath, err := LoadAndApplyConfig(option) if err != nil { @@ -20,7 +31,7 @@ func ResolveRuntimeConfig(option *Option) (string, error) { if _, err := ResolveOutputPolicy(option); err != nil { return configPath, err } - if strings.TrimSpace(option.DataDir) != "" { + if applyProcessState && strings.TrimSpace(option.DataDir) != "" { SetDataDir(option.DataDir) } return configPath, nil diff --git a/core/config/loader.go b/core/config/loader.go index 6729703f..df66bba0 100644 --- a/core/config/loader.go +++ b/core/config/loader.go @@ -86,26 +86,9 @@ func LoadAndApplyConfig(option *Option) (string, error) { return configPath, fmt.Errorf("load config %s: %w", configPath, err) } mergeOption(option, &loaded) - if err := loadRuntimeDefaults(configPath); err != nil { - return configPath, fmt.Errorf("load runtime defaults %s: %w", configPath, err) - } return configPath, nil } -func loadRuntimeDefaults(filename string) error { - c := newConfigLoader() - if err := c.LoadFiles(filename); err != nil { - return err - } - if v := c.String("scan.verify"); v != "" { - DefaultVerify = v - } - if v := c.String("search.tavily_keys"); v != "" { - DefaultTavilyKeys = v - } - return nil -} - func mergeOption(dst, src *Option) { dst.Provider = ResolveString(dst.Provider, src.Provider) dst.BaseURL = ResolveString(dst.BaseURL, src.BaseURL) @@ -142,6 +125,8 @@ func mergeOption(dst, src *Option) { dst.Providers = src.Providers } dst.ActiveProfile = ResolveString(dst.ActiveProfile, src.ActiveProfile) + dst.ScanConfig.Verify = ResolveString(dst.ScanConfig.Verify, src.ScanConfig.Verify) + dst.SearchConfig.TavilyKeys = ResolveString(dst.SearchConfig.TavilyKeys, src.SearchConfig.TavilyKeys) if len(dst.Tools) == 0 && len(src.Tools) > 0 { dst.Tools = src.Tools } diff --git a/core/config/loader_test.go b/core/config/loader_test.go index a4ff5e19..4c693bfe 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -302,16 +302,14 @@ search: tavily_keys: "K1,K2" `) - withDefaults(t, func() { - if err := loadRuntimeDefaults(filepath.Join(dir, "aiscan.yaml")); err != nil { - t.Fatal(err) - } - - cfg := AppConfig(&Option{}, RuntimeFeatures{ToolsEnabled: true}, telemetry.NopLogger()) - if cfg.Tools.TavilyKeys != "K1,K2" { - t.Fatalf("tool config = %#v", cfg.Tools) - } - }) + var option Option + if err := LoadConfig(filepath.Join(dir, "aiscan.yaml"), &option); err != nil { + t.Fatal(err) + } + cfg := AppConfig(&option, RuntimeFeatures{ToolsEnabled: true}, telemetry.NopLogger()) + if cfg.Tools.TavilyKeys != "K1,K2" { + t.Fatalf("tool config = %#v", cfg.Tools) + } } func TestLoadScanDefaults(t *testing.T) { @@ -321,15 +319,13 @@ scan: verify: critical `) - withDefaults(t, func() { - if err := loadRuntimeDefaults(filepath.Join(dir, "aiscan.yaml")); err != nil { - t.Fatal(err) - } - - if DefaultVerify != "critical" { - t.Errorf("DefaultVerify: got %q, want %q", DefaultVerify, "critical") - } - }) + var option Option + if err := LoadConfig(filepath.Join(dir, "aiscan.yaml"), &option); err != nil { + t.Fatal(err) + } + if got := AppConfig(&option, RuntimeFeatures{}, telemetry.NopLogger()).Scanner.VerifyMode; got != "critical" { + t.Errorf("VerifyMode: got %q, want %q", got, "critical") + } } func TestLoadAndApplyConfigDefaultFile(t *testing.T) { @@ -802,6 +798,53 @@ llm: } } +func TestResolveRuntimeConfigCandidateUsesStagedProfileAndExplicitCLIOverrides(t *testing.T) { + for _, key := range []string{ + "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER", "AISCAN_MODEL", "AISCAN_LLM_MODEL", + "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL", + "AISCAN_API_KEY", "AISCAN_LLM_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY", + } { + t.Setenv(key, "") + } + dir := t.TempDir() + writeTestConfig(t, dir, ` +llm: + active_profile: staged + providers: + - id: old + provider: anthropic + api_key: old-key + model: old-model + - id: staged + provider: openai + base_url: https://staged.example/v1 + api_key: staged-key + model: staged-model +`) + path := filepath.Join(dir, "aiscan.yaml") + + staged := Option{MiscOptions: MiscOptions{ConfigFile: path}} + if _, err := ResolveRuntimeConfigCandidate(&staged); err != nil { + t.Fatal(err) + } + got := ProviderConfig(&staged) + if staged.ActiveProfile != "staged" || got.Provider != "openai" || got.Model != "staged-model" || got.APIKey != "staged-key" { + t.Fatalf("staged profile was not selected: option=%+v provider=%+v", staged.LLMOptions, got) + } + + explicit := Option{ + MiscOptions: MiscOptions{ConfigFile: path}, + LLMOptions: LLMOptions{Provider: "deepseek", Model: "cli-model", APIKey: "cli-key"}, + } + if _, err := ResolveRuntimeConfigCandidate(&explicit); err != nil { + t.Fatal(err) + } + got = ProviderConfig(&explicit) + if got.Provider != "deepseek" || got.Model != "cli-model" || got.APIKey != "cli-key" { + t.Fatalf("explicit CLI LLM values did not override staged config: %+v", got) + } +} + func withDefaults(t *testing.T, fn func()) { t.Helper() saved := []*string{ diff --git a/core/config/options.go b/core/config/options.go index 515ab2a9..0cb93a71 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -19,13 +19,18 @@ type Option struct { ReconOptions `group:"Recon Options" config:"recon"` OutputOptions `group:"Agent Output Options" config:"output"` MiscOptions `group:"Miscellaneous Options" config:"misc"` - ScanConfig ScanConfigOptions `no-flag:"true" config:"scan"` + ScanConfig ScanConfigOptions `no-flag:"true" config:"scan"` + SearchConfig SearchConfigOptions `no-flag:"true" config:"search"` } type ScanConfigOptions struct { Verify string `config:"verify"` } +type SearchConfigOptions struct { + TavilyKeys string `config:"tavily_keys" description:"Tavily API keys (comma-separated; empty falls back to DuckDuckGo)"` +} + type LLMOptions struct { Provider string `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot, zhipu"` BaseURL string `long:"base-url" config:"base_url" description:"LLM API base URL (leave empty to use provider default)"` diff --git a/pkg/webagent/remote.go b/pkg/webagent/remote.go index e7e61326..d6d28f88 100644 --- a/pkg/webagent/remote.go +++ b/pkg/webagent/remote.go @@ -75,7 +75,7 @@ func distributeToOption(d *webproto.DistributeConfig) *cfg.Option { opt.ReconProxy = d.Recon.Proxy opt.ReconLimit = d.Recon.Limit if d.Search.TavilyKeys != "" { - cfg.DefaultTavilyKeys = cfg.ResolveString(cfg.DefaultTavilyKeys, d.Search.TavilyKeys) + opt.SearchConfig.TavilyKeys = cfg.ResolveString(opt.SearchConfig.TavilyKeys, d.Search.TavilyKeys) } return opt } From a320b44a0eb9feb9a1b4c507af1e060ef8030822 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 00:02:49 +0800 Subject: [PATCH 135/348] fix(web): make cancellation and event replay durable --- pkg/web/agents.go | 37 ++++-- pkg/web/handler.go | 42 +++++- pkg/web/replay_test.go | 46 +++++++ pkg/web/scan_lifecycle_test.go | 32 +++++ pkg/web/service.go | 42 ++++-- pkg/web/service_test.go | 14 ++ pkg/web/sse.go | 13 ++ pkg/web/sse_test.go | 25 ++++ pkg/web/store_sqlite.go | 226 ++++++++++++++++++++++++++++----- pkg/web/store_sqlite_test.go | 94 ++++++++++++++ pkg/web/types.go | 12 ++ web/frontend/src/api.ts | 12 +- 12 files changed, 537 insertions(+), 58 deletions(-) diff --git a/pkg/web/agents.go b/pkg/web/agents.go index eb574d92..38ac936d 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -482,7 +482,7 @@ func (p *AgentPool) SendAgentMessage(agentID string, msg webproto.Message) error func (p *AgentPool) CancelTask(agentID, taskID string) error { a := p.get(agentID) if a == nil { - return fmt.Errorf("agent %s not connected", agentID) + return nil } a.mu.Lock() resultCh, pending := a.tasks[taskID] @@ -501,18 +501,35 @@ func (p *AgentPool) CancelTask(agentID, taskID string) error { if isToolCall { cancelMessage = webproto.Message{Type: "cancel", TaskID: taskID} } - var sendErr error - select { - case a.controlCh <- cancelMessage: - case <-a.done: - sendErr = fmt.Errorf("agent %s disconnected before cancellation", agentID) - case <-time.After(time.Second): - sendErr = fmt.Errorf("agent %s control channel full", agentID) - } if resultCh != nil { close(resultCh) } - return sendErr + a.enqueueControl(cancelMessage) + return nil +} + +// enqueueControl never drops a control frame because task traffic temporarily +// fills the channel. The pending send is bounded by the agent connection's +// lifetime and the writer always drains controlCh before sendCh. +func (a *remoteAgent) enqueueControl(msg webproto.Message) { + if a == nil || a.controlCh == nil { + return + } + select { + case a.controlCh <- msg: + return + default: + } + go func() { + if a.done == nil { + a.controlCh <- msg + return + } + select { + case a.controlCh <- msg: + case <-a.done: + } + }() } // HandleTerminalWS bridges one browser terminal WebSocket to one remote agent. diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 3c898cfb..82a1d81a 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -478,15 +478,33 @@ func (h *handlerImpl) uploadFile(w http.ResponseWriter, r *http.Request) { } func (h *handlerImpl) listMessages(w http.ResponseWriter, r *http.Request) { - msgs, err := h.service.GetMessages(r.Context(), r.PathValue("id")) + before, err := parsePositiveInt64(r.URL.Query().Get("before")) if err != nil { + writeError(w, http.StatusBadRequest, "invalid before cursor") + return + } + limit := 500 + if value := r.URL.Query().Get("limit"); value != "" { + parsed, parseErr := strconv.Atoi(value) + if parseErr != nil || parsed < 1 || parsed > 500 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 500") + return + } + limit = parsed + } + page, err := h.service.GetMessagePage(r.Context(), r.PathValue("id"), before, limit) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + writeError(w, http.StatusNotFound, ErrSessionNotFound.Error()) + return + } writeError(w, http.StatusInternalServerError, err.Error()) return } - if msgs == nil { - msgs = []*ChatMessage{} + if page.Items == nil { + page.Items = []*ChatMessage{} } - writeJSON(w, http.StatusOK, msgs) + writeJSON(w, http.StatusOK, page) } func (h *handlerImpl) sessionEvents(w http.ResponseWriter, r *http.Request) { @@ -495,14 +513,15 @@ func (h *handlerImpl) sessionEvents(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "session not found") return } + after, _ := parsePositiveInt64(r.Header.Get("Last-Event-ID")) err := ServeSSEWithSnapshot(w, r, h.service.Hub(), sessionTopic(id), func() ([]HubEvent, error) { - events, err := h.service.GetAOPEvents(r.Context(), id) + events, err := h.service.GetAOPEventsAfter(r.Context(), id, after) if err != nil { return nil, err } initial := make([]HubEvent, 0, len(events)) for _, event := range events { - initial = append(initial, HubEvent{Type: "aop", Data: mustJSON(event)}) + initial = append(initial, HubEvent{ID: event.Cursor, Type: "aop", Data: mustJSON(event.Event)}) } return initial, nil }, "_never") @@ -511,6 +530,17 @@ func (h *handlerImpl) sessionEvents(w http.ResponseWriter, r *http.Request) { } } +func parsePositiveInt64(value string) (int64, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return 0, fmt.Errorf("invalid positive integer %q", value) + } + return parsed, nil +} + // ── SCO Nodes ── func (h *handlerImpl) listSCONodes(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/web/replay_test.go b/pkg/web/replay_test.go index 6dd96b6e..6ea81db5 100644 --- a/pkg/web/replay_test.go +++ b/pkg/web/replay_test.go @@ -165,3 +165,49 @@ func TestSessionEventsReplayHasNoSideEffects(t *testing.T) { t.Fatalf("event count changed by replay: before=%d after=%d", len(before), len(after)) } } + +func TestSessionEventsResumesAfterLastEventID(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "resume.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + svc := NewService(ServiceConfig{Store: store}) + session, err := svc.CreateSession(context.Background(), "", "resume") + if err != nil { + t.Fatal(err) + } + for seq := 1; seq <= 3; seq++ { + if err := store.AddAOPEvent(context.Background(), session.ID, aop.Event{ + Type: aop.TypeStatus, TS: time.Now().UTC().Format(time.RFC3339Nano), SessionID: session.ID, Agent: "aiscan", + Data: mustJSON(map[string]int{"seq": seq}), + }); err != nil { + t.Fatal(err) + } + } + + reqCtx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest("GET", "/api/chat/sessions/"+session.ID+"/events", nil).WithContext(reqCtx) + req.Header.Set("Last-Event-ID", "2") + req.SetPathValue("id", session.ID) + recorder := newLockedResponseRecorder() + done := make(chan struct{}) + go func() { + (&handlerImpl{service: svc}).sessionEvents(recorder, req) + close(done) + }() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && !strings.Contains(recorder.BodyString(), "id: 3\n") { + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("sessionEvents did not return after request cancel") + } + body := recorder.BodyString() + if strings.Contains(body, "id: 1\n") || strings.Contains(body, "id: 2\n") || !strings.Contains(body, "id: 3\n") { + t.Fatalf("resume body = %q, want only events after cursor 2", body) + } +} diff --git a/pkg/web/scan_lifecycle_test.go b/pkg/web/scan_lifecycle_test.go index 66db87e7..6e00db86 100644 --- a/pkg/web/scan_lifecycle_test.go +++ b/pkg/web/scan_lifecycle_test.go @@ -204,6 +204,38 @@ func TestCancelTaskUsesControlChannelWhenTaskQueueIsFull(t *testing.T) { } } +func TestCancelTaskWaitsForSaturatedControlChannel(t *testing.T) { + pool := NewAgentPool(NewHub()) + remote := newFakeAgent("agent-1", 1) + remote.toolCalls = map[string]struct{}{"scan-1": {}} + resultCh := make(chan taskResult, 1) + remote.tasks["scan-1"] = resultCh + remote.controlCh <- webproto.Message{Type: "config"} + pool.agents[remote.id] = remote + + if err := pool.CancelTask(remote.id, "scan-1"); err != nil { + t.Fatal(err) + } + select { + case _, ok := <-resultCh: + if ok { + t.Fatal("canceled result channel remained open") + } + case <-time.After(time.Second): + t.Fatal("cancellation did not converge the pending task") + } + + <-remote.controlCh + select { + case msg := <-remote.controlCh: + if msg.Type != "cancel" || msg.TaskID != "scan-1" { + t.Fatalf("queued cancellation = %+v", msg) + } + case <-time.After(time.Second): + t.Fatal("cancellation was dropped under control-channel backpressure") + } +} + func TestDecodeScanResultRejectsInvalidEnvelopes(t *testing.T) { for _, tc := range []struct { name string diff --git a/pkg/web/service.go b/pkg/web/service.go index 9ce4e409..a5e6394b 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -383,9 +383,7 @@ func (s *Service) CancelScan(id string) error { Reliable: true, }) if agentID != "" && s.agents != nil { - if err := s.agents.CancelTask(agentID, id); err != nil { - return err - } + _ = s.agents.CancelTask(agentID, id) } return nil } @@ -1361,10 +1359,24 @@ func (s *Service) GetMessages(ctx context.Context, sessionID string) ([]*ChatMes return s.store.ListMessages(ctx, sessionID, 500) } +func (s *Service) GetMessagePage(ctx context.Context, sessionID string, before int64, limit int) (ChatMessagePage, error) { + if _, err := s.store.GetSession(ctx, sessionID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ChatMessagePage{}, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) + } + return ChatMessagePage{}, err + } + return s.store.ListMessagePage(ctx, sessionID, before, limit) +} + func (s *Service) GetAOPEvents(ctx context.Context, sessionID string) ([]aop.Event, error) { return s.store.ListAOPEvents(ctx, sessionID, 10000) } +func (s *Service) GetAOPEventsAfter(ctx context.Context, sessionID string, after int64) ([]persistedAOPEvent, error) { + return s.store.ListAOPEventsAfter(ctx, sessionID, after, 0) +} + func (s *Service) BroadcastDomainEvent(sessionID string, event DomainEvent) { event.SessionID = sessionID if !event.Transient { @@ -1381,14 +1393,20 @@ func (s *Service) BroadcastAOPEvent(sessionID string, event aop.Event) { if s == nil || s.hub == nil || sessionID == "" || !event.Valid() { return } + var cursor int64 if s.store != nil { - _ = s.store.AddAOPEvent(context.Background(), sessionID, event) + storedCursor, _, err := s.store.AppendAOPEvent(context.Background(), sessionID, event) + if err != nil { + return + } + cursor = storedCursor } - s.broadcastAOPEvent(sessionID, event) + s.broadcastAOPEvent(sessionID, event, cursor) } -func (s *Service) broadcastAOPEvent(sessionID string, event aop.Event) { +func (s *Service) broadcastAOPEvent(sessionID string, event aop.Event, cursor int64) { s.hub.Broadcast(sessionTopic(sessionID), HubEvent{ + ID: cursor, Type: "aop", Data: mustJSON(event), Reliable: isReliableAOPEvent(event), @@ -1499,11 +1517,12 @@ func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content stri CreatedAt: now, Queued: s.sessionHasActiveTask(sessionID), } - if err := s.store.AddMessage(ctx, msg); err != nil { + cursor, err := s.store.AppendMessage(ctx, msg) + if err != nil { return nil, fmt.Errorf("store message: %w", err) } if event, err := messageEventFromChatMessage(msg); err == nil { - s.broadcastAOPEvent(sessionID, event) + s.broadcastAOPEvent(sessionID, event, cursor) } // Update session timestamp and auto-title from first message. @@ -1875,9 +1894,12 @@ func (s *Service) broadcastSystemMessage(sessionID, code, fallback string, param Metadata: meta, CreatedAt: now, } - _ = s.store.AddMessage(context.Background(), msg) + cursor, err := s.store.AppendMessage(context.Background(), msg) + if err != nil { + return + } if event, err := messageEventFromChatMessage(msg); err == nil { - s.broadcastAOPEvent(sessionID, event) + s.broadcastAOPEvent(sessionID, event, cursor) } } diff --git a/pkg/web/service_test.go b/pkg/web/service_test.go index 992158ca..30e69ad1 100644 --- a/pkg/web/service_test.go +++ b/pkg/web/service_test.go @@ -84,6 +84,20 @@ func TestSendMessageReturnsNotFoundForMissingSession(t *testing.T) { } } +func TestListMessagesReturnsNotFoundForMissingSession(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/chat/sessions/missing/messages", nil)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s; want 404", recorder.Code, recorder.Body.String()) + } +} + func TestGetScanRebuildsLegacyMergedAssets(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) if err != nil { diff --git a/pkg/web/sse.go b/pkg/web/sse.go index 0a023b81..eb70cfb0 100644 --- a/pkg/web/sse.go +++ b/pkg/web/sse.go @@ -14,6 +14,7 @@ import ( // HubEvent is the unit broadcast through the SSE hub. Type is the SSE // event name, Data is pre-serialized JSON written directly to the stream. type HubEvent struct { + ID int64 Type string Data json.RawMessage // Reliable marks a terminal event that Broadcast must not drop under @@ -129,7 +130,12 @@ func serveSSEChannel(w http.ResponseWriter, r *http.Request, ch <-chan HubEvent, w.WriteHeader(http.StatusOK) flusher.Flush() + var lastSentID int64 for _, event := range initial { + if event.ID > 0 { + fmt.Fprintf(w, "id: %d\n", event.ID) + lastSentID = event.ID + } fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, event.Data) if isTerminalEvent(event.Type, terminalEvents) { flusher.Flush() @@ -152,6 +158,13 @@ func serveSSEChannel(w http.ResponseWriter, r *http.Request, ch <-chan HubEvent, if !ok { return } + if event.ID > 0 && event.ID <= lastSentID { + continue + } + if event.ID > 0 { + fmt.Fprintf(w, "id: %d\n", event.ID) + lastSentID = event.ID + } fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, event.Data) flusher.Flush() if isTerminalEvent(event.Type, terminalEvents) { diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index 300b4e1e..8ff158e8 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -245,3 +245,28 @@ func TestServeSSEWithSnapshotSubscribesBeforeReadingSnapshot(t *testing.T) { t.Fatalf("SSE body = %q; event broadcast during snapshot was lost", body) } } + +func TestServeSSEWithSnapshotDropsQueuedSnapshotDuplicates(t *testing.T) { + hub := NewHub() + req := httptest.NewRequest("GET", "/events", nil) + recorder := newLockedResponseRecorder() + + err := ServeSSEWithSnapshot(recorder, req, hub, "session-topic", func() ([]HubEvent, error) { + hub.Broadcast("session-topic", HubEvent{ID: 2, Type: "aop", Data: mustJSON("duplicate")}) + hub.Broadcast("session-topic", HubEvent{ID: 3, Type: "done", Data: mustJSON("new"), Reliable: true}) + return []HubEvent{ + {ID: 1, Type: "aop", Data: mustJSON("one")}, + {ID: 2, Type: "aop", Data: mustJSON("duplicate")}, + }, nil + }, "done") + if err != nil { + t.Fatal(err) + } + body := recorder.BodyString() + if strings.Count(body, "id: 2\n") != 1 { + t.Fatalf("snapshot cursor 2 was emitted more than once: %q", body) + } + if !strings.Contains(body, "id: 3\n") { + t.Fatalf("new queued event was not emitted: %q", body) + } +} diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index 8dc78a1e..bae1a15d 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -72,6 +72,7 @@ func migrate(db *sql.DB) error { CREATE TABLE IF NOT EXISTS chat_aop_events ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + hub_seq INTEGER NOT NULL DEFAULT 0, event_json TEXT NOT NULL, created_at TEXT NOT NULL ); @@ -103,11 +104,33 @@ func migrate(db *sql.DB) error { {table: "chat_messages", name: "agent_id", definition: "TEXT NOT NULL DEFAULT ''"}, {table: "chat_messages", name: "agent_name", definition: "TEXT NOT NULL DEFAULT ''"}, {table: "chat_messages", name: "metadata", definition: "TEXT NOT NULL DEFAULT ''"}, + {table: "chat_aop_events", name: "hub_seq", definition: "INTEGER NOT NULL DEFAULT 0"}, } { if err := ensureSQLiteColumn(db, column); err != nil { return err } } + if _, err := db.Exec(` + DROP TABLE IF EXISTS temp.aop_seq_backfill; + CREATE TEMP TABLE aop_seq_backfill (row_id INTEGER PRIMARY KEY, hub_seq INTEGER NOT NULL); + INSERT INTO aop_seq_backfill (row_id, hub_seq) + SELECT target.rowid, + COALESCE(( + SELECT MAX(existing.hub_seq) + FROM chat_aop_events AS existing + WHERE existing.session_id = target.session_id AND existing.hub_seq > 0 + ), 0) + ROW_NUMBER() OVER ( + PARTITION BY target.session_id ORDER BY target.created_at, target.rowid + ) + FROM chat_aop_events AS target + WHERE target.hub_seq = 0; + UPDATE chat_aop_events + SET hub_seq = (SELECT backfill.hub_seq FROM aop_seq_backfill AS backfill WHERE backfill.row_id = chat_aop_events.rowid) + WHERE rowid IN (SELECT row_id FROM aop_seq_backfill); + DROP TABLE aop_seq_backfill; + `); err != nil { + return err + } if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS records ( @@ -151,6 +174,7 @@ func migrate(db *sql.DB) error { CREATE INDEX IF NOT EXISTS idx_sessions_updated ON chat_sessions(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_agent ON chat_sessions(agent_id); CREATE INDEX IF NOT EXISTS idx_aop_events_session ON chat_aop_events(session_id, created_at, id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_aop_events_session_seq ON chat_aop_events(session_id, hub_seq); CREATE INDEX IF NOT EXISTS idx_sco_nodes_type ON sco_nodes(cstx_type); CREATE INDEX IF NOT EXISTS idx_sco_nodes_scan ON sco_nodes(scan_id); `); err != nil { @@ -246,11 +270,12 @@ func rebuildAOPEventsWithForeignKey(tx *sql.Tx) error { CREATE TABLE chat_aop_events_fk_migration ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + hub_seq INTEGER NOT NULL, event_json TEXT NOT NULL, created_at TEXT NOT NULL ); - INSERT INTO chat_aop_events_fk_migration (rowid, id, session_id, event_json, created_at) - SELECT events.rowid, events.id, events.session_id, events.event_json, events.created_at + INSERT INTO chat_aop_events_fk_migration (rowid, id, session_id, hub_seq, event_json, created_at) + SELECT events.rowid, events.id, events.session_id, events.hub_seq, events.event_json, events.created_at FROM chat_aop_events AS events WHERE EXISTS ( SELECT 1 FROM chat_sessions WHERE chat_sessions.id = events.session_id @@ -628,11 +653,17 @@ func (s *SQLiteStore) DeleteSession(ctx context.Context, id string) error { // --- Chat message CRUD --- func (s *SQLiteStore) AddMessage(ctx context.Context, msg *ChatMessage) error { + _, err := s.AppendMessage(ctx, msg) + return err +} + +func (s *SQLiteStore) AppendMessage(ctx context.Context, msg *ChatMessage) (int64, error) { event, err := messageEventFromChatMessage(msg) if err != nil { - return err + return 0, err } - return s.AddAOPEvent(ctx, msg.SessionID, event) + cursor, _, err := s.AppendAOPEvent(ctx, msg.SessionID, event) + return cursor, err } // ClearMessages deletes every message in a session without removing the session @@ -644,66 +675,201 @@ func (s *SQLiteStore) ClearMessages(ctx context.Context, sessionID string) error } func (s *SQLiteStore) AddAOPEvent(ctx context.Context, sessionID string, event aop.Event) error { + _, _, err := s.AppendAOPEvent(ctx, sessionID, event) + return err +} + +// AppendAOPEvent persists one durable event and assigns the authoritative +// session-local cursor used by SSE replay and REST pagination. Message deltas +// remain transient and return persisted=false. +func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, event aop.Event) (cursor int64, persisted bool, err error) { // Deltas are streaming fragments; only complete messages are persisted so a // replayed history holds the authoritative state. if event.Type == aop.TypeMessageDelta { - return nil + return 0, false, nil } raw, err := json.Marshal(event) if err != nil { - return err + return 0, false, err } createdAt := event.TS if createdAt == "" { createdAt = time.Now().UTC().Format(time.RFC3339Nano) } - _, err = s.db.ExecContext(ctx, - `INSERT INTO chat_aop_events (id, session_id, event_json, created_at) VALUES (?, ?, ?, ?)`, - generateID(), sessionID, string(raw), createdAt, - ) - return err + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, false, err + } + defer tx.Rollback() + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(MAX(hub_seq), 0) + 1 FROM chat_aop_events WHERE session_id = ?`, sessionID, + ).Scan(&cursor); err != nil { + return 0, false, err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO chat_aop_events (id, session_id, hub_seq, event_json, created_at) VALUES (?, ?, ?, ?, ?)`, + generateID(), sessionID, cursor, string(raw), createdAt, + ); err != nil { + return 0, false, err + } + if err := tx.Commit(); err != nil { + return 0, false, err + } + return cursor, true, nil } func (s *SQLiteStore) ListAOPEvents(ctx context.Context, sessionID string, limit int) ([]aop.Event, error) { + page, _, err := s.ListAOPEventPage(ctx, sessionID, 0, limit) + if err != nil { + return nil, err + } + events := make([]aop.Event, 0, len(page)) + for _, stored := range page { + events = append(events, stored.Event) + } + return events, nil +} + +func (s *SQLiteStore) ListAOPEventPage(ctx context.Context, sessionID string, before int64, limit int) ([]persistedAOPEvent, int64, error) { if limit <= 0 { limit = 10000 } - rows, err := s.db.QueryContext(ctx, - `SELECT event_json FROM chat_aop_events WHERE session_id = ? ORDER BY created_at ASC, rowid ASC LIMIT ?`, - sessionID, limit, - ) + if limit > 10000 { + limit = 10000 + } + query := `SELECT hub_seq, event_json FROM ( + SELECT hub_seq, event_json FROM chat_aop_events + WHERE session_id = ? ORDER BY hub_seq DESC LIMIT ? + ) ORDER BY hub_seq ASC` + args := []any{sessionID, limit + 1} + if before > 0 { + query = `SELECT hub_seq, event_json FROM ( + SELECT hub_seq, event_json FROM chat_aop_events + WHERE session_id = ? AND hub_seq < ? ORDER BY hub_seq DESC LIMIT ? + ) ORDER BY hub_seq ASC` + args = []any{sessionID, before, limit + 1} + } + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() - events := make([]aop.Event, 0) + events := make([]persistedAOPEvent, 0, limit+1) for rows.Next() { var raw string - if err := rows.Scan(&raw); err != nil { - return nil, err + var cursor int64 + if err := rows.Scan(&cursor, &raw); err != nil { + return nil, 0, err } var event aop.Event if json.Unmarshal([]byte(raw), &event) == nil && event.Valid() { - events = append(events, event) + events = append(events, persistedAOPEvent{Cursor: cursor, Event: event}) + } + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + var next int64 + if len(events) > limit { + events = events[1:] + if len(events) > 0 { + next = events[0].Cursor + } + } + return events, next, nil +} + +func (s *SQLiteStore) ListAOPEventsAfter(ctx context.Context, sessionID string, after int64, limit int) ([]persistedAOPEvent, error) { + if after <= 0 { + events, _, err := s.ListAOPEventPage(ctx, sessionID, 0, limit) + return events, err + } + query := `SELECT hub_seq, event_json FROM chat_aop_events WHERE session_id = ? AND hub_seq > ? ORDER BY hub_seq ASC` + args := []any{sessionID, after} + if limit > 0 { + if limit > 10000 { + limit = 10000 + } + query += ` LIMIT ?` + args = append(args, limit) + } + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var events []persistedAOPEvent + for rows.Next() { + var stored persistedAOPEvent + var raw string + if err := rows.Scan(&stored.Cursor, &raw); err != nil { + return nil, err + } + if json.Unmarshal([]byte(raw), &stored.Event) == nil && stored.Event.Valid() { + events = append(events, stored) } } return events, rows.Err() } func (s *SQLiteStore) ListMessages(ctx context.Context, sessionID string, limit int) ([]*ChatMessage, error) { + page, err := s.ListMessagePage(ctx, sessionID, 0, limit) + if err != nil { + return nil, err + } + return page.Items, nil +} + +func (s *SQLiteStore) ListMessagePage(ctx context.Context, sessionID string, before int64, limit int) (ChatMessagePage, error) { if limit <= 0 { limit = 500 } - events, err := s.ListAOPEvents(ctx, sessionID, 10000) + if limit > 500 { + limit = 500 + } + query := `SELECT hub_seq, event_json FROM ( + SELECT hub_seq, event_json FROM chat_aop_events + WHERE session_id = ? AND json_valid(event_json) AND json_extract(event_json, '$.type') = ? + ORDER BY hub_seq DESC LIMIT ? + ) ORDER BY hub_seq ASC` + args := []any{sessionID, aop.TypeMessage, limit + 1} + if before > 0 { + query = `SELECT hub_seq, event_json FROM ( + SELECT hub_seq, event_json FROM chat_aop_events + WHERE session_id = ? AND hub_seq < ? AND json_valid(event_json) AND json_extract(event_json, '$.type') = ? + ORDER BY hub_seq DESC LIMIT ? + ) ORDER BY hub_seq ASC` + args = []any{sessionID, before, aop.TypeMessage, limit + 1} + } + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { - return nil, err + return ChatMessagePage{}, err } - capacity := len(events) - if capacity > limit { - capacity = limit + defer rows.Close() + events := make([]persistedAOPEvent, 0, limit+1) + for rows.Next() { + var stored persistedAOPEvent + var raw string + if err := rows.Scan(&stored.Cursor, &raw); err != nil { + return ChatMessagePage{}, err + } + if json.Unmarshal([]byte(raw), &stored.Event) == nil && stored.Event.Valid() { + events = append(events, stored) + } } - msgs := make([]*ChatMessage, 0, capacity) - for _, event := range events { + if err := rows.Err(); err != nil { + return ChatMessagePage{}, err + } + var next int64 + if len(events) > limit { + events = events[1:] + if len(events) > 0 { + next = events[0].Cursor + } + } + msgs := make([]*ChatMessage, 0, len(events)) + for _, stored := range events { + event := stored.Event if event.Type != aop.TypeMessage { continue } @@ -727,6 +893,7 @@ func (s *SQLiteStore) ListMessages(ctx context.Context, sessionID string, limit Role: data.Role, AgentName: event.Agent, Content: sb.String(), + Cursor: stored.Cursor, } if msg.ID == "" { msg.ID = generateID() @@ -740,11 +907,8 @@ func (s *SQLiteStore) ListMessages(ctx context.Context, sessionID string, limit msg.Metadata = ext.Metadata } msgs = append(msgs, msg) - if len(msgs) >= limit { - break - } } - return msgs, nil + return ChatMessagePage{Items: msgs, NextCursor: next}, nil } // --- Session-scan association --- diff --git a/pkg/web/store_sqlite_test.go b/pkg/web/store_sqlite_test.go index e5ffcc84..714d6427 100644 --- a/pkg/web/store_sqlite_test.go +++ b/pkg/web/store_sqlite_test.go @@ -54,6 +54,60 @@ func TestSQLiteStoreWipesLegacyTextEvents(t *testing.T) { } } +func TestSQLiteStoreBackfillsDurableEventSequence(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy-sequence.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE chat_sessions (id TEXT PRIMARY KEY, agent_id TEXT, agent_name TEXT, title TEXT, status TEXT, created_at TEXT, updated_at TEXT); + CREATE TABLE chat_aop_events (id TEXT PRIMARY KEY, session_id TEXT, event_json TEXT, created_at TEXT); + INSERT INTO chat_sessions VALUES ('s1','','','','active','2026-07-19T00:00:00Z','2026-07-19T00:00:00Z'); + INSERT INTO chat_aop_events VALUES + ('e2','s1','{"type":"status","ts":"2026-07-19T00:00:02Z","session_id":"s1","agent":"aiscan","data":{}}','2026-07-19T00:00:02Z'), + ('e1','s1','{"type":"status","ts":"2026-07-19T00:00:01Z","session_id":"s1","agent":"aiscan","data":{}}','2026-07-19T00:00:01Z'); + PRAGMA user_version = 2; + `) + if err != nil { + db.Close() + t.Fatal(err) + } + _ = db.Close() + + store, err := NewSQLiteStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + rows, err := store.db.Query(`SELECT hub_seq, id FROM chat_aop_events WHERE session_id = 's1' ORDER BY hub_seq`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var got []string + for rows.Next() { + var seq int + var id string + if err := rows.Scan(&seq, &id); err != nil { + t.Fatal(err) + } + got = append(got, id) + if seq != len(got) { + t.Fatalf("hub_seq for %s = %d, want %d", id, seq, len(got)) + } + } + if len(got) != 2 || got[0] != "e1" || got[1] != "e2" { + t.Fatalf("backfilled order = %v, want [e1 e2]", got) + } + cursor, persisted, err := store.AppendAOPEvent(context.Background(), "s1", aop.Event{ + Type: aop.TypeStatus, TS: "2026-07-19T00:00:03Z", SessionID: "s1", Agent: "aiscan", Data: json.RawMessage(`{}`), + }) + if err != nil || !persisted || cursor != 3 { + t.Fatalf("AppendAOPEvent cursor = %d, persisted = %v, err = %v; want 3, true, nil", cursor, persisted, err) + } +} + func TestSQLiteStoreMessageRoundTrip(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) if err != nil { @@ -126,6 +180,46 @@ func TestSQLiteStoreMessageRoundTrip(t *testing.T) { } } +func TestSQLiteStoreMessagePaginationIgnoresNonMessageDensity(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "message-pages.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + ctx := context.Background() + createStoredSession(t, store, "s1") + + for message := 1; message <= 4; message++ { + for event := 0; event < 25; event++ { + if err := store.AddAOPEvent(ctx, "s1", aop.Event{ + Type: aop.TypeStatus, TS: time.Now().UTC().Format(time.RFC3339Nano), SessionID: "s1", Agent: "aiscan", Data: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + if err := store.AddMessage(ctx, &ChatMessage{ + ID: string(rune('0' + message)), SessionID: "s1", Role: "user", Content: "message", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + } + + latest, err := store.ListMessagePage(ctx, "s1", 0, 2) + if err != nil { + t.Fatal(err) + } + if len(latest.Items) != 2 || latest.Items[0].ID != "3" || latest.Items[1].ID != "4" || latest.NextCursor == 0 { + t.Fatalf("latest page = %+v", latest) + } + older, err := store.ListMessagePage(ctx, "s1", latest.NextCursor, 2) + if err != nil { + t.Fatal(err) + } + if len(older.Items) != 2 || older.Items[0].ID != "1" || older.Items[1].ID != "2" || older.NextCursor != 0 { + t.Fatalf("older page = %+v", older) + } +} + func TestSQLiteStorePersistsAnalysisOptions(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) if err != nil { diff --git a/pkg/web/types.go b/pkg/web/types.go index 4b9df04e..b6206301 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -6,6 +6,7 @@ import ( "time" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -194,12 +195,23 @@ type ChatMessage struct { Content string `json:"content"` Metadata json.RawMessage `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` + Cursor int64 `json:"cursor,omitempty"` // Queued is a transient send-time hint: true when the message was accepted // while another chat task is still running on the session, so the client // can render it as pending-in-queue rather than in-flight. Queued bool `json:"queued,omitempty"` } +type ChatMessagePage struct { + Items []*ChatMessage `json:"items"` + NextCursor int64 `json:"next_cursor,omitempty"` +} + +type persistedAOPEvent struct { + Cursor int64 + Event aop.Event +} + const ( DomainEventScanStarted = "scan_started" DomainEventScanProgress = "scan_progress" diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index ba8f6ac5..ec2d8ece 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -663,6 +663,12 @@ export interface ChatMessage { content: string metadata?: Record created_at: string + cursor?: number +} + +export interface ChatMessagePage { + items: ChatMessage[] + next_cursor?: number } export type DomainEventType = @@ -771,7 +777,11 @@ export async function uploadChatFile(sessionID: string, file: File): Promise { - return apiJSON(`/api/chat/sessions/${encodeURIComponent(sessionID)}/messages`, 'Failed to list messages') + const page: ChatMessagePage = await apiJSON( + `/api/chat/sessions/${encodeURIComponent(sessionID)}/messages`, + 'Failed to list messages', + ) + return page.items } // Fetch a scan's markdown report, re-rendered server-side in the given language From 8b2e121f98d8ae8e3166ed98a37006f6d07915a6 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 14:30:57 +0800 Subject: [PATCH 136/348] fix(build): pin generator tool dependencies --- core/resources/tools.go | 6 ++++++ go.mod | 5 ++++- go.sum | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 core/resources/tools.go diff --git a/core/resources/tools.go b/core/resources/tools.go new file mode 100644 index 00000000..4a53ef91 --- /dev/null +++ b/core/resources/tools.go @@ -0,0 +1,6 @@ +//go:build tools + +package resources + +// Keep generator-only dependencies visible to go mod tidy on every platform. +import _ "sigs.k8s.io/yaml" diff --git a/go.mod b/go.mod index c20e276e..23da8b1b 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,10 @@ require ( modernc.org/sqlite v1.40.1 ) -require sigs.k8s.io/yaml v1.6.0 // indirect +require ( + go.yaml.in/yaml/v2 v2.4.2 // indirect + sigs.k8s.io/yaml v1.6.0 +) require ( aead.dev/minisign v0.2.0 // indirect diff --git a/go.sum b/go.sum index c8599085..c8534106 100644 --- a/go.sum +++ b/go.sum @@ -1029,6 +1029,8 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= From 63479ba98851c4d02d12ddae67babdae54da8fb5 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 14:54:50 +0800 Subject: [PATCH 137/348] fix: clear cross-platform lint debt --- core/pidlock/pidlock_windows.go | 4 ++-- pkg/web/agents_test.go | 22 +++++++++++----------- pkg/web/handler.go | 2 +- pkg/web/service.go | 7 +++++-- pkg/web/store_sqlite.go | 11 ++++++----- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/core/pidlock/pidlock_windows.go b/core/pidlock/pidlock_windows.go index 90c4566a..e79be231 100644 --- a/core/pidlock/pidlock_windows.go +++ b/core/pidlock/pidlock_windows.go @@ -29,10 +29,10 @@ func unlockFile(f *os.File) error { } func ProcessExists(pid int) bool { - if pid <= 0 { + if pid <= 0 || uint64(pid) > uint64(^uint32(0)) { return false } - handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) //nolint:gosec // pid is bounded to uint32 above if err == nil { _ = windows.CloseHandle(handle) return true diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index 8e91e346..4d81151e 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -796,7 +796,7 @@ func TestWSTerminalBufferPressure(t *testing.T) { t.Logf("received %d/%d messages under buffer pressure", received, 100) } -func setupE2EServer(t *testing.T) (*httptest.Server, *AgentPool) { +func setupE2EServer(t *testing.T) (*httptest.Server, *AgentPool) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "e2e.db")) if err != nil { @@ -841,13 +841,13 @@ func setupE2EServer(t *testing.T) (*httptest.Server, *AgentPool) { return srv, pool } -type mockBrowserAgent struct { +type mockBrowserAgent struct { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag conn *websocket.Conn messages chan WSMessage errors chan error } -func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *mockBrowserAgent { +func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *mockBrowserAgent { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agent/ws" conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) @@ -884,11 +884,11 @@ func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *mockBrowser return agent } -func (a *mockBrowserAgent) Close() error { +func (a *mockBrowserAgent) Close() error { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag return a.conn.Close() } -func launchBrowser(t *testing.T) *rod.Browser { +func launchBrowser(t *testing.T) *rod.Browser { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() path, ok := launcher.LookPath() if !ok { @@ -905,7 +905,7 @@ func launchBrowser(t *testing.T) *rod.Browser { return browser } -func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []WSMessage { +func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []WSMessage { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag var msgs []WSMessage timer := time.NewTimer(timeout) defer timer.Stop() @@ -922,7 +922,7 @@ func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []WSMess } } -func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) pty.Frame { +func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) pty.Frame { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() timer := time.NewTimer(5 * time.Second) defer timer.Stop() @@ -944,14 +944,14 @@ func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) } } -func writeMockAgentPTY(t *testing.T, agent *mockBrowserAgent, frame pty.Frame) { +func writeMockAgentPTY(t *testing.T, agent *mockBrowserAgent, frame pty.Frame) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() if err := agent.conn.WriteJSON(webproto.NewPTYMessage(frame)); err != nil { t.Fatalf("agent write PTY %s: %v", frame.Type, err) } } -func openFirstAgentTerminal(t *testing.T, page *rod.Page) { +func openFirstAgentTerminal(t *testing.T, page *rod.Page) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() terminal, err := page.Timeout(5*time.Second).ElementR("button", "Terminal") if err != nil { @@ -968,7 +968,7 @@ func openFirstAgentTerminal(t *testing.T, page *rod.Page) { page.Timeout(5 * time.Second).MustWaitStable() } -func runE2ETerminalOpenAndType(t *testing.T) { +func runE2ETerminalOpenAndType(t *testing.T) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag srv, pool := setupE2EServer(t) agentConn := dialMockAgent(t, srv, "e2e-agent") defer agentConn.Close() @@ -1039,7 +1039,7 @@ func runE2ETerminalOpenAndType(t *testing.T) { t.Log("e2e terminal test: open → attach → input/output → close verified") } -func runE2ETerminalResize(t *testing.T) { +func runE2ETerminalResize(t *testing.T) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag srv, pool := setupE2EServer(t) agentConn := dialMockAgent(t, srv, "resize-agent") defer agentConn.Close() diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 82a1d81a..344a692d 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -432,7 +432,7 @@ func readMultipartUpload(w http.ResponseWriter, r *http.Request, maxSize int64) return "", nil, fmt.Errorf("parse multipart form: %w", err) } if r.MultipartForm != nil { - defer r.MultipartForm.RemoveAll() + defer func() { _ = r.MultipartForm.RemoveAll() }() } file, header, err := r.FormFile("file") diff --git a/pkg/web/service.go b/pkg/web/service.go index a5e6394b..9b49b04e 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -304,7 +304,10 @@ func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, s s.mu.Lock() s.cancels[job.ID] = cancel s.mu.Unlock() - go s.runScan(runCtx, job.ID) //nolint:gosec // G118: background scan outlives the request + go func() { //nolint:gosec // G118: background scan intentionally outlives the request + defer cancel() + s.runScan(runCtx, job.ID) + }() return job, nil } @@ -1252,7 +1255,7 @@ func (s *Service) CancelSession(ctx context.Context, sessionID string) error { if s.agents != nil { for _, task := range tasks { if task.agentID != "" { - s.agents.CancelTask(task.agentID, task.taskID) + _ = s.agents.CancelTask(task.agentID, task.taskID) } } } diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index bae1a15d..f9e55441 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -188,7 +188,7 @@ func ensureSessionForeignKeys(db *sql.DB) error { if err != nil { return err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() aopConstrained, err := hasCascadeForeignKey(tx, "chat_aop_events", "session_id", "chat_sessions", "id") if err != nil { @@ -517,9 +517,10 @@ func (s *SQLiteStore) TransitionScan(ctx context.Context, job *ScanJob, expected placeholders[i] = "?" args = append(args, string(status)) } - result, err := s.db.ExecContext(ctx, - `UPDATE scans SET ai=?, verify=?, sniper=?, deep=?, status=?, progress=?, report=?, result=?, error=?, updated_at=? - WHERE id=? AND status IN (`+strings.Join(placeholders, ",")+`)`, args...) + //nolint:gosec // only fixed "?" placeholders are concatenated; statuses remain bound arguments + query := `UPDATE scans SET ai=?, verify=?, sniper=?, deep=?, status=?, progress=?, report=?, result=?, error=?, updated_at=? + WHERE id=? AND status IN (` + strings.Join(placeholders, ",") + `)` + result, err := s.db.ExecContext(ctx, query, args...) if err != nil { return false, err } @@ -700,7 +701,7 @@ func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, even if err != nil { return 0, false, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(hub_seq), 0) + 1 FROM chat_aop_events WHERE session_id = ?`, sessionID, ).Scan(&cursor); err != nil { From b18ae981f25f12eed0eee701277aee7aaec6c69c Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 13:50:29 +0800 Subject: [PATCH 138/348] refactor(agent): enforce root package dependency layers --- .gitattributes | 3 + {pkg/agent => agent}/agent.go | 21 +- {pkg/agent => agent}/agent_test.go | 4 +- {pkg/agent => agent}/aop_emit.go | 4 +- {pkg/agent => agent}/aop_emit_test.go | 2 +- {pkg/agent => agent}/compact.go | 4 +- {pkg/agent => agent}/compact_test.go | 0 {pkg/agent => agent}/context_window.go | 0 {pkg/agent => agent}/cron.go | 0 {pkg/agent => agent}/cron_test.go | 0 {pkg/agent => agent}/defaults.go | 2 +- {pkg/agent => agent}/evaluator/evaluator.go | 8 +- {pkg/agent => agent}/evaluator/loop.go | 8 +- {pkg/agent => agent}/evaluator/loop_test.go | 6 +- agent/external_import_test.go | 18 + {pkg/agent => agent}/finish_tool.go | 0 {pkg/agent => agent}/helpers_test.go | 4 +- agent/hooks/hooks.go | 310 ++++ agent/hooks/hooks_test.go | 458 +++++ agent/hooks/points.go | 182 ++ agent/hooks/reduce.go | 23 + agent/hooks_emit.go | 175 ++ agent/inbox/context.go | 23 + {pkg/agent => agent}/inbox/expand.go | 2 +- {pkg/agent => agent}/inbox/expand_test.go | 0 {pkg/agent => agent}/inbox/inbox.go | 0 {pkg/agent => agent}/inbox/inbox_test.go | 0 {pkg/agent => agent}/inbox/message.go | 2 +- {pkg/agent => agent}/input.go | 2 +- {pkg/agent => agent}/input_test.go | 2 +- {pkg/agent => agent}/loop.go | 139 +- agent/loop_context.go | 29 + {pkg/agent => agent}/loop_scheduler.go | 4 +- {pkg/agent => agent}/loop_test.go | 22 +- {pkg/agent => agent}/overflow.go | 0 {pkg/agent => agent}/probe/llm.go | 2 +- {pkg/agent => agent}/provider/anthropic.go | 0 {pkg/agent => agent}/provider/cache_test.go | 0 .../provider/capability_parity_test.go | 0 .../provider/endpoint_hint_test.go | 0 {pkg/agent => agent}/provider/errors.go | 0 {pkg/agent => agent}/provider/http.go | 0 {pkg/agent => agent}/provider/openai.go | 0 {pkg/agent => agent}/provider/provider.go | 0 .../agent => agent}/provider/provider_test.go | 0 {pkg/agent => agent}/provider/types.go | 0 {pkg/agent => agent}/provider_swap_test.go | 0 {pkg/agent => agent}/retry.go | 6 +- {pkg/agent => agent}/retry_test.go | 62 +- {pkg/agent => agent}/session.go | 0 {pkg/agent => agent}/session_test.go | 0 {pkg/agent => agent}/subagent.go | 6 +- {pkg/agent => agent}/subagent_test.go | 6 +- {pkg/agent => agent}/tmux/manager.go | 0 {pkg/agent => agent}/tmux/manager_test.go | 0 .../tmux/process_alive_unix_test.go | 0 .../tmux/process_alive_windows_test.go | 0 {pkg/agent => agent}/tool_context.go | 0 {pkg/agent => agent}/types.go | 41 +- cmd/agent/capability_test.go | 24 + cmd/agent/main.go | 9 +- cmd/aiscan/capability_default_test.go | 17 + cmd/aiscan/capability_full_test.go | 17 + cmd/aiscan/cli.go | 8 +- cmd/aiscan/cli_test.go | 18 +- cmd/aiscan/race_norace_test.go | 5 - cmd/aiscan/race_test.go | 5 - cmd/aiscan/setup.go | 27 +- cmd/aiscan/web_full.go | 8 +- cmd/aiscan/web_full_test.go | 2 +- cmd/runner/main.go | 14 +- cmd/runner/main_test.go | 2 +- {pkg => core}/aop/decode.go | 0 {pkg => core}/aop/event.go | 0 {pkg => core}/aop/ext.go | 2 +- {pkg => core}/aop/ext_types_gen.go | 0 {pkg => core}/aop/gen_error.go | 0 {pkg => core}/aop/gen_message.go | 0 {pkg => core}/aop/gen_message_delta.go | 0 {pkg => core}/aop/gen_session_start.go | 0 {pkg => core}/aop/gen_status.go | 0 {pkg => core}/aop/gen_tool_call.go | 0 {pkg => core}/aop/gen_tool_result.go | 0 {pkg => core}/aop/gen_turn.go | 0 {pkg => core}/aop/gen_usage_session.go | 0 {pkg => core}/aop/generate.go | 0 {pkg => core}/aop/schema_test.go | 0 {pkg => core}/aop/tool_result.go | 0 {pkg => core}/aop/tool_result_test.go | 0 {pkg => core}/aop/x/command/command.go | 2 +- {pkg => core}/aop/x/compact/compact.go | 2 +- {pkg => core}/aop/x/compact/generate.go | 0 {pkg => core}/aop/x/compact/types_gen.go | 0 {pkg => core}/aop/x/delegation/delegation.go | 2 +- {pkg => core}/aop/x/delegation/generate.go | 0 {pkg => core}/aop/x/delegation/types_gen.go | 0 {pkg => core}/aop/x/eval/eval.go | 2 +- {pkg => core}/aop/x/eval/generate.go | 0 {pkg => core}/aop/x/eval/types_gen.go | 0 {pkg => core}/aop/x/ioa/generate.go | 0 {pkg => core}/aop/x/ioa/ioa.go | 2 +- {pkg => core}/aop/x/ioa/types_gen.go | 0 core/capability/capability.go | 148 ++ core/capability/capability_test.go | 114 ++ core/capability/gated.go | 29 + core/capability/plan.go | 78 + core/capability/query.go | 59 + core/config/defaults.go | 23 + core/config/env.go | 34 +- core/config/loader_test.go | 124 +- core/config/scanner.go | 62 +- core/config/scanner_katana.go | 12 - core/deps/architecture_test.go | 154 ++ core/deps/deps.go | 72 + core/deps/quality_test.go | 286 +++ core/harness/expect.go | 204 --- core/harness/features_full_test.go | 9 - core/harness/features_test.go | 9 - core/harness/harness.go | 331 ---- core/harness/harness_test.go | 1528 ----------------- core/harness/intent.go | 167 -- core/harness/judge.go | 205 --- core/harness/monitor.go | 76 - core/harness/result.go | 233 --- core/harness/stdio.go | 128 -- core/harness/stdio_test.go | 195 --- core/harness/verify.go | 278 --- core/output/format.go | 2 +- core/output/format_asset.go | 354 +--- core/output/report.go | 854 +++++++++ core/output/report_golden_test.go | 113 ++ core/output/testdata/asset_color.golden | 43 + core/output/testdata/asset_empty.golden | 2 + core/output/testdata/asset_plain.golden | 43 + core/output/testdata/md_tool.golden | 116 ++ core/output/testdata/md_tool_empty.golden | 22 + core/output/testdata/md_web_empty_en.golden | 9 + core/output/testdata/md_web_empty_zh.golden | 9 + core/output/testdata/md_web_en.golden | 67 + core/output/testdata/md_web_nil.golden | 7 + core/output/testdata/md_web_zh.golden | 67 + core/output/testdata/report_empty.json | 3 + core/output/testdata/report_fixture.json | 173 ++ core/output/timeline.go | 4 +- core/output/timeline_test.go | 4 +- core/resources/keys.go | 7 + {pkg => core}/telemetry/logger.go | 0 {pkg => core}/telemetry/logger_test.go | 0 {pkg => core}/telemetry/recover.go | 0 {pkg => core}/telemetry/recover_test.go | 0 {pkg => core}/telemetry/startup.go | 0 {pkg => core}/telemetry/startup_test.go | 0 {pkg/agent => core}/truncate/clip.go | 0 {pkg/agent => core}/truncate/clip_test.go | 0 {pkg/agent => core}/truncate/truncate.go | 2 +- {pkg/agent => core}/truncate/truncate_test.go | 0 {pkg => core}/util/format.go | 0 docs/development.md | 6 +- docs/mechanisms.md | 10 +- pkg/commands/bash.go | 8 +- pkg/commands/bash_inbox_test.go | 2 +- pkg/commands/bash_test.go | 4 +- pkg/commands/command.go | 2 +- pkg/commands/context.go | 27 - pkg/commands/execution.go | 2 +- pkg/commands/factory.go | 67 +- pkg/commands/glob.go | 2 +- pkg/commands/image_optimize.go | 2 +- pkg/commands/list.go | 2 +- pkg/commands/read.go | 2 +- pkg/commands/register.go | 14 +- pkg/commands/register_test.go | 10 +- pkg/commands/tmux.go | 4 +- pkg/commands/tmux_test.go | 2 +- pkg/commands/write.go | 2 +- pkg/{agent => }/probe/config.go | 0 pkg/{agent => }/probe/conn.go | 0 {core => pkg}/runner/app.go | 63 +- {core => pkg}/runner/app_test.go | 4 +- .../runner/application_builder.go | 17 +- .../runner/application_config.go | 12 +- {core => pkg}/runner/hooks.go | 4 +- {core => pkg}/runner/ioa.go | 2 +- {core => pkg}/runner/local_repl.go | 0 .../loop_tool.go => runner/loop_command.go} | 53 +- {core => pkg}/runner/prompt.go | 2 +- {core => pkg}/runner/prompt_test.go | 2 +- .../runner/provider_config.go | 65 +- pkg/runner/provider_config_test.go | 39 + {core => pkg}/runner/remote_repl.go | 0 {core => pkg}/runner/remote_repl_test.go | 2 +- {core => pkg}/runner/runner.go | 27 +- pkg/runner/runtime_config.go | 18 + {core => pkg}/runner/runtime_protocol.go | 0 {core => pkg}/runner/runtime_protocol_test.go | 0 .../runner/runtime_semantics_test.go | 13 +- {core => pkg}/runner/runtime_session.go | 14 +- .../runner/runtime_session_isolation_test.go | 11 +- {core => pkg}/runner/scanner.go | 10 +- {core => pkg}/runner/stdio.go | 4 +- .../runner/stdio_concurrency_test.go | 4 +- {core => pkg}/runner/stdio_test.go | 4 +- {core => pkg}/runner/subagent_handoff.go | 8 +- {core => pkg}/runner/subagent_handoff_test.go | 4 +- {core => pkg}/transport/transport.go | 4 +- pkg/tui/banner_render_test.go | 2 +- pkg/tui/commands.go | 4 +- pkg/tui/console.go | 13 +- pkg/tui/console_test.go | 2 +- pkg/tui/controller.go | 6 +- pkg/tui/controller_test.go | 2 +- pkg/tui/format.go | 8 +- pkg/tui/live.go | 6 +- pkg/tui/output.go | 12 +- pkg/tui/output_test.go | 6 +- pkg/tui/remote_console.go | 4 +- pkg/tui/remote_console_test.go | 4 +- pkg/tui/render.go | 2 +- pkg/web/agents.go | 2 +- pkg/web/agents_session_end_test.go | 2 +- pkg/web/agents_test.go | 2 +- pkg/web/config_transaction_test.go | 2 +- pkg/web/conn_probe_test.go | 2 +- pkg/web/eval_forward_test.go | 6 +- pkg/web/handler.go | 2 +- pkg/web/llm_probe_test.go | 2 +- pkg/web/probe.go | 11 +- pkg/web/replay_test.go | 2 +- pkg/web/report.go | 18 + pkg/web/service.go | 378 +--- pkg/web/sse_test.go | 4 +- pkg/web/store_sqlite.go | 2 +- pkg/web/store_sqlite_test.go | 2 +- pkg/web/types.go | 2 +- pkg/webagent/agent.go | 18 +- pkg/webagent/agent_test.go | 7 +- pkg/webagent/aop_tool.go | 2 +- pkg/webagent/aop_tool_test.go | 2 +- pkg/webagent/connection.go | 6 +- pkg/webagent/connection_lifecycle_test.go | 2 +- pkg/webagent/pty.go | 2 +- pkg/webagent/stream.go | 2 +- pkg/webagent/toolnode.go | 2 +- pkg/webagent/toolnode_test.go | 2 +- pkg/webproto/message.go | 2 +- pkg/webproto/message_test.go | 2 +- skills/availability.go | 12 +- skills/availability_full.go | 8 - test-skips.json | 198 +++ tools/arsenal/register.go | 8 +- tools/capability_test.go | 10 + tools/functional_integration_full_test.go | 9 +- tools/functional_integration_test.go | 12 +- tools/functional_norace_test.go | 5 - tools/functional_race_test.go | 5 - tools/functional_regression_full_test.go | 16 +- tools/functional_regression_test.go | 29 +- tools/functional_testkit_test.go | 16 +- tools/gogo/gogo.go | 2 +- tools/gogo/gogo_test.go | 2 +- tools/gogo/register.go | 22 +- tools/ioa/commands.go | 2 +- tools/ioa/commands_test.go | 2 +- tools/ioa/keys.go | 9 + tools/ioa/register.go | 18 +- tools/katana/katana.go | 2 +- tools/katana/register.go | 4 +- tools/neutron/neutron.go | 2 +- tools/neutron/register.go | 22 +- tools/neutron/sdk_stage.go | 2 +- tools/passive/passive.go | 2 +- tools/passive/register.go | 29 +- tools/playwright/advanced.go | 2 +- tools/playwright/browser.go | 10 +- tools/playwright/interact.go | 2 +- tools/playwright/register.go | 8 +- tools/playwright/session.go | 50 +- tools/proton/command.go | 2 +- tools/proton/register.go | 28 +- tools/proton/register_test.go | 3 +- tools/proxy/command.go | 2 +- tools/proxy/mitm.go | 2 +- tools/proxy/mitm_test.go | 62 +- tools/proxy/race_norace_test.go | 5 - tools/proxy/race_test.go | 5 - tools/proxy/register_command.go | 4 +- tools/proxy/state.go | 6 +- tools/register_command.go | 41 +- tools/register_command_integration_test.go | 2 +- tools/register_command_test.go | 9 +- tools/scan/collector.go | 9 +- tools/scan/command.go | 6 +- tools/scan/command_test.go | 2 +- tools/scan/engine/gogo.go | 4 +- tools/scan/engine/keys.go | 7 + tools/scan/engine/neutron.go | 2 +- tools/scan/engine/race_norace_test.go | 5 - tools/scan/engine/race_test.go | 5 - tools/scan/engine/set.go | 4 +- tools/scan/engine/set_test.go | 5 +- tools/scan/engine/set_uncover_recon.go | 2 +- tools/scan/engine/set_uncover_stub.go | 2 +- tools/scan/engine/spray.go | 14 +- tools/scan/engine/uncover.go | 2 +- tools/scan/engine/zombie.go | 4 +- tools/scan/event.go | 2 +- tools/scan/http_auth.go | 2 +- tools/scan/input.go | 2 +- tools/scan/jsonl_writer.go | 2 +- tools/scan/keys.go | 7 + tools/scan/options.go | 4 +- tools/scan/pipeline/pipeline.go | 2 +- tools/scan/report.go | 216 +-- tools/scan/target.go | 2 +- tools/scan/verify.go | 4 +- tools/search/fetch.go | 4 +- tools/search/register.go | 32 +- tools/search/tavily.go | 2 +- tools/search/websearch.go | 2 +- tools/search/websearch_tool.go | 2 +- tools/spray/register.go | 22 +- tools/spray/spray.go | 2 +- tools/spray/spray_test.go | 2 +- tools/toolargs/base.go | 2 +- tools/zombie/register.go | 22 +- tools/zombie/zombie.go | 2 +- tools/zombie/zombie_test.go | 2 +- 327 files changed, 5029 insertions(+), 5394 deletions(-) rename {pkg/agent => agent}/agent.go (95%) rename {pkg/agent => agent}/agent_test.go (99%) rename {pkg/agent => agent}/aop_emit.go (98%) rename {pkg/agent => agent}/aop_emit_test.go (99%) rename {pkg/agent => agent}/compact.go (98%) rename {pkg/agent => agent}/compact_test.go (100%) rename {pkg/agent => agent}/context_window.go (100%) rename {pkg/agent => agent}/cron.go (100%) rename {pkg/agent => agent}/cron_test.go (100%) rename {pkg/agent => agent}/defaults.go (87%) rename {pkg/agent => agent}/evaluator/evaluator.go (96%) rename {pkg/agent => agent}/evaluator/loop.go (96%) rename {pkg/agent => agent}/evaluator/loop_test.go (95%) create mode 100644 agent/external_import_test.go rename {pkg/agent => agent}/finish_tool.go (100%) rename {pkg/agent => agent}/helpers_test.go (99%) create mode 100644 agent/hooks/hooks.go create mode 100644 agent/hooks/hooks_test.go create mode 100644 agent/hooks/points.go create mode 100644 agent/hooks/reduce.go create mode 100644 agent/hooks_emit.go create mode 100644 agent/inbox/context.go rename {pkg/agent => agent}/inbox/expand.go (97%) rename {pkg/agent => agent}/inbox/expand_test.go (100%) rename {pkg/agent => agent}/inbox/inbox.go (100%) rename {pkg/agent => agent}/inbox/inbox_test.go (100%) rename {pkg/agent => agent}/inbox/message.go (98%) rename {pkg/agent => agent}/input.go (98%) rename {pkg/agent => agent}/input_test.go (98%) rename {pkg/agent => agent}/loop.go (88%) create mode 100644 agent/loop_context.go rename {pkg/agent => agent}/loop_scheduler.go (98%) rename {pkg/agent => agent}/loop_test.go (98%) rename {pkg/agent => agent}/overflow.go (100%) rename {pkg/agent => agent}/probe/llm.go (99%) rename {pkg/agent => agent}/provider/anthropic.go (100%) rename {pkg/agent => agent}/provider/cache_test.go (100%) rename {pkg/agent => agent}/provider/capability_parity_test.go (100%) rename {pkg/agent => agent}/provider/endpoint_hint_test.go (100%) rename {pkg/agent => agent}/provider/errors.go (100%) rename {pkg/agent => agent}/provider/http.go (100%) rename {pkg/agent => agent}/provider/openai.go (100%) rename {pkg/agent => agent}/provider/provider.go (100%) rename {pkg/agent => agent}/provider/provider_test.go (100%) rename {pkg/agent => agent}/provider/types.go (100%) rename {pkg/agent => agent}/provider_swap_test.go (100%) rename {pkg/agent => agent}/retry.go (98%) rename {pkg/agent => agent}/retry_test.go (89%) rename {pkg/agent => agent}/session.go (100%) rename {pkg/agent => agent}/session_test.go (100%) rename {pkg/agent => agent}/subagent.go (98%) rename {pkg/agent => agent}/subagent_test.go (97%) rename {pkg/agent => agent}/tmux/manager.go (100%) rename {pkg/agent => agent}/tmux/manager_test.go (100%) rename {pkg/agent => agent}/tmux/process_alive_unix_test.go (100%) rename {pkg/agent => agent}/tmux/process_alive_windows_test.go (100%) rename {pkg/agent => agent}/tool_context.go (100%) rename {pkg/agent => agent}/types.go (88%) create mode 100644 cmd/agent/capability_test.go create mode 100644 cmd/aiscan/capability_default_test.go create mode 100644 cmd/aiscan/capability_full_test.go delete mode 100644 cmd/aiscan/race_norace_test.go delete mode 100644 cmd/aiscan/race_test.go rename {pkg => core}/aop/decode.go (100%) rename {pkg => core}/aop/event.go (100%) rename {pkg => core}/aop/ext.go (93%) rename {pkg => core}/aop/ext_types_gen.go (100%) rename {pkg => core}/aop/gen_error.go (100%) rename {pkg => core}/aop/gen_message.go (100%) rename {pkg => core}/aop/gen_message_delta.go (100%) rename {pkg => core}/aop/gen_session_start.go (100%) rename {pkg => core}/aop/gen_status.go (100%) rename {pkg => core}/aop/gen_tool_call.go (100%) rename {pkg => core}/aop/gen_tool_result.go (100%) rename {pkg => core}/aop/gen_turn.go (100%) rename {pkg => core}/aop/gen_usage_session.go (100%) rename {pkg => core}/aop/generate.go (100%) rename {pkg => core}/aop/schema_test.go (100%) rename {pkg => core}/aop/tool_result.go (100%) rename {pkg => core}/aop/tool_result_test.go (100%) rename {pkg => core}/aop/x/command/command.go (87%) rename {pkg => core}/aop/x/compact/compact.go (86%) rename {pkg => core}/aop/x/compact/generate.go (100%) rename {pkg => core}/aop/x/compact/types_gen.go (100%) rename {pkg => core}/aop/x/delegation/delegation.go (83%) rename {pkg => core}/aop/x/delegation/generate.go (100%) rename {pkg => core}/aop/x/delegation/types_gen.go (100%) rename {pkg => core}/aop/x/eval/eval.go (90%) rename {pkg => core}/aop/x/eval/generate.go (100%) rename {pkg => core}/aop/x/eval/types_gen.go (100%) rename {pkg => core}/aop/x/ioa/generate.go (100%) rename {pkg => core}/aop/x/ioa/ioa.go (82%) rename {pkg => core}/aop/x/ioa/types_gen.go (100%) create mode 100644 core/capability/capability.go create mode 100644 core/capability/capability_test.go create mode 100644 core/capability/gated.go create mode 100644 core/capability/plan.go create mode 100644 core/capability/query.go create mode 100644 core/config/defaults.go delete mode 100644 core/config/scanner_katana.go create mode 100644 core/deps/architecture_test.go create mode 100644 core/deps/deps.go create mode 100644 core/deps/quality_test.go delete mode 100644 core/harness/expect.go delete mode 100644 core/harness/features_full_test.go delete mode 100644 core/harness/features_test.go delete mode 100644 core/harness/harness.go delete mode 100644 core/harness/harness_test.go delete mode 100644 core/harness/intent.go delete mode 100644 core/harness/judge.go delete mode 100644 core/harness/monitor.go delete mode 100644 core/harness/result.go delete mode 100644 core/harness/stdio.go delete mode 100644 core/harness/stdio_test.go delete mode 100644 core/harness/verify.go create mode 100644 core/output/report.go create mode 100644 core/output/report_golden_test.go create mode 100644 core/output/testdata/asset_color.golden create mode 100644 core/output/testdata/asset_empty.golden create mode 100644 core/output/testdata/asset_plain.golden create mode 100644 core/output/testdata/md_tool.golden create mode 100644 core/output/testdata/md_tool_empty.golden create mode 100644 core/output/testdata/md_web_empty_en.golden create mode 100644 core/output/testdata/md_web_empty_zh.golden create mode 100644 core/output/testdata/md_web_en.golden create mode 100644 core/output/testdata/md_web_nil.golden create mode 100644 core/output/testdata/md_web_zh.golden create mode 100644 core/output/testdata/report_empty.json create mode 100644 core/output/testdata/report_fixture.json create mode 100644 core/resources/keys.go rename {pkg => core}/telemetry/logger.go (100%) rename {pkg => core}/telemetry/logger_test.go (100%) rename {pkg => core}/telemetry/recover.go (100%) rename {pkg => core}/telemetry/recover_test.go (100%) rename {pkg => core}/telemetry/startup.go (100%) rename {pkg => core}/telemetry/startup_test.go (100%) rename {pkg/agent => core}/truncate/clip.go (100%) rename {pkg/agent => core}/truncate/clip_test.go (100%) rename {pkg/agent => core}/truncate/truncate.go (99%) rename {pkg/agent => core}/truncate/truncate_test.go (100%) rename {pkg => core}/util/format.go (100%) delete mode 100644 pkg/commands/context.go rename pkg/{agent => }/probe/config.go (100%) rename pkg/{agent => }/probe/conn.go (100%) rename {core => pkg}/runner/app.go (89%) rename {core => pkg}/runner/app_test.go (96%) rename core/config/app_config.go => pkg/runner/application_builder.go (74%) rename core/config/runtime.go => pkg/runner/application_config.go (81%) rename {core => pkg}/runner/hooks.go (86%) rename {core => pkg}/runner/ioa.go (95%) rename {core => pkg}/runner/local_repl.go (100%) rename pkg/{agent/loop_tool.go => runner/loop_command.go} (70%) rename {core => pkg}/runner/prompt.go (99%) rename {core => pkg}/runner/prompt_test.go (98%) rename core/config/provider.go => pkg/runner/provider_config.go (62%) create mode 100644 pkg/runner/provider_config_test.go rename {core => pkg}/runner/remote_repl.go (100%) rename {core => pkg}/runner/remote_repl_test.go (99%) rename {core => pkg}/runner/runner.go (96%) create mode 100644 pkg/runner/runtime_config.go rename {core => pkg}/runner/runtime_protocol.go (100%) rename {core => pkg}/runner/runtime_protocol_test.go (100%) rename {core => pkg}/runner/runtime_semantics_test.go (93%) rename {core => pkg}/runner/runtime_session.go (98%) rename {core => pkg}/runner/runtime_session_isolation_test.go (87%) rename {core => pkg}/runner/scanner.go (90%) rename {core => pkg}/runner/stdio.go (96%) rename {core => pkg}/runner/stdio_concurrency_test.go (98%) rename {core => pkg}/runner/stdio_test.go (98%) rename {core => pkg}/runner/subagent_handoff.go (97%) rename {core => pkg}/runner/subagent_handoff_test.go (98%) rename {core => pkg}/transport/transport.go (89%) create mode 100644 pkg/web/report.go delete mode 100644 skills/availability_full.go create mode 100644 test-skips.json create mode 100644 tools/capability_test.go delete mode 100644 tools/functional_norace_test.go delete mode 100644 tools/functional_race_test.go create mode 100644 tools/ioa/keys.go delete mode 100644 tools/proxy/race_norace_test.go delete mode 100644 tools/proxy/race_test.go create mode 100644 tools/scan/engine/keys.go delete mode 100644 tools/scan/engine/race_norace_test.go delete mode 100644 tools/scan/engine/race_test.go create mode 100644 tools/scan/keys.go diff --git a/.gitattributes b/.gitattributes index dfdb8b77..9885429f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ *.sh text eol=lf +*.go text eol=lf +go.mod text eol=lf +go.sum text eol=lf diff --git a/pkg/agent/agent.go b/agent/agent.go similarity index 95% rename from pkg/agent/agent.go rename to agent/agent.go index 388a9105..f14b92bd 100644 --- a/pkg/agent/agent.go +++ b/agent/agent.go @@ -5,9 +5,9 @@ import ( "fmt" "sync" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/aop/x/delegation" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/aop/x/delegation" + "github.com/chainreactors/aiscan/core/telemetry" ) type Agent struct { @@ -81,17 +81,15 @@ func (a *Agent) SessionID() string { } func (a *Agent) beginSession() { - a.mu.Lock() - em, model := a.Cfg.emitter, a.Cfg.Model - a.mu.Unlock() - em.sessionStart(model) + cfg := a.configSnapshot() + cfg.emitter.sessionStart(cfg.Model) + emitSessionStart(context.Background(), cfg) } func (a *Agent) endSession(reason string) { - a.mu.Lock() - em := a.Cfg.emitter - a.mu.Unlock() - em.sessionEnd(reason) + cfg := a.configSnapshot() + cfg.emitter.sessionEnd(reason) + emitSessionEnd(context.Background(), cfg, reason) } // Continue resumes the agent without a new prompt (e.g. after tool results). @@ -235,6 +233,7 @@ func deriveNamedFromConfig(cfg Config, name, parentToolCallID string, detail *de Temperature: cfg.Temperature, CacheRetention: cfg.CacheRetention, Bus: cfg.Bus, + Hooks: cfg.Hooks, AgentName: name, ParentSessionID: cfg.SessionID, ParentToolCallID: parentToolCallID, diff --git a/pkg/agent/agent_test.go b/agent/agent_test.go similarity index 99% rename from pkg/agent/agent_test.go rename to agent/agent_test.go index 5af484c8..637c77de 100644 --- a/pkg/agent/agent_test.go +++ b/agent/agent_test.go @@ -12,9 +12,9 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/skills" ) diff --git a/pkg/agent/aop_emit.go b/agent/aop_emit.go similarity index 98% rename from pkg/agent/aop_emit.go rename to agent/aop_emit.go index 7149fc9a..173717f8 100644 --- a/pkg/agent/aop_emit.go +++ b/agent/aop_emit.go @@ -6,9 +6,9 @@ import ( "sync/atomic" "time" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/aop/x/delegation" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/aop/x/delegation" ) // aopEmitter is the agent kernel's single event-emission path. Every event diff --git a/pkg/agent/aop_emit_test.go b/agent/aop_emit_test.go similarity index 99% rename from pkg/agent/aop_emit_test.go rename to agent/aop_emit_test.go index 9de95842..d858286a 100644 --- a/pkg/agent/aop_emit_test.go +++ b/agent/aop_emit_test.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "testing" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" ) // streamEventCollector records message/message.delta events from the bus. diff --git a/pkg/agent/compact.go b/agent/compact.go similarity index 98% rename from pkg/agent/compact.go rename to agent/compact.go index bf79ff40..e1b6786b 100644 --- a/pkg/agent/compact.go +++ b/agent/compact.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" - "github.com/chainreactors/aiscan/pkg/agent/truncate" - xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" + xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" + "github.com/chainreactors/aiscan/core/truncate" ) const compactSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. diff --git a/pkg/agent/compact_test.go b/agent/compact_test.go similarity index 100% rename from pkg/agent/compact_test.go rename to agent/compact_test.go diff --git a/pkg/agent/context_window.go b/agent/context_window.go similarity index 100% rename from pkg/agent/context_window.go rename to agent/context_window.go diff --git a/pkg/agent/cron.go b/agent/cron.go similarity index 100% rename from pkg/agent/cron.go rename to agent/cron.go diff --git a/pkg/agent/cron_test.go b/agent/cron_test.go similarity index 100% rename from pkg/agent/cron_test.go rename to agent/cron_test.go diff --git a/pkg/agent/defaults.go b/agent/defaults.go similarity index 87% rename from pkg/agent/defaults.go rename to agent/defaults.go index b3d55c7a..1b6a392e 100644 --- a/pkg/agent/defaults.go +++ b/agent/defaults.go @@ -1,6 +1,6 @@ package agent -import "github.com/chainreactors/aiscan/pkg/agent/truncate" +import "github.com/chainreactors/aiscan/core/truncate" const ( DefaultMaxResultSize = truncate.DefaultMaxBytes diff --git a/pkg/agent/evaluator/evaluator.go b/agent/evaluator/evaluator.go similarity index 96% rename from pkg/agent/evaluator/evaluator.go rename to agent/evaluator/evaluator.go index 734ab26f..e4608ac5 100644 --- a/pkg/agent/evaluator/evaluator.go +++ b/agent/evaluator/evaluator.go @@ -7,10 +7,10 @@ import ( "strings" "time" - agentpkg "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/provider" - "github.com/chainreactors/aiscan/pkg/agent/truncate" - "github.com/chainreactors/aiscan/pkg/telemetry" + agentpkg "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/truncate" ) const ( diff --git a/pkg/agent/evaluator/loop.go b/agent/evaluator/loop.go similarity index 96% rename from pkg/agent/evaluator/loop.go rename to agent/evaluator/loop.go index 0558bc34..c41febb9 100644 --- a/pkg/agent/evaluator/loop.go +++ b/agent/evaluator/loop.go @@ -5,10 +5,10 @@ import ( "fmt" "strings" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/provider" - xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" + xeval "github.com/chainreactors/aiscan/core/aop/x/eval" + "github.com/chainreactors/aiscan/core/telemetry" ) const defaultMaxEvalRounds = 3 diff --git a/pkg/agent/evaluator/loop_test.go b/agent/evaluator/loop_test.go similarity index 95% rename from pkg/agent/evaluator/loop_test.go rename to agent/evaluator/loop_test.go index 9877cbe2..cd63ac0e 100644 --- a/pkg/agent/evaluator/loop_test.go +++ b/agent/evaluator/loop_test.go @@ -4,10 +4,10 @@ import ( "context" "testing" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/provider" - "github.com/chainreactors/aiscan/pkg/aop" ) type fixedProvider struct { diff --git a/agent/external_import_test.go b/agent/external_import_test.go new file mode 100644 index 00000000..d4861f14 --- /dev/null +++ b/agent/external_import_test.go @@ -0,0 +1,18 @@ +package agent_test + +import ( + "testing" + + "github.com/chainreactors/aiscan/agent" +) + +func TestRootAgentPublicImport(t *testing.T) { + config := agent.Config{}. + WithModel("example-model"). + WithMaxTokens(256). + WithContextWindow(4096) + if config.Model != "example-model" || config.MaxTokens != 256 || config.ContextWindow != 4096 { + t.Fatalf("root agent config aliases/builders are not externally usable: %#v", config) + } + _ = agent.ProviderConfig{Model: "example-model"} +} diff --git a/pkg/agent/finish_tool.go b/agent/finish_tool.go similarity index 100% rename from pkg/agent/finish_tool.go rename to agent/finish_tool.go diff --git a/pkg/agent/helpers_test.go b/agent/helpers_test.go similarity index 99% rename from pkg/agent/helpers_test.go rename to agent/helpers_test.go index 6416f91a..e2850e77 100644 --- a/pkg/agent/helpers_test.go +++ b/agent/helpers_test.go @@ -10,10 +10,10 @@ import ( "sync/atomic" "testing" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/skills" ) diff --git a/agent/hooks/hooks.go b/agent/hooks/hooks.go new file mode 100644 index 00000000..816fc143 --- /dev/null +++ b/agent/hooks/hooks.go @@ -0,0 +1,310 @@ +// Package hooks is the agent kernel's single extension mechanism: typed hook +// points with explicit result semantics and error policies. +// +// A hook point is a package-level Point[E, R] descriptor carrying both type +// parameters, so callers write ToolCallHook.Emit(ctx, reg, ev) without spelling +// out E and R. The Registry stores handlers type-erased and is copy-on-write: +// registration takes a mutex, dispatch is a single atomic load. Tool calls run +// from N goroutines concurrently, so Emit must never block on registration. +package hooks + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" +) + +type Kind string + +type ErrorPolicy uint8 + +const ( + ContinueOnError ErrorPolicy = iota // collect, report, keep dispatching + FailClosed // first error aborts dispatch; caller must deny +) + +// HandlerError attributes a failure to the handler that produced it. Handlers +// are registered with a mandatory source so a hook failure never has to be +// traced back by hand. +type HandlerError struct { + Source string + Kind Kind + Err error +} + +func (e *HandlerError) Error() string { + return fmt.Sprintf("hook %s/%s: %v", e.Kind, e.Source, e.Err) +} + +func (e *HandlerError) Unwrap() error { return e.Err } + +// errTypeMismatch means two Points share a Kind with different E/R. Reporting it +// as a handler failure keeps the handler from silently vanishing. +var errTypeMismatch = errors.New("handler signature does not match hook point") + +// Reducer folds one handler result into the accumulated result. ev is a pointer +// so fold-style points can let the next handler observe the previous handler's +// change. Returning true short-circuits the remaining handlers. +type Reducer[E any, R any] func(acc *R, ev *E, out R) (stop bool) + +type Point[E any, R any] struct { + Kind Kind + Reduce Reducer[E, R] // nil => pure observation + OnError ErrorPolicy +} + +type entry struct { + id uint64 + source string + fn any // func(context.Context, E) (R, error), asserted back in dispatch +} + +// table is replaced wholesale on every registration change; readers only ever +// see a consistent immutable snapshot. +type table struct { + byKind map[Kind][]entry +} + +type errorSink struct { + fn func(*HandlerError) +} + +type cleanup struct { + id uint64 + fn func() +} + +type Registry struct { + mu sync.Mutex + nextID uint64 + cleanups []cleanup + + handlers atomic.Pointer[table] + sink atomic.Pointer[errorSink] +} + +func New() *Registry { + r := &Registry{} + r.handlers.Store(&table{byKind: map[Kind][]entry{}}) + return r +} + +// Has is the zero-handler fast path: one atomic load plus a map lookup, no locks +// and no allocations. +func (r *Registry) Has(kind Kind) bool { + return r.Len(kind) > 0 +} + +func (r *Registry) Len(kind Kind) int { + if r == nil { + return 0 + } + t := r.handlers.Load() + if t == nil { + return 0 + } + return len(t.byKind[kind]) +} + +// SetErrorSink installs the reporter for handler failures. Passing nil disables +// reporting; errors are still collected and returned by Emit. +func (r *Registry) SetErrorSink(fn func(*HandlerError)) { + if r == nil { + return + } + r.sink.Store(&errorSink{fn: fn}) +} + +func (r *Registry) report(he *HandlerError) { + s := r.sink.Load() + if s == nil || s.fn == nil { + return + } + s.fn(he) +} + +// AddCleanup registers a function to run on Clear. The returned remove is +// idempotent. +func (r *Registry) AddCleanup(fn func()) (remove func()) { + if r == nil || fn == nil { + return func() {} + } + r.mu.Lock() + r.nextID++ + id := r.nextID + r.cleanups = append(r.cleanups, cleanup{id: id, fn: fn}) + r.mu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + r.mu.Lock() + for i := range r.cleanups { + if r.cleanups[i].id == id { + r.cleanups = append(r.cleanups[:i], r.cleanups[i+1:]...) + break + } + } + r.mu.Unlock() + }) + } +} + +// Clear drops every handler and runs the registered cleanups in registration +// order. +func (r *Registry) Clear() { + if r == nil { + return + } + r.mu.Lock() + r.handlers.Store(&table{byKind: map[Kind][]entry{}}) + pending := r.cleanups + r.cleanups = nil + r.mu.Unlock() + + // Outside the lock so a cleanup may touch the registry itself. + for _, c := range pending { + c.fn() + } +} + +func (r *Registry) add(kind Kind, source string, fn any) func() { + r.mu.Lock() + r.nextID++ + id := r.nextID + next := r.cloneLocked() + prev := next.byKind[kind] + list := make([]entry, len(prev), len(prev)+1) + copy(list, prev) + next.byKind[kind] = append(list, entry{id: id, source: source, fn: fn}) + r.handlers.Store(next) + r.mu.Unlock() + + var once sync.Once + return func() { once.Do(func() { r.remove(kind, id) }) } +} + +func (r *Registry) remove(kind Kind, id uint64) { + r.mu.Lock() + defer r.mu.Unlock() + + old := r.handlers.Load() + if old == nil { + return + } + prev := old.byKind[kind] + idx := -1 + for i := range prev { + if prev[i].id == id { + idx = i + break + } + } + if idx < 0 { + return + } + next := r.cloneLocked() + if len(prev) == 1 { + delete(next.byKind, kind) + } else { + list := make([]entry, 0, len(prev)-1) + list = append(list, prev[:idx]...) + list = append(list, prev[idx+1:]...) + next.byKind[kind] = list + } + r.handlers.Store(next) +} + +// cloneLocked copies the kind map; the per-kind slices stay shared because an +// in-flight dispatch may still be iterating them. +func (r *Registry) cloneLocked() *table { + old := r.handlers.Load() + if old == nil { + return &table{byKind: make(map[Kind][]entry, 1)} + } + next := &table{byKind: make(map[Kind][]entry, len(old.byKind)+1)} + for k, v := range old.byKind { + next.byKind[k] = v + } + return next +} + +// On registers fn for this point and returns an idempotent unsubscribe that is +// safe to call from inside a dispatch. source is mandatory: an unattributable +// handler cannot be reported when it fails, so an empty source (or a nil fn) is +// a programming error and panics. A nil registry is not — hooks are optional +// wiring, so registration on one is a no-op. +func (p Point[E, R]) On(r *Registry, source string, fn func(context.Context, E) (R, error)) (unsubscribe func()) { + if source == "" { + panic("hooks: On requires a non-empty source for " + string(p.Kind)) + } + if fn == nil { + panic("hooks: On requires a non-nil handler for " + string(p.Kind)) + } + if r == nil { + return func() {} + } + return r.add(p.Kind, source, fn) +} + +// Emit runs the point's handlers sequentially in registration order and folds +// their results through Reduce. With no handlers it returns the zero result +// without allocating. +func (p Point[E, R]) Emit(ctx context.Context, r *Registry, ev E) (R, error) { + var zero R + if r == nil { + return zero, nil + } + t := r.handlers.Load() + if t == nil { + return zero, nil + } + entries := t.byKind[p.Kind] + if len(entries) == 0 { + return zero, nil + } + return p.dispatch(ctx, r, entries, ev) +} + +// dispatch is kept out of Emit so that taking &ev here does not force Emit's +// argument onto the heap on the zero-handler path. +// +//go:noinline +func (p Point[E, R]) dispatch(ctx context.Context, r *Registry, entries []entry, ev E) (R, error) { + var acc R + var errs []error + + // entries is an immutable snapshot: handlers may subscribe or unsubscribe + // during dispatch, and this round still sees the set it started with. + for _, e := range entries { + fn, ok := e.fn.(func(context.Context, E) (R, error)) + if !ok { + he := &HandlerError{Source: e.source, Kind: p.Kind, Err: errTypeMismatch} + r.report(he) + errs = append(errs, he) + if p.OnError == FailClosed { + return acc, errors.Join(errs...) + } + continue + } + out, err := fn(ctx, ev) + if err != nil { + he := &HandlerError{Source: e.source, Kind: p.Kind, Err: err} + r.report(he) + errs = append(errs, he) + if p.OnError == FailClosed { + return acc, errors.Join(errs...) + } + continue + } + if p.Reduce == nil { + continue + } + if p.Reduce(&acc, &ev, out) { + break + } + } + return acc, errors.Join(errs...) +} diff --git a/agent/hooks/hooks_test.go b/agent/hooks/hooks_test.go new file mode 100644 index 00000000..e00732ad --- /dev/null +++ b/agent/hooks/hooks_test.go @@ -0,0 +1,458 @@ +package hooks + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" +) + +func ptr[T any](v T) *T { return &v } + +func TestEmitRunsHandlersInRegistrationOrder(t *testing.T) { + r := New() + var order []string + for _, name := range []string{"a", "b", "c"} { + Context.On(r, name, func(_ context.Context, _ ContextEvent) (ContextResult, error) { + order = append(order, name) + return ContextResult{}, nil + }) + } + + if _, err := Context.Emit(context.Background(), r, ContextEvent{}); err != nil { + t.Fatalf("emit: %v", err) + } + if got := strings.Join(order, ""); got != "abc" { + t.Fatalf("order = %q, want %q", got, "abc") + } + if n := r.Len("context"); n != 3 { + t.Fatalf("Len = %d, want 3", n) + } +} + +func TestUnsubscribeIsIdempotent(t *testing.T) { + r := New() + var calls int + off := RunEnd.On(r, "counter", func(_ context.Context, _ RunEndEvent) (struct{}, error) { + calls++ + return struct{}{}, nil + }) + + if _, err := RunEnd.Emit(context.Background(), r, RunEndEvent{}); err != nil { + t.Fatalf("emit: %v", err) + } + off() + off() + if r.Has("run_end") { + t.Fatal("Has after unsubscribe = true") + } + if _, err := RunEnd.Emit(context.Background(), r, RunEndEvent{}); err != nil { + t.Fatalf("emit: %v", err) + } + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } +} + +// The in-flight dispatch works off an immutable snapshot, so a handler that +// unsubscribes a later handler does not affect the round already running. +func TestUnsubscribeDuringDispatch(t *testing.T) { + r := New() + var seen []string + var offSecond func() + + Context.On(r, "first", func(_ context.Context, _ ContextEvent) (ContextResult, error) { + seen = append(seen, "first") + offSecond() + return ContextResult{}, nil + }) + offSecond = Context.On(r, "second", func(_ context.Context, _ ContextEvent) (ContextResult, error) { + seen = append(seen, "second") + return ContextResult{}, nil + }) + + if _, err := Context.Emit(context.Background(), r, ContextEvent{}); err != nil { + t.Fatalf("emit: %v", err) + } + if got := strings.Join(seen, ","); got != "first,second" { + t.Fatalf("first dispatch = %q, want %q", got, "first,second") + } + + seen = nil + if _, err := Context.Emit(context.Background(), r, ContextEvent{}); err != nil { + t.Fatalf("emit: %v", err) + } + if got := strings.Join(seen, ","); got != "first" { + t.Fatalf("second dispatch = %q, want %q", got, "first") + } +} + +func TestFailClosedShortCircuits(t *testing.T) { + r := New() + var sunk []*HandlerError + r.SetErrorSink(func(he *HandlerError) { sunk = append(sunk, he) }) + + boom := errors.New("boom") + var secondRan bool + ToolCallHook.On(r, "proxy", func(_ context.Context, _ ToolCallEvent) (ToolCallResult, error) { + return ToolCallResult{}, boom + }) + ToolCallHook.On(r, "audit", func(_ context.Context, _ ToolCallEvent) (ToolCallResult, error) { + secondRan = true + return ToolCallResult{Block: true, Reason: "nope"}, nil + }) + + res, err := ToolCallHook.Emit(context.Background(), r, ToolCallEvent{}) + if err == nil { + t.Fatal("err = nil, want failure") + } + if secondRan { + t.Fatal("second handler ran after fail-closed abort") + } + if res.Block { + t.Fatal("result should be zero when dispatch aborts") + } + if !errors.Is(err, boom) { + t.Fatalf("errors.Is(err, boom) = false: %v", err) + } + + var he *HandlerError + if !errors.As(err, &he) { + t.Fatalf("errors.As(*HandlerError) = false: %v", err) + } + if he.Source != "proxy" || he.Kind != "tool_call" { + t.Fatalf("attribution = %s/%s, want tool_call/proxy", he.Kind, he.Source) + } + if got := he.Error(); got != "hook tool_call/proxy: boom" { + t.Fatalf("Error() = %q", got) + } + if len(sunk) != 1 || sunk[0] != he { + t.Fatalf("sink got %d errors, want the one reported", len(sunk)) + } +} + +func TestContinueOnErrorCollectsAndKeepsGoing(t *testing.T) { + r := New() + first := errors.New("first") + second := errors.New("second") + var ran int + + for _, tc := range []struct { + source string + err error + }{{"a", first}, {"b", second}, {"c", nil}} { + RunEnd.On(r, tc.source, func(_ context.Context, _ RunEndEvent) (struct{}, error) { + ran++ + return struct{}{}, tc.err + }) + } + + _, err := RunEnd.Emit(context.Background(), r, RunEndEvent{}) + if ran != 3 { + t.Fatalf("ran = %d, want 3", ran) + } + if !errors.Is(err, first) || !errors.Is(err, second) { + t.Fatalf("err = %v, want both collected", err) + } +} + +func TestToolResultPatchChaining(t *testing.T) { + r := New() + var observed string + + ToolResult.On(r, "redact", func(_ context.Context, ev ToolResultEvent) (ToolResultPatch, error) { + return ToolResultPatch{Content: ptr(ev.Content + "+redacted")}, nil + }) + ToolResult.On(r, "truncate", func(_ context.Context, ev ToolResultEvent) (ToolResultPatch, error) { + observed = ev.Content + return ToolResultPatch{IsError: ptr(true), Terminate: ptr(true)}, nil + }) + + patch, err := ToolResult.Emit(context.Background(), r, ToolResultEvent{Content: "raw"}) + if err != nil { + t.Fatalf("emit: %v", err) + } + if observed != "raw+redacted" { + t.Fatalf("second handler saw %q, want the first handler's patch", observed) + } + if patch.Content == nil || *patch.Content != "raw+redacted" { + t.Fatalf("patch.Content = %v", patch.Content) + } + if patch.IsError == nil || !*patch.IsError { + t.Fatalf("patch.IsError = %v", patch.IsError) + } + if patch.Terminate == nil || !*patch.Terminate { + t.Fatalf("patch.Terminate = %v", patch.Terminate) + } +} + +func TestBeforeRunFoldsSystemPromptAndAggregatesPrepend(t *testing.T) { + r := New() + var observed string + + BeforeRun.On(r, "base", func(_ context.Context, ev RunStartEvent) (RunStartResult, error) { + return RunStartResult{ + SystemPrompt: ptr(ev.SystemPrompt + "\nbase"), + Prepend: []Msg{{Role: "system", Content: ptr("one")}}, + }, nil + }) + BeforeRun.On(r, "extra", func(_ context.Context, ev RunStartEvent) (RunStartResult, error) { + observed = ev.SystemPrompt + return RunStartResult{ + SystemPrompt: ptr(ev.SystemPrompt + "\nextra"), + Prepend: []Msg{{Role: "user", Content: ptr("two")}}, + }, nil + }) + + res, err := BeforeRun.Emit(context.Background(), r, RunStartEvent{SystemPrompt: "root"}) + if err != nil { + t.Fatalf("emit: %v", err) + } + if observed != "root\nbase" { + t.Fatalf("second handler saw %q, want the folded prompt", observed) + } + if res.SystemPrompt == nil || *res.SystemPrompt != "root\nbase\nextra" { + t.Fatalf("SystemPrompt = %v", res.SystemPrompt) + } + if len(res.Prepend) != 2 || res.Prepend[0].Role != "system" || res.Prepend[1].Role != "user" { + t.Fatalf("Prepend = %+v", res.Prepend) + } +} + +func TestContextReplacementFolds(t *testing.T) { + r := New() + var observed int + + Context.On(r, "drop", func(_ context.Context, ev ContextEvent) (ContextResult, error) { + return ContextResult{Messages: ev.Messages[1:]}, nil + }) + Context.On(r, "noop", func(_ context.Context, ev ContextEvent) (ContextResult, error) { + observed = len(ev.Messages) + return ContextResult{}, nil + }) + + res, err := Context.Emit(context.Background(), r, ContextEvent{Messages: make([]Msg, 3)}) + if err != nil { + t.Fatalf("emit: %v", err) + } + if observed != 2 { + t.Fatalf("second handler saw %d messages, want 2", observed) + } + if len(res.Messages) != 2 { + t.Fatalf("result = %d messages, want 2", len(res.Messages)) + } +} + +func TestStopWhenShortCircuits(t *testing.T) { + r := New() + var ran int + + BeforeCompact.On(r, "budget", func(_ context.Context, _ CompactEvent) (CancelResult, error) { + ran++ + return CancelResult{Cancel: true, Reason: "still cheap"}, nil + }) + BeforeCompact.On(r, "never", func(_ context.Context, _ CompactEvent) (CancelResult, error) { + ran++ + return CancelResult{}, nil + }) + + res, err := BeforeCompact.Emit(context.Background(), r, CompactEvent{}) + if err != nil { + t.Fatalf("emit: %v", err) + } + if ran != 1 { + t.Fatalf("ran = %d, want 1", ran) + } + if !res.Cancel || res.Reason != "still cheap" { + t.Fatalf("res = %+v", res) + } +} + +func TestObservationPointsIgnoreResults(t *testing.T) { + r := New() + var ran int + for _, name := range []string{"a", "b"} { + SessionStart.On(r, name, func(_ context.Context, _ SessionEvent) (struct{}, error) { + ran++ + return struct{}{}, nil + }) + } + + res, err := SessionStart.Emit(context.Background(), r, SessionEvent{SessionID: "s1"}) + if err != nil { + t.Fatalf("emit: %v", err) + } + if ran != 2 { + t.Fatalf("ran = %d, want 2 (observation must not short-circuit)", ran) + } + if res != (struct{}{}) { + t.Fatal("observation result must be zero") + } +} + +var ( + sinkResult ToolCallResult + sinkErr error +) + +func TestEmitFastPathDoesNotAllocate(t *testing.T) { + r := New() + // A handler on a different kind ensures the map lookup misses rather than + // short-circuiting on an empty table. + RunEnd.On(r, "other", func(_ context.Context, _ RunEndEvent) (struct{}, error) { + return struct{}{}, nil + }) + if r.Has("tool_call") { + t.Fatal("Has(tool_call) = true") + } + + ctx := context.Background() + ev := ToolCallEvent{SessionID: "s1", TurnID: "t1", Call: ToolCall{ID: "c1"}} + + if got := testing.AllocsPerRun(100, func() { + sinkResult, sinkErr = ToolCallHook.Emit(ctx, r, ev) + }); got != 0 { + t.Fatalf("Emit allocs = %v, want 0", got) + } + if sinkErr != nil || sinkResult.Block { + t.Fatalf("fast path returned %+v, %v", sinkResult, sinkErr) + } + + if got := testing.AllocsPerRun(100, func() { + sinkResult, sinkErr = ToolCallHook.Emit(ctx, nil, ev) + }); got != 0 { + t.Fatalf("nil-registry Emit allocs = %v, want 0", got) + } +} + +func TestNilRegistryTolerated(t *testing.T) { + var r *Registry + if r.Has("tool_call") || r.Len("tool_call") != 0 { + t.Fatal("nil registry reports handlers") + } + r.SetErrorSink(func(*HandlerError) {}) + r.Clear() + off := ToolCallHook.On(r, "x", func(_ context.Context, _ ToolCallEvent) (ToolCallResult, error) { + return ToolCallResult{}, nil + }) + off() + + res, err := ToolCallHook.Emit(context.Background(), r, ToolCallEvent{}) + if err != nil || res.Block { + t.Fatalf("nil registry Emit = %+v, %v", res, err) + } +} + +func TestOnRequiresSource(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("On with empty source did not panic") + } + }() + ToolCallHook.On(New(), "", func(_ context.Context, _ ToolCallEvent) (ToolCallResult, error) { + return ToolCallResult{}, nil + }) +} + +func TestClearDropsHandlersAndRunsCleanups(t *testing.T) { + r := New() + RunEnd.On(r, "a", func(_ context.Context, _ RunEndEvent) (struct{}, error) { + return struct{}{}, nil + }) + + var order []string + r.AddCleanup(func() { order = append(order, "first") }) + removeSecond := r.AddCleanup(func() { order = append(order, "second") }) + r.AddCleanup(func() { order = append(order, "third") }) + removeSecond() + removeSecond() + + r.Clear() + if r.Has("run_end") { + t.Fatal("Clear left handlers behind") + } + if got := strings.Join(order, ","); got != "first,third" { + t.Fatalf("cleanups = %q, want %q", got, "first,third") + } + + r.Clear() + if len(order) != 2 { + t.Fatalf("cleanups ran twice: %v", order) + } +} + +// Two points sharing a Kind with different types must surface as an attributed +// error rather than a silently skipped handler. +func TestSignatureMismatchIsReported(t *testing.T) { + r := New() + imposter := Point[SessionEvent, struct{}]{Kind: ToolCallHook.Kind} + imposter.On(r, "imposter", func(_ context.Context, _ SessionEvent) (struct{}, error) { + return struct{}{}, nil + }) + + _, err := ToolCallHook.Emit(context.Background(), r, ToolCallEvent{}) + if !errors.Is(err, errTypeMismatch) { + t.Fatalf("err = %v, want type mismatch", err) + } +} + +func TestConcurrentEmitWhileRegistering(t *testing.T) { + r := New() + var calls atomic.Int64 + r.SetErrorSink(func(*HandlerError) {}) + + ctx := context.Background() + stop := make(chan struct{}) + var emitters, registrars sync.WaitGroup + + for i := 0; i < 8; i++ { + emitters.Add(1) + go func() { + defer emitters.Done() + for { + select { + case <-stop: + return + default: + } + if _, err := ToolResult.Emit(ctx, r, ToolResultEvent{Content: "x"}); err != nil { + t.Errorf("emit: %v", err) + return + } + _, _ = RunEnd.Emit(ctx, r, RunEndEvent{Stop: StopReasonCompleted}) + } + }() + } + + for i := 0; i < 4; i++ { + registrars.Add(1) + go func() { + defer registrars.Done() + for j := 0; j < 200; j++ { + off := ToolResult.On(r, "racer", func(_ context.Context, ev ToolResultEvent) (ToolResultPatch, error) { + calls.Add(1) + return ToolResultPatch{Content: ptr(ev.Content + "!")}, nil + }) + offEnd := RunEnd.On(r, "racer", func(_ context.Context, _ RunEndEvent) (struct{}, error) { + calls.Add(1) + return struct{}{}, nil + }) + off() + offEnd() + } + }() + } + + registrars.Wait() + close(stop) + emitters.Wait() + + if n := r.Len("tool_result"); n != 0 { + t.Fatalf("leftover handlers: %d", n) + } + if calls.Load() == 0 { + t.Fatal("no handler ever ran concurrently with registration") + } +} diff --git a/agent/hooks/points.go b/agent/hooks/points.go new file mode 100644 index 00000000..9d717423 --- /dev/null +++ b/agent/hooks/points.go @@ -0,0 +1,182 @@ +package hooks + +import ( + "github.com/chainreactors/aiscan/agent/provider" + "github.com/chainreactors/aiscan/core/tool" +) + +// Aliases keep event definitions readable without pulling agent in (that +// would be an import cycle). +type ( + Msg = provider.ChatMessage + ToolCall = provider.ToolCall +) + +// StopReason lives here rather than in agent because run_end events carry +// it; agent aliases these back. +type StopReason string + +const ( + StopReasonCompleted StopReason = "completed" + StopReasonTerminated StopReason = "terminated" + StopReasonStopped StopReason = "stopped" + StopReasonBudget StopReason = "budget" + StopReasonError StopReason = "error" + StopReasonCanceled StopReason = "canceled" +) + +// RunStartEvent carries the config context a handler needs in flattened form, +// since the event type cannot reference *agent.Config. +type RunStartEvent struct { + SessionID string + TurnID string + AgentName string + Model string + Turn int + SystemPrompt string + ToolNames []string +} + +// RunStartResult replaces the system prompt (nil = keep) and prepends messages +// to the turn. +type RunStartResult struct { + SystemPrompt *string + Prepend []Msg +} + +var BeforeRun = Point[RunStartEvent, RunStartResult]{ + Kind: "before_run", + Reduce: Fold(func(acc *RunStartResult, ev *RunStartEvent, out RunStartResult) { + if out.SystemPrompt != nil { + // Fold into the event so the next handler edits the new prompt. + ev.SystemPrompt = *out.SystemPrompt + acc.SystemPrompt = out.SystemPrompt + } + acc.Prepend = append(acc.Prepend, out.Prepend...) + }), +} + +type ContextEvent struct { + SessionID string + Turn int + Messages []Msg +} + +// ContextResult replaces the whole message list; nil means unchanged. +type ContextResult struct { + Messages []Msg +} + +var Context = Point[ContextEvent, ContextResult]{ + Kind: "context", + Reduce: Fold(func(acc *ContextResult, ev *ContextEvent, out ContextResult) { + if out.Messages == nil { + return + } + ev.Messages = out.Messages + acc.Messages = out.Messages + }), +} + +type ToolCallEvent struct { + SessionID string + TurnID string + AssistantMessage Msg + Call ToolCall + SystemPrompt string + Messages []Msg +} + +type ToolCallResult struct { + Block bool + Reason string +} + +// ToolCallHook is fail-closed: a handler that errors out cannot be assumed to +// have approved the call, so the caller must treat any error as a denial. +var ToolCallHook = Point[ToolCallEvent, ToolCallResult]{ + Kind: "tool_call", + OnError: FailClosed, + Reduce: StopWhen[ToolCallEvent](func(r ToolCallResult) bool { return r.Block }), +} + +type ToolResultEvent struct { + SessionID string + TurnID string + Call ToolCall + Content string + IsError bool + Terminate bool + DurationMs int + Full *tool.Result +} + +// ToolResultPatch patches individual fields; nil fields are left alone. +type ToolResultPatch struct { + Content *string + IsError *bool + Terminate *bool +} + +var ToolResult = Point[ToolResultEvent, ToolResultPatch]{ + Kind: "tool_result", + Reduce: Fold(func(acc *ToolResultPatch, ev *ToolResultEvent, out ToolResultPatch) { + // Each patch is mirrored onto the event so later handlers see the + // already-patched result rather than the original. + if out.Content != nil { + ev.Content = *out.Content + acc.Content = out.Content + } + if out.IsError != nil { + ev.IsError = *out.IsError + acc.IsError = out.IsError + } + if out.Terminate != nil { + ev.Terminate = *out.Terminate + acc.Terminate = out.Terminate + } + }), +} + +type RunEndEvent struct { + SessionID string + TurnID string + Stop StopReason + Output string + Messages []Msg + MessageCounter int64 + Usage provider.Usage + Err error +} + +var RunEnd = Point[RunEndEvent, struct{}]{Kind: "run_end"} + +type SessionEvent struct { + SessionID string + ParentID string + AgentName string + Model string + Reason string +} + +var ( + SessionStart = Point[SessionEvent, struct{}]{Kind: "session_start"} + SessionEnd = Point[SessionEvent, struct{}]{Kind: "session_end"} +) + +type CompactEvent struct { + SessionID string + Trigger string + ContextTokens int + ContextWindow int +} + +type CancelResult struct { + Cancel bool + Reason string +} + +var BeforeCompact = Point[CompactEvent, CancelResult]{ + Kind: "before_compact", + Reduce: StopWhen[CompactEvent](func(r CancelResult) bool { return r.Cancel }), +} diff --git a/agent/hooks/reduce.go b/agent/hooks/reduce.go new file mode 100644 index 00000000..2a30110a --- /dev/null +++ b/agent/hooks/reduce.go @@ -0,0 +1,23 @@ +package hooks + +// StopWhen is the veto shape: the first handler whose result satisfies pred wins +// and the rest are skipped. Results that fail pred are discarded. +func StopWhen[E any, R any](pred func(R) bool) Reducer[E, R] { + return func(acc *R, _ *E, out R) bool { + if !pred(out) { + return false + } + *acc = out + return true + } +} + +// Fold is the mutation shape: apply merges each result into both the accumulator +// and the event, so the next handler sees what the previous one changed. It never +// short-circuits — every handler gets a turn. +func Fold[E any, R any](apply func(acc *R, ev *E, out R)) Reducer[E, R] { + return func(acc *R, ev *E, out R) bool { + apply(acc, ev, out) + return false + } +} diff --git a/agent/hooks_emit.go b/agent/hooks_emit.go new file mode 100644 index 00000000..6244beb2 --- /dev/null +++ b/agent/hooks_emit.go @@ -0,0 +1,175 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/chainreactors/aiscan/agent/hooks" +) + +// The kernel reaches the typed hook registry only through these helpers. Each +// helper preserves the zero-handler fast path exposed by hooks.Registry. + +func runStartHook(ctx context.Context, cfg Config, systemPrompt string) (string, []ChatMessage) { + if !cfg.Hooks.Has(hooks.BeforeRun.Kind) { + return systemPrompt, nil + } + result, _ := hooks.BeforeRun.Emit(ctx, cfg.Hooks, hooks.RunStartEvent{ + SessionID: cfg.SessionID, + TurnID: cfg.TurnID, + AgentName: cfg.AgentName, + Model: cfg.Model, + SystemPrompt: systemPrompt, + ToolNames: toolNames(cfg), + }) + if result.SystemPrompt != nil { + systemPrompt = *result.SystemPrompt + } + return systemPrompt, result.Prepend +} + +func toolNames(cfg Config) []string { + if cfg.Tools == nil { + return nil + } + definitions := cfg.Tools.ToolDefinitions() + names := make([]string, 0, len(definitions)) + for _, definition := range definitions { + names = append(names, definition.Function.Name) + } + return names +} + +func transformContextHook(ctx context.Context, cfg Config, messages []ChatMessage, turn int) []ChatMessage { + if !cfg.Hooks.Has(hooks.Context.Kind) { + return messages + } + result, _ := hooks.Context.Emit(ctx, cfg.Hooks, hooks.ContextEvent{ + SessionID: cfg.SessionID, + Turn: turn, + Messages: messages, + }) + if result.Messages != nil { + return result.Messages + } + return messages +} + +// beforeTypedToolCall is fail-closed: a handler error means the call was not +// approved and is returned to the model as a tool error. +func beforeTypedToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall) toolExecution { + if !cfg.Hooks.Has(hooks.ToolCallHook.Kind) { + return toolExecution{} + } + decision, err := hooks.ToolCallHook.Emit(ctx, cfg.Hooks, hooks.ToolCallEvent{ + SessionID: cfg.SessionID, + TurnID: cfg.TurnID, + AssistantMessage: assistantMsg, + Call: tc, + SystemPrompt: cfg.SystemPrompt, + Messages: cfg.Messages, + }) + if err != nil { + return toolExecution{result: fmt.Sprintf("error: %s", err), isError: true, err: err} + } + if !decision.Block { + return toolExecution{} + } + reason := decision.Reason + if reason == "" { + reason = "tool execution was blocked" + } + return toolExecution{result: reason, isError: true} +} + +func afterTypedToolCall(ctx context.Context, cfg Config, tc ToolCall, execution toolExecution, durationMs int) toolExecution { + if !cfg.Hooks.Has(hooks.ToolResult.Kind) { + return execution + } + patch, err := hooks.ToolResult.Emit(ctx, cfg.Hooks, hooks.ToolResultEvent{ + SessionID: cfg.SessionID, + TurnID: cfg.TurnID, + Call: tc, + Content: execution.result, + IsError: execution.isError, + Terminate: execution.flow == ToolFlowTerminate, + DurationMs: durationMs, + Full: execution.fullResult, + }) + if err != nil { + execution.result = fmt.Sprintf("error: %s", err) + execution.isError = true + execution.err = err + return execution + } + if patch.Content != nil { + execution.result = *patch.Content + } + if patch.IsError != nil { + execution.isError = *patch.IsError + if !execution.isError { + execution.err = nil + } + } + if patch.Terminate != nil { + if *patch.Terminate { + execution.flow = ToolFlowTerminate + } else { + execution.flow = ToolFlowContinue + } + } + return execution +} + +func compactCanceled(ctx context.Context, cfg Config, trigger string, contextTokens int) (bool, string) { + if !cfg.Hooks.Has(hooks.BeforeCompact.Kind) { + return false, "" + } + result, _ := hooks.BeforeCompact.Emit(ctx, cfg.Hooks, hooks.CompactEvent{ + SessionID: cfg.SessionID, + Trigger: trigger, + ContextTokens: contextTokens, + ContextWindow: cfg.ContextWindow, + }) + return result.Cancel, result.Reason +} + +func emitRunEnd(ctx context.Context, cfg Config, result *Result) { + if result == nil || !cfg.Hooks.Has(hooks.RunEnd.Kind) { + return + } + _, _ = hooks.RunEnd.Emit(ctx, cfg.Hooks, hooks.RunEndEvent{ + SessionID: cfg.SessionID, + TurnID: cfg.TurnID, + Stop: result.Stop, + Output: result.Output, + Messages: result.Messages, + MessageCounter: result.MessageCounter, + Usage: result.TotalUsage, + Err: result.Err, + }) +} + +func emitSessionStart(ctx context.Context, cfg Config) { + if !cfg.Hooks.Has(hooks.SessionStart.Kind) { + return + } + _, _ = hooks.SessionStart.Emit(ctx, cfg.Hooks, sessionEvent(cfg, "")) +} + +func emitSessionEnd(ctx context.Context, cfg Config, reason string) { + if !cfg.Hooks.Has(hooks.SessionEnd.Kind) { + return + } + _, _ = hooks.SessionEnd.Emit(ctx, cfg.Hooks, sessionEvent(cfg, reason)) +} + +func sessionEvent(cfg Config, reason string) hooks.SessionEvent { + return hooks.SessionEvent{ + SessionID: cfg.SessionID, + ParentID: cfg.ParentSessionID, + AgentName: cfg.AgentName, + Model: cfg.Model, + Reason: reason, + } +} diff --git a/agent/inbox/context.go b/agent/inbox/context.go new file mode 100644 index 00000000..2e74db1a --- /dev/null +++ b/agent/inbox/context.go @@ -0,0 +1,23 @@ +package inbox + +import "context" + +type contextKey struct{} + +// ContextWithInbox scopes asynchronous command notifications to the agent +// session that invoked a tool. +func ContextWithInbox(ctx context.Context, ib Inbox) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, contextKey{}, ib) +} + +// FromContext returns the session inbox attached to ctx, if any. +func FromContext(ctx context.Context) Inbox { + if ctx == nil { + return nil + } + ib, _ := ctx.Value(contextKey{}).(Inbox) + return ib +} diff --git a/pkg/agent/inbox/expand.go b/agent/inbox/expand.go similarity index 97% rename from pkg/agent/inbox/expand.go rename to agent/inbox/expand.go index adaf45da..b2dedfb0 100644 --- a/pkg/agent/inbox/expand.go +++ b/agent/inbox/expand.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) const defaultMaxFileSize = truncate.DefaultMaxBytes diff --git a/pkg/agent/inbox/expand_test.go b/agent/inbox/expand_test.go similarity index 100% rename from pkg/agent/inbox/expand_test.go rename to agent/inbox/expand_test.go diff --git a/pkg/agent/inbox/inbox.go b/agent/inbox/inbox.go similarity index 100% rename from pkg/agent/inbox/inbox.go rename to agent/inbox/inbox.go diff --git a/pkg/agent/inbox/inbox_test.go b/agent/inbox/inbox_test.go similarity index 100% rename from pkg/agent/inbox/inbox_test.go rename to agent/inbox/inbox_test.go diff --git a/pkg/agent/inbox/message.go b/agent/inbox/message.go similarity index 98% rename from pkg/agent/inbox/message.go rename to agent/inbox/message.go index 6350eb25..c4297dba 100644 --- a/pkg/agent/inbox/message.go +++ b/agent/inbox/message.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/pkg/agent/provider" + "github.com/chainreactors/aiscan/agent/provider" ) type Origin string diff --git a/pkg/agent/input.go b/agent/input.go similarity index 98% rename from pkg/agent/input.go rename to agent/input.go index 4ed8c844..d07bfbc9 100644 --- a/pkg/agent/input.go +++ b/agent/input.go @@ -7,7 +7,7 @@ import ( "os" "strings" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" ) // maxInputImageBytes caps a single input image (20 MiB), matching common diff --git a/pkg/agent/input_test.go b/agent/input_test.go similarity index 98% rename from pkg/agent/input_test.go rename to agent/input_test.go index 623b410e..1f60fac5 100644 --- a/pkg/agent/input_test.go +++ b/agent/input_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" ) // pngBytes is a minimal PNG header so http.DetectContentType sniffs image/png. diff --git a/pkg/agent/loop.go b/agent/loop.go similarity index 88% rename from pkg/agent/loop.go rename to agent/loop.go index 744420e9..ee756a6c 100644 --- a/pkg/agent/loop.go +++ b/agent/loop.go @@ -9,14 +9,13 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/aop" + xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/agent/truncate" - "github.com/chainreactors/aiscan/pkg/aop" - xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/truncate" ) func runLoop(ctx context.Context, cfg Config) (*Result, error) { @@ -48,6 +47,7 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { if result.Err != nil && stop == StopReasonError { em.errorEvt(result.Err, isRetryableError(result.Err)) } + emitRunEnd(ctx, cfg, result) if cfg.OnRunEnd != nil { cfg.OnRunEnd(result) } @@ -55,6 +55,10 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { return result, err } + initialPrompt, prepend := runStartHook(ctx, cfg, cfg.SystemPrompt) + cfg.SystemPrompt = initialPrompt + transcript.append(prepend...) + for turn = 1; ; turn++ { if err := ctx.Err(); err != nil { failure := NewTextMessage("assistant", "") @@ -86,15 +90,15 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { if cfg.SystemPromptFn != nil { systemPrompt = cfg.SystemPromptFn(&cfg) } - reqMessages := requestMessages(systemPrompt, transcript.messages, cfg.TransformContext) + reqMessages := requestMessages(ctx, cfg, systemPrompt, transcript.messages, turn) toolDefinitions := cfg.Tools.ToolDefinitions() contextTokens := transcript.estimatedContextTokens(estimateRequestTokens(reqMessages, toolDefinitions)) if shouldCompactContext(contextTokens, cfg.ContextWindow, cfg.Compaction) { - compacted, compactErr := runAutoCompaction(ctx, cfg, em, transcript, "threshold") + compacted, compactErr := runAutoCompaction(ctx, cfg, em, transcript, "threshold", contextTokens) if compactErr != nil { cfg.Logger.Warnf("auto-compaction failed: %s", compactErr) } else if compacted { - reqMessages = requestMessages(systemPrompt, transcript.messages, cfg.TransformContext) + reqMessages = requestMessages(ctx, cfg, systemPrompt, transcript.messages, turn) } } cfg.Logger.Debugf("[turn %d] sending %d messages to LLM", turn, len(reqMessages)) @@ -106,7 +110,7 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { return end(nil, ctx.Err(), StopReasonCanceled) } if isContextOverflowError(err) && !overflowRecoveryAttempted { - compacted, compactErr := runAutoCompaction(ctx, cfg, em, transcript, "overflow") + compacted, compactErr := runAutoCompaction(ctx, cfg, em, transcript, "overflow", transcript.contextTokens) if compactErr != nil { cfg.Logger.Warnf("context overflow recovery failed: %s", compactErr) } else if compacted { @@ -121,7 +125,7 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { assistantMsg = normalizeToolCalls(assistantMsg) if isLengthContextOverflow(assistantMsg.FinishReason, usage, cfg.ContextWindow) { if !overflowRecoveryAttempted { - compacted, compactErr := runAutoCompaction(ctx, cfg, em, transcript, "overflow") + compacted, compactErr := runAutoCompaction(ctx, cfg, em, transcript, "overflow", transcript.contextTokens) if compactErr != nil { cfg.Logger.Warnf("length overflow recovery failed: %s", compactErr) } else if compacted { @@ -266,11 +270,15 @@ func shouldCompactContext(contextTokens, contextWindow int, settings CompactionS return contextTokens > contextWindow-reserve } -func runAutoCompaction(ctx context.Context, cfg Config, em *aopEmitter, transcript *transcript, reason string) (bool, error) { +func runAutoCompaction(ctx context.Context, cfg Config, em *aopEmitter, transcript *transcript, reason string, contextTokens int) (bool, error) { reserve, keepRecent := effectiveCompactionLimits(cfg.ContextWindow, cfg.Compaction) if len(transcript.messages) < 2 || findCutPoint(transcript.messages, keepRecent) <= 0 { return false, nil } + if canceled, hookReason := compactCanceled(ctx, cfg, reason, contextTokens); canceled { + cfg.Logger.Debugf("compaction canceled by hook: %s", hookReason) + return false, nil + } em.status(xcompact.StateStart, "", nil) newMessages, result, err := compactHistory(ctx, CompactConfig{ @@ -501,9 +509,10 @@ type toolExecution struct { } func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall, turn int) toolExecution { + startedAt := time.Now() toolCtx := output.ContextWithCallID(ctx, tc.ID) toolCtx = withToolAgentConfig(toolCtx, cfg) - toolCtx = commands.ContextWithInbox(toolCtx, cfg.Inbox) + toolCtx = inbox.ContextWithInbox(toolCtx, cfg.Inbox) execution := beforeToolCall(toolCtx, cfg, assistantMsg, tc) if execution.result == "" && !execution.isError { toolResult, execErr := cfg.Tools.ExecuteTool(toolCtx, tc.Function.Name, tc.Function.Arguments) @@ -529,7 +538,7 @@ func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc T "\n\n[truncated: showing %d/%d lines (%s of %s). Refine your query or use filter/parse tools to access specific parts.]", tr.OutputLines, tr.TotalLines, truncate.FormatSize(tr.OutputBytes), truncate.FormatSize(tr.TotalBytes)) } - return afterToolCall(toolCtx, cfg, assistantMsg, tc, execution) + return afterToolCall(toolCtx, cfg, assistantMsg, tc, execution, time.Since(startedAt).Milliseconds()) } // eventContent returns the AOP tool.result payload: a plain string, or the @@ -576,67 +585,65 @@ func toolResultToMessage(toolCallID string, exec toolExecution) ChatMessage { } func beforeToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall) toolExecution { - if cfg.BeforeToolCall == nil { - return toolExecution{} - } - before, err := cfg.BeforeToolCall(ctx, BeforeToolCallContext{ - AssistantMessage: assistantMsg, - ToolCall: tc, - SystemPrompt: cfg.SystemPrompt, - Messages: cfg.Messages, - }) - if err != nil { - return toolExecution{result: fmt.Sprintf("error: %s", err.Error()), isError: true, err: err} - } - if before == nil || !before.Block { - return toolExecution{} - } - result := before.Reason - if result == "" { - result = "tool execution was blocked" + if cfg.BeforeToolCall != nil { + before, err := cfg.BeforeToolCall(ctx, BeforeToolCallContext{ + AssistantMessage: assistantMsg, + ToolCall: tc, + SystemPrompt: cfg.SystemPrompt, + Messages: cfg.Messages, + }) + if err != nil { + return toolExecution{result: fmt.Sprintf("error: %s", err.Error()), isError: true, err: err} + } + if before != nil && before.Block { + result := before.Reason + if result == "" { + result = "tool execution was blocked" + } + return toolExecution{result: result, isError: true} + } } - return toolExecution{result: result, isError: true} + return beforeTypedToolCall(ctx, cfg, assistantMsg, tc) } -func afterToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall, execution toolExecution) toolExecution { - if cfg.AfterToolCall == nil { - return execution - } - after, err := cfg.AfterToolCall(ctx, AfterToolCallContext{ - AssistantMessage: assistantMsg, - ToolCall: tc, - Result: execution.result, - IsError: execution.isError, - SystemPrompt: cfg.SystemPrompt, - Messages: cfg.Messages, - }) - if err != nil { - execution.result = fmt.Sprintf("error: %s", err.Error()) - execution.isError = true - execution.err = err - return execution - } - if after == nil { - return execution - } - if after.Result != nil { - execution.result = *after.Result - } - if after.IsError != nil { - execution.isError = *after.IsError - if !execution.isError { - execution.err = nil +func afterToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall, execution toolExecution, durationMs int64) toolExecution { + if cfg.AfterToolCall != nil { + after, err := cfg.AfterToolCall(ctx, AfterToolCallContext{ + AssistantMessage: assistantMsg, + ToolCall: tc, + Result: execution.result, + IsError: execution.isError, + SystemPrompt: cfg.SystemPrompt, + Messages: cfg.Messages, + }) + if err != nil { + execution.result = fmt.Sprintf("error: %s", err.Error()) + execution.isError = true + execution.err = err + return execution + } + if after != nil { + if after.Result != nil { + execution.result = *after.Result + } + if after.IsError != nil { + execution.isError = *after.IsError + if !execution.isError { + execution.err = nil + } + } + execution.flow = after.Flow } } - execution.flow = after.Flow - return execution + return afterTypedToolCall(ctx, cfg, tc, execution, int(durationMs)) } -func requestMessages(systemPrompt string, messages []ChatMessage, transform TransformContextFunc) []ChatMessage { +func requestMessages(ctx context.Context, cfg Config, systemPrompt string, messages []ChatMessage, turn int) []ChatMessage { out := sanitizeMessages(append([]ChatMessage(nil), messages...)) - if transform != nil { - out = transform(out) + if cfg.TransformContext != nil { + out = cfg.TransformContext(out) } + out = transformContextHook(ctx, cfg, out, turn) if systemPrompt != "" { out = append([]ChatMessage{NewTextMessage("system", systemPrompt)}, out...) } diff --git a/agent/loop_context.go b/agent/loop_context.go new file mode 100644 index 00000000..5fdd5fbb --- /dev/null +++ b/agent/loop_context.go @@ -0,0 +1,29 @@ +package agent + +import "context" + +type loopSchedulerContextKey struct{} + +// ContextWithLoopScheduler scopes direct command execution to one runtime +// session. Agent tool calls carry the scheduler in their Config snapshot. +func ContextWithLoopScheduler(ctx context.Context, scheduler *LoopScheduler) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, loopSchedulerContextKey{}, scheduler) +} + +// LoopSchedulerFromContext resolves both direct-command and agent-tool-call +// contexts without exposing the agent's full Config. +func LoopSchedulerFromContext(ctx context.Context) *LoopScheduler { + if ctx == nil { + return nil + } + if scheduler, _ := ctx.Value(loopSchedulerContextKey{}).(*LoopScheduler); scheduler != nil { + return scheduler + } + if cfg, ok := toolAgentConfig(ctx); ok { + return cfg.LoopScheduler + } + return nil +} diff --git a/pkg/agent/loop_scheduler.go b/agent/loop_scheduler.go similarity index 98% rename from pkg/agent/loop_scheduler.go rename to agent/loop_scheduler.go index 03934976..cefead78 100644 --- a/pkg/agent/loop_scheduler.go +++ b/agent/loop_scheduler.go @@ -8,8 +8,8 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/telemetry" ) type LoopMode int diff --git a/pkg/agent/loop_test.go b/agent/loop_test.go similarity index 98% rename from pkg/agent/loop_test.go rename to agent/loop_test.go index 8c6b2ac8..45decb4d 100644 --- a/pkg/agent/loop_test.go +++ b/agent/loop_test.go @@ -8,12 +8,12 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/agent/tmux" - "github.com/chainreactors/aiscan/pkg/agent/truncate" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/agent/tmux" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ) func TestRunEmitsTurnEndAfterToolResults(t *testing.T) { @@ -1034,7 +1034,11 @@ func TestSessionCompletionInjectedIntoAgentLoop(t *testing.T) { t.Fatalf("Create: %v", err) } - time.Sleep(500 * time.Millisecond) + waitCtx, waitCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer waitCancel() + if !ib.Wait(waitCtx) { + t.Fatal("timed out waiting for session completion") + } scripted := &scriptedProvider{ responses: []*ChatCompletionResponse{ @@ -1111,7 +1115,11 @@ func TestSessionCompletionMetadata(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - time.Sleep(500 * time.Millisecond) + waitCtx, waitCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer waitCancel() + if !ib.Wait(waitCtx) { + t.Fatal("timed out waiting for session completion") + } received := ib.Drain() if len(received) == 0 { diff --git a/pkg/agent/overflow.go b/agent/overflow.go similarity index 100% rename from pkg/agent/overflow.go rename to agent/overflow.go diff --git a/pkg/agent/probe/llm.go b/agent/probe/llm.go similarity index 99% rename from pkg/agent/probe/llm.go rename to agent/probe/llm.go index c1d0ffa0..c87cb528 100644 --- a/pkg/agent/probe/llm.go +++ b/agent/probe/llm.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/agent" ) // LLMProbeRequest carries the connection parameters the user wants to verify diff --git a/pkg/agent/provider/anthropic.go b/agent/provider/anthropic.go similarity index 100% rename from pkg/agent/provider/anthropic.go rename to agent/provider/anthropic.go diff --git a/pkg/agent/provider/cache_test.go b/agent/provider/cache_test.go similarity index 100% rename from pkg/agent/provider/cache_test.go rename to agent/provider/cache_test.go diff --git a/pkg/agent/provider/capability_parity_test.go b/agent/provider/capability_parity_test.go similarity index 100% rename from pkg/agent/provider/capability_parity_test.go rename to agent/provider/capability_parity_test.go diff --git a/pkg/agent/provider/endpoint_hint_test.go b/agent/provider/endpoint_hint_test.go similarity index 100% rename from pkg/agent/provider/endpoint_hint_test.go rename to agent/provider/endpoint_hint_test.go diff --git a/pkg/agent/provider/errors.go b/agent/provider/errors.go similarity index 100% rename from pkg/agent/provider/errors.go rename to agent/provider/errors.go diff --git a/pkg/agent/provider/http.go b/agent/provider/http.go similarity index 100% rename from pkg/agent/provider/http.go rename to agent/provider/http.go diff --git a/pkg/agent/provider/openai.go b/agent/provider/openai.go similarity index 100% rename from pkg/agent/provider/openai.go rename to agent/provider/openai.go diff --git a/pkg/agent/provider/provider.go b/agent/provider/provider.go similarity index 100% rename from pkg/agent/provider/provider.go rename to agent/provider/provider.go diff --git a/pkg/agent/provider/provider_test.go b/agent/provider/provider_test.go similarity index 100% rename from pkg/agent/provider/provider_test.go rename to agent/provider/provider_test.go diff --git a/pkg/agent/provider/types.go b/agent/provider/types.go similarity index 100% rename from pkg/agent/provider/types.go rename to agent/provider/types.go diff --git a/pkg/agent/provider_swap_test.go b/agent/provider_swap_test.go similarity index 100% rename from pkg/agent/provider_swap_test.go rename to agent/provider_swap_test.go diff --git a/pkg/agent/retry.go b/agent/retry.go similarity index 98% rename from pkg/agent/retry.go rename to agent/retry.go index d1e2eaf8..bf61c0ca 100644 --- a/pkg/agent/retry.go +++ b/agent/retry.go @@ -12,9 +12,9 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/pkg/agent/provider" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/agent/provider" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/telemetry" ) type imageDisabler interface { diff --git a/pkg/agent/retry_test.go b/agent/retry_test.go similarity index 89% rename from pkg/agent/retry_test.go rename to agent/retry_test.go index d8fa2f27..5ceea4c5 100644 --- a/pkg/agent/retry_test.go +++ b/agent/retry_test.go @@ -9,11 +9,11 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/agent/provider" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/agent/provider" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ) func TestRetryOnTransientError(t *testing.T) { @@ -227,62 +227,6 @@ func TestStreamAssistantMessageReturnsContextErrorOnClosedCanceledStream(t *test } } -func TestProviderFailureDoesNotAutomaticallyFallback(t *testing.T) { - primary := &scriptedProvider{err: &APIError{StatusCode: 401, Message: "invalid api key"}} - fallback := &scriptedProvider{ - responses: []*ChatCompletionResponse{ - chatResponse(NewTextMessage("assistant", "from fallback")), - }, - } - - a := NewAgent(Config{ - Provider: primary, - Model: "primary-model", - Fallbacks: []ProviderEntry{{Provider: fallback, Model: "fallback-model"}}, - MaxRetries: 0, - Logger: telemetry.NopLogger(), - }) - - _, err := a.Run(context.Background(), TextInput("hello")) - if err == nil { - t.Fatal("Run() error = nil, want the primary provider error") - } - if len(fallback.requestsSnapshot()) != 0 { - t.Fatal("fallback provider was called automatically") - } -} - -func TestNoFallbackWhenPrimarySucceeds(t *testing.T) { - primary := &scriptedProvider{ - responses: []*ChatCompletionResponse{ - chatResponse(NewTextMessage("assistant", "from primary")), - }, - } - fallback := &scriptedProvider{ - responses: []*ChatCompletionResponse{ - chatResponse(NewTextMessage("assistant", "from fallback")), - }, - } - - a := NewAgent(Config{ - Provider: primary, - Fallbacks: []ProviderEntry{{Provider: fallback, Model: "fallback-model"}}, - MaxRetries: 0, - Logger: telemetry.NopLogger(), - }) - - result, err := a.Run(context.Background(), TextInput("hello")) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - if result.Output != "from primary" { - t.Fatalf("Output = %q, want 'from primary'", result.Output) - } - if len(fallback.requestsSnapshot()) != 0 { - t.Fatal("fallback provider should not be called when primary succeeds") - } -} - // --- Image error recovery tests --- func TestImageErrorAutoRecovery(t *testing.T) { diff --git a/pkg/agent/session.go b/agent/session.go similarity index 100% rename from pkg/agent/session.go rename to agent/session.go diff --git a/pkg/agent/session_test.go b/agent/session_test.go similarity index 100% rename from pkg/agent/session_test.go rename to agent/session_test.go diff --git a/pkg/agent/subagent.go b/agent/subagent.go similarity index 98% rename from pkg/agent/subagent.go rename to agent/subagent.go index a6feb98d..b336a130 100644 --- a/pkg/agent/subagent.go +++ b/agent/subagent.go @@ -10,11 +10,11 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/aop/x/delegation" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/aop/x/delegation" - "github.com/chainreactors/aiscan/pkg/telemetry" ) type AgentType struct { diff --git a/pkg/agent/subagent_test.go b/agent/subagent_test.go similarity index 97% rename from pkg/agent/subagent_test.go rename to agent/subagent_test.go index c6752e85..6eb134ba 100644 --- a/pkg/agent/subagent_test.go +++ b/agent/subagent_test.go @@ -6,11 +6,11 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/aop/x/delegation" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/aop/x/delegation" "github.com/chainreactors/aiscan/pkg/commands" ) diff --git a/pkg/agent/tmux/manager.go b/agent/tmux/manager.go similarity index 100% rename from pkg/agent/tmux/manager.go rename to agent/tmux/manager.go diff --git a/pkg/agent/tmux/manager_test.go b/agent/tmux/manager_test.go similarity index 100% rename from pkg/agent/tmux/manager_test.go rename to agent/tmux/manager_test.go diff --git a/pkg/agent/tmux/process_alive_unix_test.go b/agent/tmux/process_alive_unix_test.go similarity index 100% rename from pkg/agent/tmux/process_alive_unix_test.go rename to agent/tmux/process_alive_unix_test.go diff --git a/pkg/agent/tmux/process_alive_windows_test.go b/agent/tmux/process_alive_windows_test.go similarity index 100% rename from pkg/agent/tmux/process_alive_windows_test.go rename to agent/tmux/process_alive_windows_test.go diff --git a/pkg/agent/tool_context.go b/agent/tool_context.go similarity index 100% rename from pkg/agent/tool_context.go rename to agent/tool_context.go diff --git a/pkg/agent/types.go b/agent/types.go similarity index 88% rename from pkg/agent/types.go rename to agent/types.go index 87ac0316..957c0164 100644 --- a/pkg/agent/types.go +++ b/agent/types.go @@ -5,13 +5,14 @@ import ( crand "crypto/rand" "encoding/hex" + "github.com/chainreactors/aiscan/agent/hooks" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/agent/provider" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/aop/x/delegation" "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/agent/provider" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/aop/x/delegation" - "github.com/chainreactors/aiscan/pkg/telemetry" ) // Re-export provider types so external consumers only import agent. @@ -64,15 +65,17 @@ var ( // Agent-specific types. -type StopReason string +// StopReason is owned by agent/hooks so lifecycle events can carry it without +// introducing an import cycle back to the root agent package. +type StopReason = hooks.StopReason const ( - StopReasonCompleted StopReason = "completed" - StopReasonTerminated StopReason = "terminated" - StopReasonStopped StopReason = "stopped" - StopReasonBudget StopReason = "budget" - StopReasonError StopReason = "error" - StopReasonCanceled StopReason = "canceled" + StopReasonCompleted = hooks.StopReasonCompleted + StopReasonTerminated = hooks.StopReasonTerminated + StopReasonStopped = hooks.StopReasonStopped + StopReasonBudget = hooks.StopReasonBudget + StopReasonError = hooks.StopReasonError + StopReasonCanceled = hooks.StopReasonCanceled ) type TransformContextFunc func([]ChatMessage) []ChatMessage @@ -126,13 +129,9 @@ type CompactionSettings struct { } type Config struct { - Provider Provider - Tools tool.Executor - Model string - // Fallbacks is retained for source compatibility. It is deliberately - // ignored: provider selection is explicit and a run never switches models. - // Deprecated: configure and select provider profiles explicitly. - Fallbacks []ProviderEntry + Provider Provider + Tools tool.Executor + Model string SystemPrompt string SystemPromptFn SystemPromptFunc Messages []ChatMessage @@ -146,6 +145,9 @@ type Config struct { Logger telemetry.Logger TransformContext TransformContextFunc Bus *eventbus.Bus[aop.Event] + // Hooks is the typed extension registry shared by a runtime and its derived + // agents. Nil means no handlers and keeps the dispatch fast path allocation-free. + Hooks *hooks.Registry // OnRunEnd fires once per run with the final result — replaces the old // EventAgentEnd Messages subscription for session persistence. OnRunEnd func(*Result) @@ -197,6 +199,7 @@ func (c Config) WithCacheRetention(r CacheRetention) Config { c.CacheRetention = func (c Config) WithSessionID(id string) Config { c.SessionID = id; return c } func (c Config) WithTurnID(id string) Config { c.TurnID = id; return c } func (c Config) WithAgentName(name string) Config { c.AgentName = name; return c } +func (c Config) WithHooks(r *hooks.Registry) Config { c.Hooks = r; return c } func (c Config) WithOnRunEnd(fn func(*Result)) Config { c.OnRunEnd = fn; return c } func (c Config) WithLoopScheduler(s *LoopScheduler) Config { c.LoopScheduler = s diff --git a/cmd/agent/capability_test.go b/cmd/agent/capability_test.go new file mode 100644 index 00000000..97cf0968 --- /dev/null +++ b/cmd/agent/capability_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "slices" + "testing" + + "github.com/chainreactors/aiscan/core/capability" + cfg "github.com/chainreactors/aiscan/core/config" +) + +func TestAgentCapabilitySetHasNoScanner(t *testing.T) { + want := []string{"arsenal", "core", "ioa"} + if got := capability.IDsSorted(); !slices.Equal(got, want) { + t.Fatalf("agent capabilities = %#v, want %#v", got, want) + } + for _, descriptor := range capability.All() { + if descriptor.Kind == capability.KindScanner { + t.Fatalf("agent linked scanner capability %q", descriptor.ID) + } + } + if got := cfg.CLICommandSummary(); got != "agent, web, serve" { + t.Fatalf("agent command summary = %q, want %q", got, "agent, web, serve") + } +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go index ef5c5a6d..7a2517d6 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -10,14 +10,13 @@ import ( "time" cfg "github.com/chainreactors/aiscan/core/config" - transportpkg "github.com/chainreactors/aiscan/core/transport" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/pkg/runner" + transportpkg "github.com/chainreactors/aiscan/pkg/transport" goflags "github.com/jessevdk/go-flags" ) func main() { - cfg.ScannerEnabled = false - var option cfg.Option parser := goflags.NewParser(&option, goflags.Default&^goflags.PrintErrors) parser.Usage = `[OPTIONS] @@ -43,7 +42,7 @@ Examples: return } - cfgPath, err := cfg.ResolveRuntimeConfig(&option) + cfgPath, err := runner.ResolveRuntimeConfig(&option) if err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) os.Exit(1) diff --git a/cmd/aiscan/capability_default_test.go b/cmd/aiscan/capability_default_test.go new file mode 100644 index 00000000..3209663e --- /dev/null +++ b/cmd/aiscan/capability_default_test.go @@ -0,0 +1,17 @@ +//go:build !full + +package main + +import ( + "slices" + "testing" + + "github.com/chainreactors/aiscan/core/capability" +) + +func TestDefaultCapabilitySet(t *testing.T) { + want := []string{"arsenal", "core", "gogo", "ioa", "neutron", "proton", "proxy", "scan", "search", "spray", "zombie"} + if got := capability.IDsSorted(); !slices.Equal(got, want) { + t.Fatalf("default capabilities = %#v, want %#v", got, want) + } +} diff --git a/cmd/aiscan/capability_full_test.go b/cmd/aiscan/capability_full_test.go new file mode 100644 index 00000000..025208a7 --- /dev/null +++ b/cmd/aiscan/capability_full_test.go @@ -0,0 +1,17 @@ +//go:build full + +package main + +import ( + "slices" + "testing" + + "github.com/chainreactors/aiscan/core/capability" +) + +func TestFullCapabilitySet(t *testing.T) { + want := []string{"arsenal", "browser", "core", "gogo", "ioa", "katana", "neutron", "passive", "proton", "proxy", "scan", "search", "spray", "zombie"} + if got := capability.IDsSorted(); !slices.Equal(got, want) { + t.Fatalf("full capabilities = %#v, want %#v", got, want) + } +} diff --git a/cmd/aiscan/cli.go b/cmd/aiscan/cli.go index 4a51d43d..82771790 100644 --- a/cmd/aiscan/cli.go +++ b/cmd/aiscan/cli.go @@ -15,9 +15,9 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/runner" - transportpkg "github.com/chainreactors/aiscan/core/transport" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/pkg/runner" + transportpkg "github.com/chainreactors/aiscan/pkg/transport" goflags "github.com/jessevdk/go-flags" ) @@ -137,7 +137,7 @@ func aiscan() { os.Exit(1) } - cfgPath, err := cfg.ResolveRuntimeConfig(&option) + cfgPath, err := runner.ResolveRuntimeConfig(&option) if err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) os.Exit(1) diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go index 631101ea..00c8b653 100644 --- a/cmd/aiscan/cli_test.go +++ b/cmd/aiscan/cli_test.go @@ -7,12 +7,12 @@ import ( "strings" "testing" + "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/runner" - "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/pkg/runner" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" goflags "github.com/jessevdk/go-flags" @@ -145,9 +145,6 @@ func TestParseCLIRootTimeoutAppliesToAgent(t *testing.T) { } func TestDirectScannerModeSuppressesInitInfoByDefault(t *testing.T) { - if raceEnabled { - t.Skip("scanner pipeline has known races under -race; this test checks log output") - } var logBuf bytes.Buffer logger := telemetry.NewLogger(telemetry.LogConfig{Output: &logBuf}) err := runner.RunDirectScannerMode(context.Background(), &cfg.Option{ @@ -165,9 +162,6 @@ func TestDirectScannerModeSuppressesInitInfoByDefault(t *testing.T) { } func TestDirectScannerModeDebugShowsInitInfo(t *testing.T) { - if raceEnabled { - t.Skip("scanner pipeline has known races under -race; this test checks log output") - } var logBuf bytes.Buffer logger := telemetry.NewLogger(telemetry.LogConfig{Debug: true, Output: &logBuf}) err := runner.RunDirectScannerMode(context.Background(), &cfg.Option{ @@ -199,7 +193,7 @@ func TestParseCLIAgentAcceptsLLMFlags(t *testing.T) { if opt.BaseURL != "https://api.deepseek.com" || opt.APIKey != "KEY" || opt.Model != "deepseek-v4-pro" { t.Fatalf("llm options = %#v", opt.LLMOptions) } - pcfg := cfg.ProviderConfig(&opt) + pcfg := runner.ProviderConfig(&opt) if pcfg.Provider != "" { t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider) } @@ -309,7 +303,7 @@ func TestParseCLIScanExtractsLLMFlags(t *testing.T) { if opt.AI || opt.APIKey != "KEY" || opt.Model != "deepseek-v4-pro" || opt.BaseURL != "https://api.deepseek.com" { t.Fatalf("llm options = %#v", opt.LLMOptions) } - pcfg := cfg.ProviderConfig(&opt) + pcfg := runner.ProviderConfig(&opt) if pcfg.Provider != "" { t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider) } @@ -730,7 +724,7 @@ func TestAppConfigUsesCompiledDefaults(t *testing.T) { opt := &cfg.Option{} cfg.ApplyDefaults(opt) - appCfg := cfg.AppConfig(opt, cfg.RuntimeFeatures{ + appCfg := runner.AppConfig(opt, runner.RuntimeFeatures{ ProviderEnabled: true, ProviderOptional: true, AIEnabled: true, diff --git a/cmd/aiscan/race_norace_test.go b/cmd/aiscan/race_norace_test.go deleted file mode 100644 index 94aee521..00000000 --- a/cmd/aiscan/race_norace_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !race - -package main - -const raceEnabled = false diff --git a/cmd/aiscan/race_test.go b/cmd/aiscan/race_test.go deleted file mode 100644 index ad0458fc..00000000 --- a/cmd/aiscan/race_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build race - -package main - -const raceEnabled = true diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 87f16108..9485a8b0 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -7,16 +7,17 @@ import ( "os" "strings" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/capability" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/pidlock" "github.com/chainreactors/aiscan/core/resources" - "github.com/chainreactors/aiscan/core/runner" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" "github.com/chainreactors/aiscan/tools/scan" @@ -37,14 +38,14 @@ func init() { // Scanner engine initialization // --------------------------------------------------------------------------- -func scannerInit(ctx context.Context, a *runner.App, rc cfg.RuntimeConfig, logger telemetry.Logger) { +func scannerInit(ctx context.Context, a *runner.App, rc runner.ApplicationConfig, logger telemetry.Logger) { es := initEngines(ctx, rc.Scanner, logger) a.Engines = es registerScannerCommands(a.Commands, es, rc.Scanner, rc.Tools, a.Provider, a.ProviderConfig, a.Skills, a.DataBus, logger) } -func initEngines(ctx context.Context, sc cfg.ScannerConfig, logger telemetry.Logger) *engine.Set { +func initEngines(ctx context.Context, sc runner.ScannerConfig, logger telemetry.Logger) *engine.Set { engineSet, err := engine.InitWithOptions(ctx, resources.Options{ CyberhubURL: sc.CyberhubURL, APIKey: sc.CyberhubKey, @@ -67,8 +68,8 @@ func initEngines(ctx context.Context, sc cfg.ScannerConfig, logger telemetry.Log return engineSet } -func registerScannerCommands(cmdReg *commands.CommandRegistry, engineSet *engine.Set, scanCfg cfg.ScannerConfig, toolCfg cfg.ToolConfig, llmProvider agent.Provider, providerConfig agent.ProviderConfig, skillStore *skills.Store, dataBus *eventbus.Bus[output.ToolDataEvent], logger telemetry.Logger) { - var scanOpts []any +func registerScannerCommands(cmdReg *commands.CommandRegistry, engineSet *engine.Set, scanCfg runner.ScannerConfig, toolCfg runner.ToolConfig, llmProvider agent.Provider, providerConfig agent.ProviderConfig, skillStore *skills.Store, dataBus *eventbus.Bus[output.ToolDataEvent], logger telemetry.Logger) { + var scanOpts []scan.Option if scanCfg.AIEnabled && llmProvider != nil { scannerParent := agent.NewAgent(agent.Config{ Provider: llmProvider, @@ -99,19 +100,17 @@ func registerScannerCommands(cmdReg *commands.CommandRegistry, engineSet *engine WorkDir: workDir, BashTimeout: toolCfg.BashTimeout, SkillStore: skillStore, - EngineSet: engineSet, ScannerProxy: scanCfg.Proxy, - ScanOpts: scanOpts, Logger: logger, TavilyKeys: toolCfg.TavilyKeys, DataBus: dataBus, } + commands.Provide(deps, scan.OptsKey, scanOpts) if engineSet != nil { - deps.Resources = engineSet.Resources + commands.Provide(deps, engine.SetKey, engineSet) + commands.Provide(deps, resources.SetKey, engineSet.Resources) } - commands.BuildGroup("scanner", deps, cmdReg) - commands.BuildGroup("proxy", deps, cmdReg) - commands.BuildGroup("ioa", deps, cmdReg) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"scanner", "proxy", "ioa"}}), deps, cmdReg) logger.Infof("%s", telemetry.StartupOK("scanner", strings.Join(cmdReg.GroupNames("scanner"), ","))) } diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 4c75da50..bb300a0b 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -17,8 +17,8 @@ import ( "time" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/runner" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/web" "github.com/chainreactors/aiscan/pkg/webproto" webstatic "github.com/chainreactors/aiscan/web" @@ -58,7 +58,7 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom candidateOption = *explicitOption } candidateOption.ConfigFile = prepared.RuntimePath - if _, err := cfg.ResolveRuntimeConfigCandidate(&candidateOption); err != nil { + if _, err := runner.ResolveRuntimeConfigCandidate(&candidateOption); err != nil { return nil, err } candidate, err := initWebApp(ctx, &candidateOption, logger) @@ -194,7 +194,7 @@ func initWebApp(ctx context.Context, baseOption *cfg.Option, logger telemetry.Lo if baseOption != nil { option = *baseOption } - appCfg := cfg.AppConfig(&option, cfg.RuntimeFeatures{ + appCfg := runner.AppConfig(&option, runner.RuntimeFeatures{ ProviderEnabled: true, ProviderOptional: true, ToolsEnabled: true, diff --git a/cmd/aiscan/web_full_test.go b/cmd/aiscan/web_full_test.go index abe52acf..9e114ea0 100644 --- a/cmd/aiscan/web_full_test.go +++ b/cmd/aiscan/web_full_test.go @@ -11,7 +11,7 @@ import ( "testing" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/runner" + "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/web" "github.com/chainreactors/aiscan/pkg/webproto" "gopkg.in/yaml.v3" diff --git a/cmd/runner/main.go b/cmd/runner/main.go index 0a5d4293..defca306 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -9,12 +9,14 @@ import ( "strings" "syscall" + "github.com/chainreactors/aiscan/core/capability" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/resources" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" + apprunner "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/webagent" "github.com/chainreactors/aiscan/tools/scan/engine" ) @@ -43,7 +45,7 @@ func main() { option := &cfg.Option{} option.ConfigFile = configFile - if _, err := cfg.ResolveRuntimeConfig(option); err != nil { + if _, err := apprunner.ResolveRuntimeConfig(option); err != nil { logger.Errorf("load config: %v", err) os.Exit(1) } @@ -90,17 +92,15 @@ func initTools(ctx context.Context, option *cfg.Option, logger telemetry.Logger, deps := &commands.Deps{ WorkDir: workDir, RunnerMode: true, - EngineSet: engineSet, Logger: logger, DataBus: dataBus, ScannerProxy: option.Proxy, } if engineSet != nil { - deps.Resources = engineSet.Resources - } - for _, group := range []string{"core", "scanner", "arsenal"} { - commands.BuildGroup(group, deps, registry) + commands.Provide(deps, engine.SetKey, engineSet) + commands.Provide(deps, resources.SetKey, engineSet.Resources) } + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core", "scanner", "arsenal"}}), deps, registry) registry.SetLogger(logger) return registry, nil } diff --git a/cmd/runner/main_test.go b/cmd/runner/main_test.go index fb6a7609..d942021c 100644 --- a/cmd/runner/main_test.go +++ b/cmd/runner/main_test.go @@ -7,7 +7,7 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) func TestInitToolsRegistersBash(t *testing.T) { diff --git a/pkg/aop/decode.go b/core/aop/decode.go similarity index 100% rename from pkg/aop/decode.go rename to core/aop/decode.go diff --git a/pkg/aop/event.go b/core/aop/event.go similarity index 100% rename from pkg/aop/event.go rename to core/aop/event.go diff --git a/pkg/aop/ext.go b/core/aop/ext.go similarity index 93% rename from pkg/aop/ext.go rename to core/aop/ext.go index 614abdb0..02cae0a2 100644 --- a/pkg/aop/ext.go +++ b/core/aop/ext.go @@ -9,7 +9,7 @@ import ( // // Ext/SetExt are the codec primitives for the extension map. Business code // must not call them directly — use the typed namespace packages under -// pkg/aop/x/ (or pkg/webproto for hub-owned namespaces) instead. +// core/aop/x/ (or pkg/webproto for hub-owned namespaces) instead. func Ext[T any](event Event, namespace string) (T, bool, error) { var value T raw, ok := event.Ext[namespace] diff --git a/pkg/aop/ext_types_gen.go b/core/aop/ext_types_gen.go similarity index 100% rename from pkg/aop/ext_types_gen.go rename to core/aop/ext_types_gen.go diff --git a/pkg/aop/gen_error.go b/core/aop/gen_error.go similarity index 100% rename from pkg/aop/gen_error.go rename to core/aop/gen_error.go diff --git a/pkg/aop/gen_message.go b/core/aop/gen_message.go similarity index 100% rename from pkg/aop/gen_message.go rename to core/aop/gen_message.go diff --git a/pkg/aop/gen_message_delta.go b/core/aop/gen_message_delta.go similarity index 100% rename from pkg/aop/gen_message_delta.go rename to core/aop/gen_message_delta.go diff --git a/pkg/aop/gen_session_start.go b/core/aop/gen_session_start.go similarity index 100% rename from pkg/aop/gen_session_start.go rename to core/aop/gen_session_start.go diff --git a/pkg/aop/gen_status.go b/core/aop/gen_status.go similarity index 100% rename from pkg/aop/gen_status.go rename to core/aop/gen_status.go diff --git a/pkg/aop/gen_tool_call.go b/core/aop/gen_tool_call.go similarity index 100% rename from pkg/aop/gen_tool_call.go rename to core/aop/gen_tool_call.go diff --git a/pkg/aop/gen_tool_result.go b/core/aop/gen_tool_result.go similarity index 100% rename from pkg/aop/gen_tool_result.go rename to core/aop/gen_tool_result.go diff --git a/pkg/aop/gen_turn.go b/core/aop/gen_turn.go similarity index 100% rename from pkg/aop/gen_turn.go rename to core/aop/gen_turn.go diff --git a/pkg/aop/gen_usage_session.go b/core/aop/gen_usage_session.go similarity index 100% rename from pkg/aop/gen_usage_session.go rename to core/aop/gen_usage_session.go diff --git a/pkg/aop/generate.go b/core/aop/generate.go similarity index 100% rename from pkg/aop/generate.go rename to core/aop/generate.go diff --git a/pkg/aop/schema_test.go b/core/aop/schema_test.go similarity index 100% rename from pkg/aop/schema_test.go rename to core/aop/schema_test.go diff --git a/pkg/aop/tool_result.go b/core/aop/tool_result.go similarity index 100% rename from pkg/aop/tool_result.go rename to core/aop/tool_result.go diff --git a/pkg/aop/tool_result_test.go b/core/aop/tool_result_test.go similarity index 100% rename from pkg/aop/tool_result_test.go rename to core/aop/tool_result_test.go diff --git a/pkg/aop/x/command/command.go b/core/aop/x/command/command.go similarity index 87% rename from pkg/aop/x/command/command.go rename to core/aop/x/command/command.go index 123965de..ba5e1365 100644 --- a/pkg/aop/x/command/command.go +++ b/core/aop/x/command/command.go @@ -1,6 +1,6 @@ package command -import "github.com/chainreactors/aiscan/pkg/aop" +import "github.com/chainreactors/aiscan/core/aop" const NS = "command" diff --git a/pkg/aop/x/compact/compact.go b/core/aop/x/compact/compact.go similarity index 86% rename from pkg/aop/x/compact/compact.go rename to core/aop/x/compact/compact.go index aa0987ab..1e77568a 100644 --- a/pkg/aop/x/compact/compact.go +++ b/core/aop/x/compact/compact.go @@ -1,6 +1,6 @@ package compact -import "github.com/chainreactors/aiscan/pkg/aop" +import "github.com/chainreactors/aiscan/core/aop" const ( NS = "compact" diff --git a/pkg/aop/x/compact/generate.go b/core/aop/x/compact/generate.go similarity index 100% rename from pkg/aop/x/compact/generate.go rename to core/aop/x/compact/generate.go diff --git a/pkg/aop/x/compact/types_gen.go b/core/aop/x/compact/types_gen.go similarity index 100% rename from pkg/aop/x/compact/types_gen.go rename to core/aop/x/compact/types_gen.go diff --git a/pkg/aop/x/delegation/delegation.go b/core/aop/x/delegation/delegation.go similarity index 83% rename from pkg/aop/x/delegation/delegation.go rename to core/aop/x/delegation/delegation.go index a4204828..0cd27e1b 100644 --- a/pkg/aop/x/delegation/delegation.go +++ b/core/aop/x/delegation/delegation.go @@ -1,6 +1,6 @@ package delegation -import "github.com/chainreactors/aiscan/pkg/aop" +import "github.com/chainreactors/aiscan/core/aop" const NS = "delegation" diff --git a/pkg/aop/x/delegation/generate.go b/core/aop/x/delegation/generate.go similarity index 100% rename from pkg/aop/x/delegation/generate.go rename to core/aop/x/delegation/generate.go diff --git a/pkg/aop/x/delegation/types_gen.go b/core/aop/x/delegation/types_gen.go similarity index 100% rename from pkg/aop/x/delegation/types_gen.go rename to core/aop/x/delegation/types_gen.go diff --git a/pkg/aop/x/eval/eval.go b/core/aop/x/eval/eval.go similarity index 90% rename from pkg/aop/x/eval/eval.go rename to core/aop/x/eval/eval.go index 237f9cbf..5b9c9188 100644 --- a/pkg/aop/x/eval/eval.go +++ b/core/aop/x/eval/eval.go @@ -1,6 +1,6 @@ package eval -import "github.com/chainreactors/aiscan/pkg/aop" +import "github.com/chainreactors/aiscan/core/aop" const ( NS = "eval" diff --git a/pkg/aop/x/eval/generate.go b/core/aop/x/eval/generate.go similarity index 100% rename from pkg/aop/x/eval/generate.go rename to core/aop/x/eval/generate.go diff --git a/pkg/aop/x/eval/types_gen.go b/core/aop/x/eval/types_gen.go similarity index 100% rename from pkg/aop/x/eval/types_gen.go rename to core/aop/x/eval/types_gen.go diff --git a/pkg/aop/x/ioa/generate.go b/core/aop/x/ioa/generate.go similarity index 100% rename from pkg/aop/x/ioa/generate.go rename to core/aop/x/ioa/generate.go diff --git a/pkg/aop/x/ioa/ioa.go b/core/aop/x/ioa/ioa.go similarity index 82% rename from pkg/aop/x/ioa/ioa.go rename to core/aop/x/ioa/ioa.go index a93a4a1b..2a231fa3 100644 --- a/pkg/aop/x/ioa/ioa.go +++ b/core/aop/x/ioa/ioa.go @@ -1,6 +1,6 @@ package ioa -import "github.com/chainreactors/aiscan/pkg/aop" +import "github.com/chainreactors/aiscan/core/aop" const NS = "ioa" diff --git a/pkg/aop/x/ioa/types_gen.go b/core/aop/x/ioa/types_gen.go similarity index 100% rename from pkg/aop/x/ioa/types_gen.go rename to core/aop/x/ioa/types_gen.go diff --git a/core/capability/capability.go b/core/capability/capability.go new file mode 100644 index 00000000..60c62d25 --- /dev/null +++ b/core/capability/capability.go @@ -0,0 +1,148 @@ +// Package capability is the single answer to "is this feature part of this +// binary". A capability exists if and only if its package is linked, and it is +// linked if and only if a blank import in cmd/*/imports*.go pulls it in — so +// build tags and blank imports stay the edition switch, and everything else +// (CLI help, scanner availability, skill gating, tool-group assembly) is +// derived from the descriptors registered here instead of from parallel global +// tables. +// +// The package deliberately imports nothing from aiscan so that core/config, +// skills, pkg/commands and pkg/tools can all depend on it. +package capability + +import ( + "sort" + "sync" +) + +type ID string + +type Kind uint8 + +const ( + KindTool Kind = iota // agent tool group + KindScanner // CLI-facing scanner command + KindService // ioa / proxy / web +) + +// Descriptor is what a capability package declares about itself in init(). +type Descriptor struct { + ID ID + Kind Kind + // Group is the command-factory group; empty means the ID is the group. + Group string + // CLIName is the top-level command name; empty means not CLI-facing. + CLIName string + // Summary is the word shown in the CLI command summary line. + Summary string + // UsageLine is the pre-aligned row shown in the scanner usage block. + UsageLine string + // Usage renders the command's full help lazily, so registering a + // capability never costs the work of building its usage text. + Usage func() string + // Skills lists skill names this capability unlocks. + Skills []string + // Optional marks a group selectable through --tools. + Optional bool + // Default enables an Optional group when --tools is empty. + Default bool + // Requires names the dependencies the factory needs, for the skip log. + Requires []string +} + +// Conflict records a duplicate registration. First registration wins; the +// duplicate is reported once at startup rather than silently shadowing. +type Conflict struct { + ID ID + Group string +} + +var ( + mu sync.RWMutex + order []ID + byID = map[ID]Descriptor{} + conflicts []Conflict +) + +// Register declares a capability. Called from init(); first registration wins. +func Register(d Descriptor) { + if d.ID == "" { + return + } + if d.Group == "" { + d.Group = string(d.ID) + } + mu.Lock() + defer mu.Unlock() + if _, exists := byID[d.ID]; exists { + conflicts = append(conflicts, Conflict{ID: d.ID, Group: d.Group}) + return + } + byID[d.ID] = d + order = append(order, d.ID) +} + +// All returns the descriptors in registration order. +func All() []Descriptor { + mu.RLock() + defer mu.RUnlock() + out := make([]Descriptor, 0, len(order)) + for _, id := range order { + out = append(out, byID[id]) + } + return out +} + +func Get(id ID) (Descriptor, bool) { + mu.RLock() + defer mu.RUnlock() + d, ok := byID[id] + return d, ok +} + +// Enabled reports whether the capability is linked into this binary. +func Enabled(id ID) bool { + mu.RLock() + defer mu.RUnlock() + _, ok := byID[id] + return ok +} + +func Conflicts() []Conflict { + mu.RLock() + defer mu.RUnlock() + return append([]Conflict(nil), conflicts...) +} + +// Groups lists every distinct factory group, in registration order. +func Groups() []string { + seen := map[string]bool{} + var out []string + for _, d := range All() { + if d.Group == "" || seen[d.Group] { + continue + } + seen[d.Group] = true + out = append(out, d.Group) + } + return out +} + +// IDsSorted is the stable identity of an edition, for golden tests. +func IDsSorted() []string { + ids := make([]string, 0, len(All())) + for _, d := range All() { + ids = append(ids, string(d.ID)) + } + sort.Strings(ids) + return ids +} + +// reset clears the registry. Tests only. +func reset() { + mu.Lock() + defer mu.Unlock() + order = nil + byID = map[ID]Descriptor{} + conflicts = nil +} diff --git a/core/capability/capability_test.go b/core/capability/capability_test.go new file mode 100644 index 00000000..b78c8c89 --- /dev/null +++ b/core/capability/capability_test.go @@ -0,0 +1,114 @@ +package capability + +import ( + "reflect" + "testing" +) + +func TestRegisterFirstWinsAndRecordsConflict(t *testing.T) { + reset() + Register(Descriptor{ID: "gogo", Kind: KindScanner, CLIName: "gogo", Summary: "gogo"}) + Register(Descriptor{ID: "gogo", Kind: KindScanner, CLIName: "shadow"}) + + d, ok := Get("gogo") + if !ok || d.CLIName != "gogo" { + t.Fatalf("first registration should win, got %#v (ok=%v)", d, ok) + } + if got := Conflicts(); len(got) != 1 || got[0].ID != "gogo" { + t.Fatalf("conflicts = %#v, want one entry for gogo", got) + } +} + +func TestGroupDefaultsToID(t *testing.T) { + reset() + Register(Descriptor{ID: "arsenal"}) + d, _ := Get("arsenal") + if d.Group != "arsenal" { + t.Fatalf("group = %q, want arsenal", d.Group) + } +} + +func TestQueriesOnlySeeLinkedCapabilities(t *testing.T) { + reset() + Register(Descriptor{ + ID: "gogo", Kind: KindScanner, Group: "scanner", + CLIName: "gogo", Summary: "gogo", UsageLine: " gogo Run gogo directly", + Usage: func() string { return "gogo help" }, + }) + + if !CLIAvailable("gogo") { + t.Fatal("gogo should be CLI-available") + } + if CLIAvailable("katana") { + t.Fatal("katana is not linked and must not be CLI-available") + } + if got := Summaries(); !reflect.DeepEqual(got, []string{"gogo"}) { + t.Fatalf("summaries = %#v", got) + } + if got := UsageLines(); !reflect.DeepEqual(got, []string{" gogo Run gogo directly"}) { + t.Fatalf("usage lines = %#v", got) + } + if usage, ok := Usage("gogo"); !ok || usage != "gogo help" { + t.Fatalf("usage = %q ok=%v", usage, ok) + } + if _, ok := Usage("katana"); ok { + t.Fatal("unlinked capability must not render usage") + } +} + +func TestSkillGatingFollowsLinkedCapability(t *testing.T) { + reset() + if SkillEnabled("katana") { + t.Fatal("katana skill must stay hidden while the capability is unlinked") + } + if !SkillEnabled("scan") { + t.Fatal("ungated skills are always enabled") + } + Register(Descriptor{ID: "katana", Kind: KindScanner, Skills: []string{"katana"}}) + if !SkillEnabled("katana") { + t.Fatal("katana skill should unlock once the capability is linked") + } +} + +func TestSelectHonoursOptionalAndDefault(t *testing.T) { + reset() + Register(Descriptor{ID: "core"}) + Register(Descriptor{ID: "search", Optional: true, Default: true}) + Register(Descriptor{ID: "browser", Optional: true, Default: true}) + Register(Descriptor{ID: "ioa", Optional: true}) + + plan := Select(Options{}) + for _, id := range []ID{"core", "search", "browser"} { + if !plan.Has(id) { + t.Fatalf("%s should be enabled by default", id) + } + } + if plan.Has("ioa") { + t.Fatal("non-default optional capability must stay off") + } + + plan = Select(Options{OptionalTools: []string{"browser"}}) + if plan.Has("search") { + t.Fatal("explicit --tools must not keep other optional capabilities") + } + if !plan.Has("browser") || !plan.Has("core") { + t.Fatal("explicit --tools must keep the selection and all non-optional capabilities") + } + + plan = Select(Options{Extra: []ID{"ioa"}}) + if !plan.Has("ioa") { + t.Fatal("Extra must force-enable a capability") + } +} + +func TestPlanGroupsFollowRegistrationOrder(t *testing.T) { + reset() + Register(Descriptor{ID: "core", Group: "core"}) + Register(Descriptor{ID: "gogo", Group: "scanner"}) + Register(Descriptor{ID: "spray", Group: "scanner"}) + Register(Descriptor{ID: "arsenal", Group: "arsenal"}) + + if got := Select(Options{}).Groups(); !reflect.DeepEqual(got, []string{"core", "scanner", "arsenal"}) { + t.Fatalf("groups = %#v", got) + } +} diff --git a/core/capability/gated.go b/core/capability/gated.go new file mode 100644 index 00000000..954488e9 --- /dev/null +++ b/core/capability/gated.go @@ -0,0 +1,29 @@ +package capability + +// gatedSkills maps a skill that ships in the embedded skill set to the +// capability that makes it usable. A skill is hidden unless its capability is +// linked — the embedded FS is the same in every edition, so this table (not a +// build tag) is what keeps the standard build from advertising skills whose +// tools it cannot run. +var gatedSkills = map[string]ID{ + "katana": "katana", + "passive": "passive", +} + +// SkillEnabled reports whether a skill should be visible in this binary. +func SkillEnabled(name string) bool { + id, gated := gatedSkills[name] + if !gated { + return true + } + return Enabled(id) +} + +// GatedSkills lists the skill names that depend on a capability being linked. +func GatedSkills() []string { + out := make([]string, 0, len(gatedSkills)) + for name := range gatedSkills { + out = append(out, name) + } + return out +} diff --git a/core/capability/plan.go b/core/capability/plan.go new file mode 100644 index 00000000..7d2c0799 --- /dev/null +++ b/core/capability/plan.go @@ -0,0 +1,78 @@ +package capability + +// A Plan is the set of capabilities one binary should actually assemble. It +// replaces the four divergent BuildGroup lists that each entry point used to +// keep, so "which groups does this binary build" has exactly one answer. + +type Options struct { + // Groups limits the plan to these assembly groups. Nil means every linked + // group. Entry points use this to describe their runtime surface without + // keeping private factory lists. + Groups []string + // OptionalTools is --tools / config tools. Empty selects the defaults. + OptionalTools []string + // Extra force-enables capabilities that become available at runtime, such + // as ioa once a client has connected. + Extra []ID +} + +type Plan struct { + enabled map[ID]bool + groups []string +} + +// Select resolves the registered descriptors against the caller's options. +// A capability that is not Optional is always part of the plan: it is linked, +// so it is meant to be there. +func Select(o Options) Plan { + groups := map[string]bool{} + for _, group := range o.Groups { + groups[group] = true + } + chosen := map[string]bool{} + for _, name := range o.OptionalTools { + chosen[name] = true + } + extra := map[ID]bool{} + for _, id := range o.Extra { + extra[id] = true + } + + p := Plan{enabled: map[ID]bool{}} + seen := map[string]bool{} + for _, d := range All() { + if len(groups) > 0 && !groups[d.Group] { + continue + } + switch { + case extra[d.ID]: + case !d.Optional: + case len(chosen) > 0: + if !chosen[string(d.ID)] && !chosen[d.Group] { + continue + } + case !d.Default: + continue + } + p.enabled[d.ID] = true + if d.Group != "" && !seen[d.Group] { + seen[d.Group] = true + p.groups = append(p.groups, d.Group) + } + } + return p +} + +func (p Plan) Has(id ID) bool { return p.enabled[id] } + +// Groups lists the factory groups to build, in registration order. +func (p Plan) Groups() []string { return append([]string(nil), p.groups...) } + +func (p Plan) HasGroup(group string) bool { + for _, g := range p.groups { + if g == group { + return true + } + } + return false +} diff --git a/core/capability/query.go b/core/capability/query.go new file mode 100644 index 00000000..e73ac837 --- /dev/null +++ b/core/capability/query.go @@ -0,0 +1,59 @@ +package capability + +// The queries here replace the Extra* globals that core/config used to carry: +// CLI availability, the usage block, the command summary and the lazy usage +// text all now come from the descriptors that are actually linked. + +// CLIAvailable reports whether name is a top-level CLI command in this binary. +func CLIAvailable(name string) bool { + _, ok := byCLIName(name) + return ok +} + +// UsageLines returns the pre-aligned usage rows of every linked CLI +// capability, in registration order. +func UsageLines() []string { + var out []string + for _, d := range All() { + if d.CLIName == "" || d.UsageLine == "" { + continue + } + out = append(out, d.UsageLine) + } + return out +} + +// Summaries returns the summary words of every linked CLI capability, in +// registration order. Callers prepend the binary's own modes (agent, web, …). +func Summaries() []string { + var out []string + for _, d := range All() { + if d.CLIName == "" || d.Summary == "" { + continue + } + out = append(out, d.Summary) + } + return out +} + +// Usage renders a CLI capability's help text, or false when the capability is +// not linked or declares no usage. +func Usage(name string) (string, bool) { + d, ok := byCLIName(name) + if !ok || d.Usage == nil { + return "", false + } + return d.Usage(), true +} + +func byCLIName(name string) (Descriptor, bool) { + if name == "" { + return Descriptor{}, false + } + for _, d := range All() { + if d.CLIName == name { + return d, true + } + } + return Descriptor{}, false +} diff --git a/core/config/defaults.go b/core/config/defaults.go new file mode 100644 index 00000000..804f1992 --- /dev/null +++ b/core/config/defaults.go @@ -0,0 +1,23 @@ +package config + +var ( + DefaultProvider = "openai" + DefaultBaseURL = "" + DefaultAPIKey = "" + DefaultModel = "" + + DefaultScannerProxy = "" + + DefaultCyberhubURL = "" + DefaultCyberhubKey = "" + DefaultCyberhubMode = "merge" + + DefaultVerify = "auto" + + DefaultIOAURL = "" + DefaultIOANodeID = "" + DefaultIOANodeName = "" + DefaultSpace = "" + + DefaultTavilyKeys = "" +) diff --git a/core/config/env.go b/core/config/env.go index 0dde5095..9517cab9 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -3,30 +3,20 @@ package config import ( "os" "strings" - - "github.com/chainreactors/aiscan/pkg/agent" ) type envLookup func(string) (string, bool) -func ResolveRuntimeConfig(option *Option) (string, error) { - return resolveRuntimeConfig(option, true) -} - -// ResolveRuntimeConfigCandidate resolves a staged configuration without -// mutating process-wide state. It is used to validate a Web reload candidate -// before the staged file is committed. -func ResolveRuntimeConfigCandidate(option *Option) (string, error) { - return resolveRuntimeConfig(option, false) -} - -func resolveRuntimeConfig(option *Option, applyProcessState bool) (string, error) { +// ResolveRuntimeConfig resolves parsed configuration with environment and +// defaults. Provider inference is supplied by the integration layer so config +// remains independent of concrete LLM implementations. +func ResolveRuntimeConfig(option *Option, applyProcessState bool, inferProvider func(string) string) (string, error) { explicit := *option configPath, err := LoadAndApplyConfig(option) if err != nil { return configPath, err } - applyEnvironment(option, explicit, os.LookupEnv) + applyEnvironment(option, explicit, os.LookupEnv, inferProvider) ApplyDefaults(option) if _, err := ResolveOutputPolicy(option); err != nil { return configPath, err @@ -37,19 +27,19 @@ func resolveRuntimeConfig(option *Option, applyProcessState bool) (string, error return configPath, nil } -func applyEnvironment(option *Option, explicit Option, lookup envLookup) { - applyLLMEnvironment(option, explicit, lookup) +func applyEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { + applyLLMEnvironment(option, explicit, lookup, inferProvider) applyScannerEnvironment(option, explicit, lookup) applyReconEnvironment(option, explicit, lookup) } -func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup) { +func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { providerExplicit := strings.TrimSpace(explicit.Provider) != "" if v := firstEnv(lookup, "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER"); v != "" && !providerExplicit { option.Provider = v } - selectedProvider := selectedEnvProvider(option, lookup) + selectedProvider := selectedEnvProvider(option, lookup, inferProvider) if option.Provider == "" && selectedProvider != "" && !providerExplicit { option.Provider = selectedProvider } @@ -173,12 +163,12 @@ func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) { } } -func selectedEnvProvider(option *Option, lookup envLookup) string { +func selectedEnvProvider(option *Option, lookup envLookup, inferProvider func(string) string) string { if v := strings.ToLower(strings.TrimSpace(option.Provider)); v != "" { return v } - if option.BaseURL != "" { - return agent.InferProviderFromBaseURL(option.BaseURL) + if option.BaseURL != "" && inferProvider != nil { + return inferProvider(option.BaseURL) } if firstEnv(lookup, "ANTHROPIC_API_KEY") != "" { return "anthropic" diff --git a/core/config/loader_test.go b/core/config/loader_test.go index 4c693bfe..b3b4af69 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -5,8 +5,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/chainreactors/aiscan/pkg/telemetry" ) func writeTestConfig(t *testing.T, dir, content string) string { @@ -306,9 +304,8 @@ search: if err := LoadConfig(filepath.Join(dir, "aiscan.yaml"), &option); err != nil { t.Fatal(err) } - cfg := AppConfig(&option, RuntimeFeatures{ToolsEnabled: true}, telemetry.NopLogger()) - if cfg.Tools.TavilyKeys != "K1,K2" { - t.Fatalf("tool config = %#v", cfg.Tools) + if option.SearchConfig.TavilyKeys != "K1,K2" { + t.Fatalf("search config = %#v", option.SearchConfig) } } @@ -323,7 +320,7 @@ scan: if err := LoadConfig(filepath.Join(dir, "aiscan.yaml"), &option); err != nil { t.Fatal(err) } - if got := AppConfig(&option, RuntimeFeatures{}, telemetry.NopLogger()).Scanner.VerifyMode; got != "critical" { + if got := option.ScanConfig.Verify; got != "critical" { t.Errorf("VerifyMode: got %q, want %q", got, "critical") } } @@ -517,7 +514,7 @@ cyberhub: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } @@ -556,7 +553,7 @@ llm: option.Model = "cli-model" option.BaseURL = "https://cli.example/v1" option.APIKey = "cli-key" - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } if option.Model != "cli-model" || option.BaseURL != "https://cli.example/v1" || option.APIKey != "cli-key" { @@ -581,7 +578,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } if option.Provider != "openai" || option.BaseURL != "https://openai-proxy.example/v1" || option.Model != "gpt-env" || option.APIKey != "openai-key" { @@ -606,7 +603,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } if option.Provider != "anthropic" || option.BaseURL != "https://anthropic-proxy.example/v1" || option.Model != "claude-env" || option.APIKey != "anthropic-key" { @@ -638,7 +635,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } if option.Model != "kimi-for-coding" { @@ -655,7 +652,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } if option.Model != "claude-opus-4-8" { @@ -687,7 +684,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } if option.BaseURL != "https://kiro.example/v1" { @@ -708,7 +705,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option); err != nil { + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { t.Fatal(err) } if option.BaseURL != "https://borrowed.example/v1" { @@ -720,84 +717,6 @@ llm: }) } -func TestProvidersListOnly(t *testing.T) { - option := Option{} - option.Providers = []LLMProviderEntry{ - {Provider: "deepseek", APIKey: "key1", Model: "deepseek-chat"}, - {Provider: "openai", APIKey: "key2", Model: "gpt-4o"}, - } - - primary := ProviderConfig(&option) - if primary.Provider != "deepseek" || primary.APIKey != "key1" || primary.Model != "deepseek-chat" { - t.Errorf("primary should be providers[0], got %+v", primary) - } - - fallbacks := FallbackProviderConfigs(&option) - if len(fallbacks) != 1 || fallbacks[0].Provider != "openai" || fallbacks[0].Model != "gpt-4o" { - t.Errorf("fallback should be providers[1:], got %+v", fallbacks) - } -} - -func TestProvidersListWithSingleFields(t *testing.T) { - option := Option{} - option.Provider = "anthropic" - option.APIKey = "cli-key" - option.Providers = []LLMProviderEntry{ - {Provider: "deepseek", APIKey: "fb1", Model: "deepseek-chat"}, - } - - primary := ProviderConfig(&option) - if primary.Provider != "anthropic" || primary.APIKey != "cli-key" { - t.Errorf("single fields should win when set, got %+v", primary) - } - - fallbacks := FallbackProviderConfigs(&option) - if len(fallbacks) != 1 || fallbacks[0].Provider != "deepseek" { - t.Errorf("providers should be fallback when single fields set, got %+v", fallbacks) - } -} - -func TestProvidersListFromConfig(t *testing.T) { - dir := t.TempDir() - writeTestConfig(t, dir, ` -llm: - active_profile: openai - providers: - - id: deepseek - provider: deepseek - api_key: dk-111 - model: deepseek-chat - max_tokens: 8192 - context_window: 128000 - - id: openai - provider: openai - api_key: sk-222 - model: gpt-4o - max_tokens: 32768 - context_window: 1000000 -`) - - var opt Option - if err := LoadConfig(filepath.Join(dir, "aiscan.yaml"), &opt); err != nil { - t.Fatal(err) - } - if len(opt.Providers) != 2 { - t.Fatalf("expected 2 providers, got %d", len(opt.Providers)) - } - - primary := ProviderConfig(&opt) - if primary.Provider != "openai" || primary.APIKey != "sk-222" || - primary.MaxTokens != 32768 || primary.ContextWindow != 1000000 { - t.Errorf("primary from list: %+v", primary) - } - - fallbacks := FallbackProviderConfigs(&opt) - if len(fallbacks) != 1 || fallbacks[0].APIKey != "dk-111" || - fallbacks[0].MaxTokens != 8192 || fallbacks[0].ContextWindow != 128000 { - t.Errorf("fallbacks from list: %+v", fallbacks) - } -} - func TestResolveRuntimeConfigCandidateUsesStagedProfileAndExplicitCLIOverrides(t *testing.T) { for _, key := range []string{ "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER", "AISCAN_MODEL", "AISCAN_LLM_MODEL", @@ -824,25 +743,30 @@ llm: path := filepath.Join(dir, "aiscan.yaml") staged := Option{MiscOptions: MiscOptions{ConfigFile: path}} - if _, err := ResolveRuntimeConfigCandidate(&staged); err != nil { + if _, err := ResolveRuntimeConfig(&staged, false, testProviderInference); err != nil { t.Fatal(err) } - got := ProviderConfig(&staged) - if staged.ActiveProfile != "staged" || got.Provider != "openai" || got.Model != "staged-model" || got.APIKey != "staged-key" { - t.Fatalf("staged profile was not selected: option=%+v provider=%+v", staged.LLMOptions, got) + if staged.ActiveProfile != "staged" || len(staged.Providers) != 2 || staged.Providers[1].Model != "staged-model" { + t.Fatalf("staged profile was not loaded: option=%+v", staged.LLMOptions) } explicit := Option{ MiscOptions: MiscOptions{ConfigFile: path}, LLMOptions: LLMOptions{Provider: "deepseek", Model: "cli-model", APIKey: "cli-key"}, } - if _, err := ResolveRuntimeConfigCandidate(&explicit); err != nil { + if _, err := ResolveRuntimeConfig(&explicit, false, testProviderInference); err != nil { t.Fatal(err) } - got = ProviderConfig(&explicit) - if got.Provider != "deepseek" || got.Model != "cli-model" || got.APIKey != "cli-key" { - t.Fatalf("explicit CLI LLM values did not override staged config: %+v", got) + if explicit.Provider != "deepseek" || explicit.Model != "cli-model" || explicit.APIKey != "cli-key" { + t.Fatalf("explicit CLI LLM values did not override staged config: %+v", explicit.LLMOptions) + } +} + +func testProviderInference(baseURL string) string { + if strings.Contains(strings.ToLower(baseURL), "anthropic") { + return "anthropic" } + return "openai" } func withDefaults(t *testing.T, fn func()) { diff --git a/core/config/scanner.go b/core/config/scanner.go index 46553154..61ec269f 100644 --- a/core/config/scanner.go +++ b/core/config/scanner.go @@ -2,19 +2,9 @@ package config import ( "strings" -) - -var ExtraCommands = map[string]bool{} - -var ExtraUsageEntries []string - -var ExtraSummaryEntries []string -var ExtraScannerUsage = map[string]func() string{} - -// ScannerEnabled reports whether built-in scanner commands are available. -// Defaults to true; cmd/agent sets it to false. -var ScannerEnabled = true + "github.com/chainreactors/aiscan/core/capability" +) type ScannerCommands struct { Scan struct{} `command:"scan" description:"Run the scan pipeline"` @@ -28,47 +18,20 @@ type ScannerCommands struct { } func ScannerCommandAvailable(name string) bool { - if !ScannerEnabled { - return ExtraCommands[name] - } - switch name { - case "scan", "gogo", "spray", "zombie", "neutron": - return true - default: - return ExtraCommands[name] - } + return capability.CLIAvailable(name) } func ScannerUsageLines() string { - if !ScannerEnabled { - if len(ExtraUsageEntries) == 0 { - return "" - } - return strings.Join(ExtraUsageEntries, "\n") - } - base := ` gogo Run gogo directly - spray Run spray directly - zombie Run zombie directly - neutron Run neutron directly` - if len(ExtraUsageEntries) == 0 { - return base - } - return base + "\n" + strings.Join(ExtraUsageEntries, "\n") + return strings.Join(capability.UsageLines(), "\n") } func CLICommandSummary() string { - if !ScannerEnabled { - base := "agent, serve" - if len(ExtraSummaryEntries) == 0 { - return base - } - return base + ", " + strings.Join(ExtraSummaryEntries, ", ") - } - base := "agent, web, serve, scan, gogo, spray, zombie, neutron" - if len(ExtraSummaryEntries) == 0 { + base := "agent, web, serve" + summaries := capability.Summaries() + if len(summaries) == 0 { return base } - return base + ", " + strings.Join(ExtraSummaryEntries, ", ") + return base + ", " + strings.Join(summaries, ", ") } func IsScannerHelpRequest(args []string) bool { @@ -84,12 +47,5 @@ func IsScannerHelpRequest(args []string) bool { } func StaticScannerUsage(name string) (string, bool) { - if !ScannerCommandAvailable(name) { - return "", false - } - fn, ok := ExtraScannerUsage[name] - if !ok || fn == nil { - return "", false - } - return fn(), true + return capability.Usage(name) } diff --git a/core/config/scanner_katana.go b/core/config/scanner_katana.go deleted file mode 100644 index 8870151a..00000000 --- a/core/config/scanner_katana.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build full - -package config - -import katanacmd "github.com/chainreactors/aiscan/tools/katana" - -func init() { - ExtraCommands["katana"] = true - ExtraUsageEntries = append(ExtraUsageEntries, " katana Run katana web crawler") - ExtraSummaryEntries = append(ExtraSummaryEntries, "katana") - ExtraScannerUsage["katana"] = func() string { return katanacmd.New().Usage() } -} diff --git a/core/deps/architecture_test.go b/core/deps/architecture_test.go new file mode 100644 index 00000000..f582257c --- /dev/null +++ b/core/deps/architecture_test.go @@ -0,0 +1,154 @@ +package deps_test + +import ( + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const modulePath = "github.com/chainreactors/aiscan" + +func TestLayerImportsAreUnidirectional(t *testing.T) { + root := repositoryRoot(t) + assertNoFirstPartyImports(t, filepath.Join(root, "core"), map[string]bool{ + "agent": true, + "pkg": true, + "tools": true, + "cmd": true, + }) + assertNoFirstPartyImports(t, filepath.Join(root, "agent"), map[string]bool{ + "pkg": true, + "tools": true, + "cmd": true, + }) +} + +func TestLegacyPackagesCannotReturn(t *testing.T) { + root := repositoryRoot(t) + legacy := []struct { + dir string + importPath string + }{ + {dir: filepath.Join("pkg", "agent"), importPath: modulePath + "/pkg/" + "agent"}, + {dir: filepath.Join("pkg", "aop"), importPath: modulePath + "/pkg/" + "aop"}, + {dir: filepath.Join("pkg", "telemetry"), importPath: modulePath + "/pkg/" + "telemetry"}, + {dir: filepath.Join("pkg", "util"), importPath: modulePath + "/pkg/" + "util"}, + {dir: filepath.Join("core", "runner"), importPath: modulePath + "/core/" + "runner"}, + {dir: filepath.Join("core", "transport"), importPath: modulePath + "/core/" + "transport"}, + } + for _, item := range legacy { + legacyDir := filepath.Join(root, item.dir) + if _, err := os.Stat(legacyDir); err == nil { + t.Errorf("legacy package directory still exists: %s", legacyDir) + } else if !os.IsNotExist(err) { + t.Errorf("stat legacy package directory: %v", err) + } + } + + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if path != root && shouldSkipTree(root, path) { + return filepath.SkipDir + } + return nil + } + if filepath.Ext(path) != ".go" { + return nil + } + imports, parseErr := importsInFile(path) + if parseErr != nil { + return parseErr + } + for _, importPath := range imports { + for _, item := range legacy { + if importPath == item.importPath || strings.HasPrefix(importPath, item.importPath+"/") { + t.Errorf("legacy import %q in %s", importPath, relative(root, path)) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func assertNoFirstPartyImports(t *testing.T, tree string, forbidden map[string]bool) { + t.Helper() + root := repositoryRoot(t) + err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + imports, parseErr := importsInFile(path) + if parseErr != nil { + return parseErr + } + for _, importPath := range imports { + if !strings.HasPrefix(importPath, modulePath+"/") { + continue + } + remainder := strings.TrimPrefix(importPath, modulePath+"/") + layer, _, _ := strings.Cut(remainder, "/") + if forbidden[layer] { + t.Errorf("forbidden dependency %q in %s", importPath, relative(root, path)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func importsInFile(path string) ([]string, error) { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return nil, err + } + imports := make([]string, 0, len(file.Imports)) + for _, spec := range file.Imports { + value, err := strconv.Unquote(spec.Path.Value) + if err != nil { + return nil, err + } + imports = append(imports, value) + } + return imports, nil +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Clean(filepath.Join(wd, "..", "..")) +} + +func shouldSkipTree(root, path string) bool { + rel := filepath.ToSlash(relative(root, path)) + return rel == ".git" || strings.HasPrefix(rel, ".git/") || + rel == "refer" || strings.HasPrefix(rel, "refer/") || + rel == "templates" || strings.HasPrefix(rel, "templates/") || + rel == "web/frontend/cyber-ui" || strings.HasPrefix(rel, "web/frontend/cyber-ui/") +} + +func relative(root, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + return path + } + return filepath.ToSlash(rel) +} diff --git a/core/deps/deps.go b/core/deps/deps.go new file mode 100644 index 00000000..c9b19a20 --- /dev/null +++ b/core/deps/deps.go @@ -0,0 +1,72 @@ +// Package deps carries optional, typed dependencies from the assembly layer to +// the command factories. Keys are declared by the package that owns the value +// type, so the wiring layer (pkg/commands) never has to import — and therefore +// never links — the packages whose values it forwards. +package deps + +import "sync" + +// keyID gives a key its identity. Two keys declared with the same name are +// still distinct, and a key cannot be forged from a string. +type keyID struct{ name string } + +// Key names a dependency of type T. +type Key[T any] struct{ id *keyID } + +func NewKey[T any](name string) Key[T] { return Key[T]{id: &keyID{name: name}} } + +// Name reports the declared name, used when logging a missing dependency. +func Name[T any](k Key[T]) string { + if k.id == nil { + return "" + } + return k.id.name +} + +// Bag maps key identity to value. Accessors are package functions because Go +// methods cannot declare type parameters. +type Bag struct { + mu sync.RWMutex + values map[*keyID]any +} + +func New() *Bag { return &Bag{values: make(map[*keyID]any)} } + +// Set stores v under k. A nil Bag is a no-op so a half-built Deps cannot panic; +// use commands.Provide when the bag may not exist yet. +func Set[T any](b *Bag, k Key[T], v T) { + if b == nil || k.id == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + if b.values == nil { + b.values = make(map[*keyID]any) + } + b.values[k.id] = v +} + +// Get returns the value stored under k. The key identity already guarantees the +// type; the assertion exists only because the backing map is untyped. +func Get[T any](b *Bag, k Key[T]) (T, bool) { + var zero T + if b == nil || k.id == nil { + return zero, false + } + b.mu.RLock() + raw, stored := b.values[k.id] + b.mu.RUnlock() + if !stored { + return zero, false + } + typed, ok := raw.(T) + if !ok { + return zero, false + } + return typed, true +} + +func Has[T any](b *Bag, k Key[T]) bool { + _, ok := Get(b, k) + return ok +} diff --git a/core/deps/quality_test.go b/core/deps/quality_test.go new file mode 100644 index 00000000..76abd8c5 --- /dev/null +++ b/core/deps/quality_test.go @@ -0,0 +1,286 @@ +package deps_test + +import ( + "bytes" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" +) + +type skipAllowance struct { + Path string `json:"path"` + Format string `json:"format"` + Count int `json:"count"` + Category string `json:"category"` + Reason string `json:"reason"` +} + +type skipKey struct { + Path string + Format string +} + +func TestSkipsMatchCentralRegistry(t *testing.T) { + root := repositoryRoot(t) + registryPath := filepath.Join(root, "test-skips.json") + data, err := os.ReadFile(registryPath) + if err != nil { + t.Fatal(err) + } + + var allowances []skipAllowance + if err := json.Unmarshal(data, &allowances); err != nil { + t.Fatalf("parse %s: %v", relative(root, registryPath), err) + } + + allowedCategories := map[string]bool{ + "capability": true, + "external_api": true, + "external_runtime": true, + "live_llm": true, + "platform": true, + } + want := make(map[skipKey]int, len(allowances)) + for _, allowance := range allowances { + key := skipKey{Path: filepath.ToSlash(allowance.Path), Format: allowance.Format} + switch { + case key.Path == "" || key.Format == "": + t.Errorf("skip registry entry must include path and format: %+v", allowance) + case allowance.Count <= 0: + t.Errorf("skip registry entry must have a positive count: %+v", allowance) + case !allowedCategories[allowance.Category]: + t.Errorf("skip registry entry has invalid category %q: %+v", allowance.Category, allowance) + case strings.TrimSpace(allowance.Reason) == "": + t.Errorf("skip registry entry must document its reason: %+v", allowance) + case want[key] != 0: + t.Errorf("duplicate skip registry entry for %s %q", key.Path, key.Format) + default: + want[key] = allowance.Count + } + } + + got, scanErrors := scanSkipCalls(root) + for _, scanErr := range scanErrors { + t.Error(scanErr) + } + + keys := make([]skipKey, 0, len(want)+len(got)) + seen := make(map[skipKey]bool, len(want)+len(got)) + for key := range want { + seen[key] = true + keys = append(keys, key) + } + for key := range got { + if !seen[key] { + keys = append(keys, key) + } + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].Path == keys[j].Path { + return keys[i].Format < keys[j].Format + } + return keys[i].Path < keys[j].Path + }) + for _, key := range keys { + if got[key] != want[key] { + t.Errorf("skip registry mismatch for %s %q: found %d, registered %d", key.Path, key.Format, got[key], want[key]) + } + } +} + +func scanSkipCalls(root string) (map[skipKey]int, []error) { + got := make(map[skipKey]int) + var scanErrors []error + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if path != root && shouldSkipQualityTree(root, path) { + return filepath.SkipDir + } + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".ts" || ext == ".tsx" || ext == ".js" || ext == ".jsx" { + calls, scriptErrors := scriptSkipCalls(root, path) + for key, count := range calls { + got[key] += count + } + scanErrors = append(scanErrors, scriptErrors...) + return nil + } + if ext != ".go" { + return nil + } + + file, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if parseErr != nil { + return parseErr + } + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || (selector.Sel.Name != "Skip" && selector.Sel.Name != "Skipf" && selector.Sel.Name != "SkipNow") { + return true + } + receiver, ok := selector.X.(*ast.Ident) + if !ok || (receiver.Name != "t" && receiver.Name != "b") { + return true + } + if len(call.Args) == 0 { + scanErrors = append(scanErrors, fmt.Errorf("unregistered reasonless skip in %s", relative(root, path))) + return true + } + literal, ok := call.Args[0].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + scanErrors = append(scanErrors, fmt.Errorf("skip reason must be a string literal in %s", relative(root, path))) + return true + } + format, unquoteErr := strconv.Unquote(literal.Value) + if unquoteErr != nil { + scanErrors = append(scanErrors, fmt.Errorf("parse skip reason in %s: %w", relative(root, path), unquoteErr)) + return true + } + got[skipKey{Path: relative(root, path), Format: format}]++ + return true + }) + return nil + }) + if err != nil { + scanErrors = append(scanErrors, err) + } + return got, scanErrors +} + +var ( + scriptSkipStart = regexp.MustCompile(`\b(?:test|it|describe)\.skip\s*\(`) + scriptSkipReason = regexp.MustCompile("'[^']*'|\"[^\"]*\"|`[^`]*`") +) + +func scriptSkipCalls(root, path string) (map[skipKey]int, []error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, []error{err} + } + rel := relative(root, path) + got := make(map[skipKey]int) + var scanErrors []error + for lineNumber, line := range strings.Split(string(data), "\n") { + starts := scriptSkipStart.FindAllStringIndex(line, -1) + for i, start := range starts { + end := len(line) + if i+1 < len(starts) { + end = starts[i+1][0] + } + literals := scriptSkipReason.FindAllString(line[start[0]:end], -1) + if len(literals) == 0 { + scanErrors = append(scanErrors, fmt.Errorf("skip reason must be a string literal in %s:%d", rel, lineNumber+1)) + continue + } + literal := literals[len(literals)-1] + reason := literal[1 : len(literal)-1] + got[skipKey{Path: rel, Format: reason}]++ + } + } + return got, scanErrors +} + +func TestRepositoryDebtMarkersCannotReturn(t *testing.T) { + root := repositoryRoot(t) + markers := [][]byte{[]byte("TO" + "DO"), []byte("FIX" + "ME")} + var failures []string + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if path != root && shouldSkipQualityTree(root, path) { + return filepath.SkipDir + } + return nil + } + + rel := relative(root, path) + if isBackupFile(entry.Name()) { + failures = append(failures, rel+": backup/editor artifact") + return nil + } + if !isDebtScannable(path) { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for _, marker := range markers { + if bytes.Contains(data, marker) { + failures = append(failures, fmt.Sprintf("%s: contains forbidden debt marker %q", rel, marker)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(failures) + for _, failure := range failures { + t.Error(failure) + } +} + +func shouldSkipQualityTree(root, path string) bool { + if shouldSkipTree(root, path) { + return true + } + rel := filepath.ToSlash(relative(root, path)) + if rel == "web/static" || strings.HasPrefix(rel, "web/static/") { + return true + } + parts := strings.Split(rel, "/") + for _, part := range parts { + switch part { + case "node_modules", "dist", "coverage", "playwright-report", "test-results", ".cache": + return true + } + } + return false +} + +func isBackupFile(name string) bool { + lower := strings.ToLower(name) + return strings.HasSuffix(lower, "~") || + strings.HasSuffix(lower, ".bak") || + strings.HasSuffix(lower, ".backup") || + strings.HasSuffix(lower, ".orig") || + strings.HasSuffix(lower, ".rej") || + strings.HasSuffix(lower, ".swp") || + strings.HasSuffix(lower, ".swo") || + strings.HasPrefix(lower, ".#") +} + +func isDebtScannable(path string) bool { + base := filepath.Base(path) + if base == "Makefile" || base == "Dockerfile" || base == ".gitattributes" || base == ".gitmodules" { + return true + } + switch strings.ToLower(filepath.Ext(path)) { + case ".css", ".go", ".html", ".js", ".json", ".jsx", ".md", ".mod", ".ps1", ".scss", ".sh", ".sum", ".toml", ".ts", ".tsx", ".yaml", ".yml": + return true + default: + return false + } +} diff --git a/core/harness/expect.go b/core/harness/expect.go deleted file mode 100644 index 4000695f..00000000 --- a/core/harness/expect.go +++ /dev/null @@ -1,204 +0,0 @@ -//go:build e2e - -package harness - -import ( - "encoding/json" - "fmt" - "strings" -) - -// ToolPattern describes an expected tool call. Built with the Tool() function -// and refined with chainable methods. -// -// Tool("bash").ArgContains("gogo").NoError() -// Tool("subagent").Action("create").Arg("name", "worker").Arg("mode", "async") -type ToolPattern struct { - tool string - action string - argChecks []argCheck - resultHas []string - resultNot []string - noError bool - isError bool - label string -} - -type argCheck struct { - key string - contains string -} - -func Tool(name string) ToolPattern { - return ToolPattern{tool: name, label: name} -} - -func (p ToolPattern) Action(action string) ToolPattern { - p.action = action - p.label = fmt.Sprintf("%s/%s", p.tool, action) - return p -} - -func (p ToolPattern) Arg(key, contains string) ToolPattern { - p.argChecks = append(p.argChecks, argCheck{key: key, contains: contains}) - return p -} - -func (p ToolPattern) ArgContains(substr string) ToolPattern { - p.argChecks = append(p.argChecks, argCheck{contains: substr}) - return p -} - -func (p ToolPattern) ResultHas(substr string) ToolPattern { - p.resultHas = append(p.resultHas, substr) - return p -} - -func (p ToolPattern) ResultNot(substr string) ToolPattern { - p.resultNot = append(p.resultNot, substr) - return p -} - -func (p ToolPattern) NoError() ToolPattern { - p.noError = true - return p -} - -func (p ToolPattern) IsError() ToolPattern { - p.isError = true - return p -} - -func (p ToolPattern) Label() string { return p.label } - -func (p ToolPattern) Match(e ToolExecution) bool { - if e.Name() != p.tool { - return false - } - if p.action != "" && !argsContainAction(e.Args(), p.action) { - return false - } - for _, ac := range p.argChecks { - if ac.key != "" { - if !argsFieldContains(e.Args(), ac.key, ac.contains) { - return false - } - } else { - if !strings.Contains(argsText(e.Args()), ac.contains) { - return false - } - } - } - for _, s := range p.resultHas { - if !strings.Contains(e.ResultText(), s) { - return false - } - } - for _, s := range p.resultNot { - if strings.Contains(e.ResultText(), s) { - return false - } - } - if p.noError && e.IsError() { - return false - } - if p.isError && !e.IsError() { - return false - } - return true -} - -func (p ToolPattern) describe() string { - var parts []string - parts = append(parts, p.tool) - if p.action != "" { - parts = append(parts, fmt.Sprintf("action=%s", p.action)) - } - for _, ac := range p.argChecks { - if ac.key != "" { - parts = append(parts, fmt.Sprintf("arg[%s]~%q", ac.key, ac.contains)) - } else { - parts = append(parts, fmt.Sprintf("args~%q", ac.contains)) - } - } - for _, s := range p.resultHas { - parts = append(parts, fmt.Sprintf("result~%q", s)) - } - return strings.Join(parts, " ") -} - -func argsContainAction(args any, action string) bool { - values, ok := args.(map[string]any) - if !ok { - return false - } - value, _ := values["action"].(string) - return value == action -} - -func argsFieldContains(args any, key, contains string) bool { - values, ok := args.(map[string]any) - if !ok { - return false - } - encoded, _ := json.Marshal(values[key]) - return strings.Contains(string(encoded), contains) -} - -// matchResult holds the result of matching expectations against actual tool calls. -type matchResult struct { - matched []matchPair - unmatched []ToolPattern -} - -type matchPair struct { - pattern ToolPattern - event ToolExecution - index int -} - -// matchUnordered finds a matching event for each pattern (greedy, unordered). -func matchUnordered(patterns []ToolPattern, events []ToolExecution) matchResult { - used := make([]bool, len(events)) - var matched []matchPair - var unmatched []ToolPattern - - for _, p := range patterns { - found := false - for i, e := range events { - if used[i] { - continue - } - if p.Match(e) { - matched = append(matched, matchPair{pattern: p, event: e, index: i}) - used[i] = true - found = true - break - } - } - if !found { - unmatched = append(unmatched, p) - } - } - return matchResult{matched: matched, unmatched: unmatched} -} - -// matchOrdered finds matching events in order (subsequence match). -func matchOrdered(patterns []ToolPattern, events []ToolExecution) matchResult { - var matched []matchPair - pi := 0 - for i, e := range events { - if pi >= len(patterns) { - break - } - if patterns[pi].Match(e) { - matched = append(matched, matchPair{pattern: patterns[pi], event: e, index: i}) - pi++ - } - } - var unmatched []ToolPattern - for _, p := range patterns[pi:] { - unmatched = append(unmatched, p) - } - return matchResult{matched: matched, unmatched: unmatched} -} diff --git a/core/harness/features_full_test.go b/core/harness/features_full_test.go deleted file mode 100644 index 932cd3cb..00000000 --- a/core/harness/features_full_test.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build e2e && full - -package harness - -func buildTags() string { return "emptytemplates noembed full" } - -func scannerHelpCommands() []string { - return []string{"gogo", "spray", "katana", "zombie", "neutron", "passive", "scan"} -} diff --git a/core/harness/features_test.go b/core/harness/features_test.go deleted file mode 100644 index 78cc3b81..00000000 --- a/core/harness/features_test.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build e2e && !full - -package harness - -func buildTags() string { return "emptytemplates noembed" } - -func scannerHelpCommands() []string { - return []string{"gogo", "spray", "zombie", "neutron", "scan"} -} diff --git a/core/harness/harness.go b/core/harness/harness.go deleted file mode 100644 index 03899bb4..00000000 --- a/core/harness/harness.go +++ /dev/null @@ -1,331 +0,0 @@ -//go:build e2e - -package harness - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/webproto" -) - -var ( - cachedExe string - cachedExeOnce sync.Once - cachedExeErr error -) - -type Harness struct { - t *testing.T - exe string - workDir string - baseURL string - apiKey string - model string - timeout time.Duration - monitor *Monitor -} - -func (h *Harness) WithMonitor(out ...io.Writer) *Harness { - w := io.Writer(os.Stderr) - if len(out) > 0 { - w = out[0] - } - h.monitor = NewMonitor(w) - return h -} - -func New(t *testing.T) *Harness { - t.Helper() - - baseURL := os.Getenv("AISCAN_TEST_BASE_URL") - apiKey := os.Getenv("AISCAN_TEST_API_KEY") - model := os.Getenv("AISCAN_TEST_MODEL") - - if apiKey == "" { - t.Skip("AISCAN_TEST_API_KEY not set, skipping e2e test") - } - if baseURL == "" { - baseURL = "https://api.deepseek.com" - } - if model == "" { - model = "deepseek-v4-pro" - } - - cachedExeOnce.Do(func() { - cachedExe, cachedExeErr = buildOnce(t) - }) - if cachedExeErr != nil { - t.Fatalf("build aiscan: %v", cachedExeErr) - } - - h := &Harness{ - t: t, - exe: cachedExe, - workDir: t.TempDir(), - baseURL: baseURL, - apiKey: apiKey, - model: model, - timeout: 180 * time.Second, - } - if os.Getenv("AISCAN_MONITOR") != "" { - h.monitor = NewMonitor(os.Stderr) - } - return h -} - -func buildOnce(t *testing.T) (string, error) { - t.Helper() - dir, err := os.MkdirTemp("", "aiscan-e2e-*") - if err != nil { - return "", err - } - exeName := "aiscan-e2e" - if runtime.GOOS == "windows" { - exeName += ".exe" - } - exe := filepath.Join(dir, exeName) - args := []string{"build", "-tags", buildTags(), "-o", exe, "./cmd/aiscan"} - cmd := exec.Command("go", args...) - cmd.Dir = repoRoot(t) - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("%v\n%s", err, out) - } - return exe, nil -} - -func (h *Harness) llmArgs() []string { - return []string{ - "--base-url", h.baseURL, - "--api-key", h.apiKey, - "--model", h.model, - } -} - -func (h *Harness) Run(args ...string) *RunResult { - h.t.Helper() - return h.RunWithTimeout(h.timeout, args...) -} - -func (h *Harness) RunWithTimeout(timeout time.Duration, args ...string) *RunResult { - h.t.Helper() - - var fullArgs []string - switch { - case len(args) > 0 && args[0] == "agent": - fullArgs = h.agentCLIArgs(args[1:]...) - case len(args) == 1 && args[0] == "--version": - fullArgs = []string{"--no-color", "--quiet", "--version"} - default: - fullArgs = append(h.llmArgs(), "--no-color", "--quiet") - fullArgs = append(fullArgs, args...) - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, h.exe, fullArgs...) - cmd.Dir = h.workDir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - start := time.Now() - err := cmd.Run() - duration := time.Since(start) - - exitCode := processExitCode(err) - - result := &RunResult{ - Stdout: stdout.String(), - Stderr: stderr.String(), - ExitCode: exitCode, - Duration: duration, - } - - h.t.Logf("ran: aiscan %s (exit=%d, duration=%s, turns=%d, tools=%d)", - strings.Join(args, " "), exitCode, duration.Round(time.Millisecond), - result.Turns(), len(result.ToolCalls())) - if exitCode != 0 { - h.t.Logf("stderr: %s", clip(stderr.String(), 2000)) - } - - return result -} - -func (h *Harness) WorkFile(name string) string { - return filepath.Join(h.workDir, name) -} - -// --- convenience runners --- - -func (h *Harness) Agent(prompt string, extraArgs ...string) *RunResult { - h.t.Helper() - return h.AgentWithTimeout(h.timeout, prompt, extraArgs...) -} - -func (h *Harness) AgentWithTimeout(timeout time.Duration, prompt string, extraArgs ...string) *RunResult { - h.t.Helper() - - fullArgs := h.agentCLIArgs(extraArgs...) - fullArgs = append(fullArgs, "--transport", "stdio") - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, h.exe, fullArgs...) - cmd.Dir = h.workDir - stdin, err := cmd.StdinPipe() - if err != nil { - h.t.Fatalf("agent stdin pipe: %v", err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - h.t.Fatalf("agent stdout pipe: %v", err) - } - var stderr bytes.Buffer - cmd.Stderr = &stderr - - start := time.Now() - if err := cmd.Start(); err != nil { - h.t.Fatalf("start aiscan agent: %v", err) - } - - encoder := json.NewEncoder(stdin) - openErr := encoder.Encode(webproto.Message{ - Type: webproto.TypeSessionOpen, - Payload: webproto.MustJSON(webproto.SessionOpenPayload{SessionID: "harness"}), - }) - writeErr := openErr - if writeErr == nil { - writeErr = encoder.Encode(webproto.Message{ - Type: webproto.TypeRun, TurnID: "turn-1", - Payload: webproto.MustJSON(webproto.RunPayload{ - SessionID: "harness", - Parts: []aop.MessagePart{{Type: aop.PartText, Text: prompt}}, - }), - }) - } - closeErr := stdin.Close() - if writeErr != nil || closeErr != nil { - cancel() - } - - output, events, streamErr := consumeAgentStream(stdout, h.monitor) - if streamErr != nil { - cancel() - } - waitErr := cmd.Wait() - duration := time.Since(start) - - exitCode := processExitCode(waitErr) - if writeErr != nil { - exitCode = -1 - fmt.Fprintf(&stderr, "write stdio request: %v\n", writeErr) - } else if closeErr != nil { - exitCode = -1 - fmt.Fprintf(&stderr, "close stdio request: %v\n", closeErr) - } - if streamErr != nil { - exitCode = -1 - fmt.Fprintf(&stderr, "read AOP stdout: %v\n", streamErr) - } - - result := &RunResult{ - Stdout: output, - Stderr: stderr.String(), - ExitCode: exitCode, - Duration: duration, - Events: events, - } - - h.t.Logf("ran: aiscan agent (exit=%d, duration=%s, turns=%d, tools=%d)", - exitCode, duration.Round(time.Millisecond), result.Turns(), len(result.ToolCalls())) - if exitCode != 0 { - h.t.Logf("stderr: %s", clip(stderr.String(), 2000)) - } - - return result -} - -func (h *Harness) AgentWithInput(prompt string, inputs []string, extraArgs ...string) *RunResult { - h.t.Helper() - args := make([]string, 0, len(inputs)*2+len(extraArgs)) - for _, input := range inputs { - args = append(args, "-i", input) - } - args = append(args, extraArgs...) - task := fmt.Sprintf("%s\n\nTargets:\n%s", prompt, config.FormatInputs(inputs)) - return h.Agent(task, args...) -} - -func (h *Harness) agentCLIArgs(extraArgs ...string) []string { - args := []string{"--no-color", "--quiet", "agent"} - args = append(args, h.llmArgs()...) - return append(args, extraArgs...) -} - -func (h *Harness) Scanner(name string, scannerArgs ...string) *RunResult { - h.t.Helper() - args := []string{name} - args = append(args, scannerArgs...) - return h.Run(args...) -} - -func (h *Harness) ScannerAI(name string, scannerArgs ...string) *RunResult { - h.t.Helper() - args := []string{"--ai", name} - args = append(args, scannerArgs...) - return h.Run(args...) -} - -// --- helpers --- - -func repoRoot(t *testing.T) string { - t.Helper() - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - return filepath.Clean(filepath.Join(wd, "..", "..")) -} - -func envOrDefault(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func processExitCode(err error) int { - if err == nil { - return 0 - } - if exitErr, ok := err.(*exec.ExitError); ok { - return exitErr.ExitCode() - } - return -1 -} - -func clip(s string, maxLen int) string { - s = strings.TrimSpace(s) - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "... (truncated)" -} diff --git a/core/harness/harness_test.go b/core/harness/harness_test.go deleted file mode 100644 index 04e1afdc..00000000 --- a/core/harness/harness_test.go +++ /dev/null @@ -1,1528 +0,0 @@ -//go:build e2e - -package harness - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http/httptest" - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/webproto" - ioaclient "github.com/chainreactors/ioa/client" - "github.com/chainreactors/ioa/protocols" - ioaserver "github.com/chainreactors/ioa/server" -) - -// ===================================================================== -// init -// ===================================================================== - -func init() { - if _, err := exec.LookPath("go"); err != nil { - panic("go compiler not found; e2e tests require Go toolchain") - } -} - -// ===================================================================== -// Agent — basic prompts and tool use -// ===================================================================== - -func TestAgentSimplePrompt(t *testing.T) { - h := New(t) - Intent{ - Name: "simple-prompt", - Prompt: "What is 2+2? Reply with just the number.", - OutputContains: []string{"4"}, - MaxTurns: 2, - JudgeCriteria: "The agent must reply with the number 4. No tool calls needed. The answer must be mathematically correct.", - }.Run(t, h) -} - -// TestAgentDualSessionInterleaved drives the stdio host with two concurrent -// sessions: messages for sess-a and sess-b are written interleaved and both -// sessions must run to completion independently. -func TestAgentDualSessionInterleaved(t *testing.T) { - h := New(t) - - fullArgs := h.agentCLIArgs() - fullArgs = append(fullArgs, "--transport", "stdio") - - ctx, cancel := context.WithTimeout(context.Background(), h.timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, h.exe, fullArgs...) - cmd.Dir = h.workDir - stdin, err := cmd.StdinPipe() - if err != nil { - t.Fatalf("stdin pipe: %v", err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - t.Fatalf("stdout pipe: %v", err) - } - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { - t.Fatalf("start agent: %v", err) - } - - encoder := json.NewEncoder(stdin) - writeFrame := func(message webproto.Message) { - t.Helper() - if err := encoder.Encode(message); err != nil { - t.Fatalf("write %s: %v", message.Type, err) - } - } - writeRun := func(sessionID, turnID, text string) { - writeFrame(webproto.Message{ - Type: webproto.TypeRun, TurnID: turnID, - Payload: webproto.MustJSON(webproto.RunPayload{ - SessionID: sessionID, - Parts: []aop.MessagePart{{Type: aop.PartText, Text: text}}, - }), - }) - } - writeFrame(webproto.Message{Type: webproto.TypeSessionOpen, Payload: webproto.MustJSON(webproto.SessionOpenPayload{SessionID: "sess-a"})}) - writeFrame(webproto.Message{Type: webproto.TypeSessionOpen, Payload: webproto.MustJSON(webproto.SessionOpenPayload{SessionID: "sess-b"})}) - writeRun("sess-a", "turn-a1", "Reply with exactly: ALPHA") - writeRun("sess-b", "turn-b1", "Reply with exactly: BRAVO") - writeRun("sess-a", "turn-a2", "Reply with exactly: ALPHA2") - if err := stdin.Close(); err != nil { - t.Fatalf("close stdin: %v", err) - } - - type sessionState struct { - ended int - wantEnd int - outputs []string - } - sessions := map[string]*sessionState{"sess-a": {wantEnd: 2}, "sess-b": {wantEnd: 1}} - decoder := json.NewDecoder(stdout) - for { - var message webproto.Message - if err := decoder.Decode(&message); err != nil { - break - } - if message.Type != webproto.TypeAOP { - continue - } - var event aop.Event - if err := json.Unmarshal(message.Payload, &event); err != nil { - t.Fatalf("decode AOP payload: %v", err) - } - state, ok := sessions[event.SessionID] - if !ok { - continue - } - switch event.Type { - case aop.TypeMessage: - data, err := aop.DecodeData[aop.MessageData](event) - if err == nil && data.Role == "assistant" { - state.outputs = append(state.outputs, messageText(data)) - } - case aop.TypeTurnEnd: - state.ended++ - } - } - _ = cmd.Wait() - - for id, state := range sessions { - if state.ended != state.wantEnd { - t.Errorf("%s completed %d/%d runs (stderr: %s)", id, state.ended, state.wantEnd, clip(stderr.String(), 500)) - } - } - if got := strings.Join(sessions["sess-a"].outputs, "\n"); !strings.Contains(got, "ALPHA") { - t.Errorf("sess-a outputs = %q, want ALPHA", got) - } - if got := strings.Join(sessions["sess-b"].outputs, "\n"); !strings.Contains(got, "BRAVO") { - t.Errorf("sess-b outputs = %q, want BRAVO", got) - } -} - -func TestAgentEmptyReply(t *testing.T) { - h := New(t) - r := h.Agent("Reply with the word 'pong' and nothing else.") - Verify(t, r).OK().Done() - if !strings.Contains(strings.ToLower(r.Output()), "pong") { - t.Fatalf("expected 'pong', got: %s", r.Output()) - } -} - -func TestAgentBashTool(t *testing.T) { - h := New(t) - Intent{ - Name: "bash-echo", - Prompt: "Run 'echo hello_e2e' in a shell and tell me the exact output.", - Steps: Steps( - Tool("bash").ArgContains("echo hello_e2e").ResultHas("hello_e2e").NoError(), - ), - OutputContains: []string{"hello_e2e"}, - NoErrors: true, - MaxTurns: 3, - JudgeCriteria: "The agent must: (1) call the bash tool with a command containing 'echo hello_e2e', " + - "(2) the bash result must contain 'hello_e2e', " + - "(3) the final output must report 'hello_e2e' as the result.", - }.Run(t, h) -} - -func TestAgentReadTool(t *testing.T) { - h := New(t) - Intent{ - Name: "read-file", - Prompt: "Read /etc/hostname and reply with only its contents.", - Steps: Steps( - Tool("read").ArgContains("hostname").NoError(), - ), - NoErrors: true, - MaxTurns: 3, - JudgeCriteria: "The agent must use the read tool to read /etc/hostname, and the final output must contain the hostname value " + - "(not just say 'I read it' — the actual content must appear).", - }.Run(t, h) -} - -func TestAgentWriteReadRoundtrip(t *testing.T) { - h := New(t) - Intent{ - Name: "write-read-roundtrip", - Prompt: "Write 'e2e_marker_42' to /tmp/aiscan_e2e_test.txt, then read it back and confirm.", - Steps: Steps( - Tool("write").ArgContains("e2e_marker_42").NoError(), - Tool("read").ArgContains("aiscan_e2e_test").NoError(), - ), - Ordered: true, - OutputContains: []string{"e2e_marker_42"}, - NoErrors: true, - MaxTurns: 5, - JudgeCriteria: "The agent must: (1) write the exact string 'e2e_marker_42' to a file, " + - "(2) read it back and confirm the content matches. Both steps must succeed without errors.", - }.Run(t, h) -} - -func TestAgentGlobAndRead(t *testing.T) { - h := New(t) - Intent{ - Name: "glob-and-read", - Prompt: "List .go files in /mnt/chainreactors/aiscan/pkg/agent/ using glob, then read the first line of defaults.go and tell me the package name.", - Steps: Steps( - Tool("glob").NoError(), - Tool("read").ArgContains("defaults.go").NoError(), - ), - Ordered: true, - OutputContains: []string{"agent"}, - NoErrors: true, - MaxTurns: 4, - JudgeCriteria: "The agent must: (1) use glob to list .go files in the agent directory, " + - "(2) read defaults.go, (3) correctly report that the package name is 'agent'.", - }.Run(t, h) -} - -func TestAgentMultiStepTask(t *testing.T) { - h := New(t) - Intent{ - Name: "multi-step-bash", - Prompt: "First run 'uname -a' in bash. After you see the result, run 'whoami' in a SEPARATE bash call. Report both results.", - Steps: Steps( - Tool("bash").ArgContains("uname").NoError(), - Tool("bash").ArgContains("whoami").NoError(), - ), - Ordered: true, - NoErrors: true, - MaxTurns: 6, - JudgeCriteria: "The agent must make TWO separate bash calls: one for 'uname -a' and one for 'whoami'. " + - "Both results must appear in the final output. They must NOT be combined in a single bash call.", - }.Run(t, h) -} - -func TestAgentMultiTurn(t *testing.T) { - h := New(t) - Intent{ - Name: "multi-turn-file-ops", - Prompt: "Step 1: Create file /tmp/aiscan_multi.txt with content 'step1'. Step 2: Append ' step2' to it. Step 3: Read it and confirm it says 'step1 step2'.", - NoErrors: true, - MaxTurns: 8, - JudgeCriteria: "The agent must perform three sequential file operations: " + - "(1) create a file with 'step1', (2) append ' step2' to it, (3) read and confirm the content is 'step1 step2'. " + - "The final output must confirm the combined content.", - }.Run(t, h) -} - -func TestAgentLargeOutput(t *testing.T) { - h := New(t) - Intent{ - Name: "large-output", - Prompt: "Run 'seq 1 500' in bash. Tell me the last number printed.", - Steps: Steps( - Tool("bash").ArgContains("seq").NoError(), - ), - OutputContains: []string{"500"}, - NoErrors: true, - MaxTurns: 8, - JudgeCriteria: "The agent must run 'seq 1 500' and correctly identify that the last number is 500.", - }.Run(t, h) -} - -func TestAgentErrorRecovery(t *testing.T) { - h := New(t) - Intent{ - Name: "error-recovery", - Prompt: "Run 'cat /nonexistent/file' in bash. If it fails, report the error message. Then run 'echo recovered' and report that output.", - Steps: Steps( - Tool("bash").ArgContains("nonexistent"), - Tool("bash").ArgContains("recovered").NoError(), - ), - Ordered: true, - OutputContains: []string{"recovered"}, - MaxTurns: 5, - JudgeCriteria: "The agent must: (1) attempt to cat a nonexistent file, (2) recognize the error, " + - "(3) recover by running 'echo recovered', (4) report both the error and the recovery in the final output.", - }.Run(t, h) -} - -// ===================================================================== -// CLI — scanner help, version, direct modes -// ===================================================================== - -func TestScannerHelpExitsClean(t *testing.T) { - h := New(t) - for _, name := range scannerHelpCommands() { - t.Run(name, func(t *testing.T) { - r := h.Scanner(name, "-h") - Verify(t, r). - OK(). - OutputContains("Usage:"). - Done() - }) - } -} - -func TestVersionFlag(t *testing.T) { - h := New(t) - r := h.Run("--version") - Verify(t, r). - OK(). - OutputContains("aiscan v"). - Done() -} - -func TestScannerDirectGogo(t *testing.T) { - h := New(t) - r := h.Scanner("gogo", "-i", "127.0.0.1", "-p", "80") - if r.ExitCode != 0 { - t.Logf("gogo exit=%d stderr: %s", r.ExitCode, clip(r.Stderr, 500)) - } -} - -func TestScannerDirectSpray(t *testing.T) { - h := New(t) - r := h.Scanner("spray", "-i", "http://127.0.0.1:1", "--limit", "1") - if r.ExitCode != 0 { - t.Logf("spray exit=%d stderr: %s", r.ExitCode, clip(r.Stderr, 500)) - } -} - -func TestAgentTimeout(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(15*time.Second, - "agent", "-p", "Run 'sleep 60' in bash.", - "--timeout", "5", - ) - if r.ExitCode == 0 && r.Duration < 4*time.Second { - t.Logf("agent completed before timeout — skipping assertion") - return - } - if r.Duration < 4*time.Second { - t.Fatalf("expected ≥4s duration, got %s", r.Duration) - } -} - -// ===================================================================== -// IOA loop — task dispatch, multi-worker, peer messages -// ===================================================================== - -func TestIOALoopReceivesTask(t *testing.T) { - service := ioaserver.NewService(ioaserver.NewMemoryStore(), "") - srv := httptest.NewServer(ioaserver.NewHandler(service)) - defer srv.Close() - - h := New(t) - - go func() { - h.RunWithTimeout(60*time.Second, - "agent", "--ioa-url", "http://127.0.0.1:8765", - "--ioa-url", srv.URL, - "--space", "test-loop", - "-p", "I am a test worker", - "--timeout", "45", - ) - }() - - time.Sleep(3 * time.Second) - - controller, err := ioaclient.NewClient(srv.URL, "") - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil { - t.Fatal(err) - } - space, err := controller.Space(ctx, "test-loop", "e2e test") - if err != nil { - t.Fatal(err) - } - - nodes, err := controller.ListNodes(ctx) - if err != nil { - t.Fatal(err) - } - if len(nodes) == 0 { - t.Fatal("no worker nodes registered in space") - } - workerNodeID := nodes[0].ID - - _, err = controller.Send(ctx, space.ID, protocols.SendMessage{ - Content: map[string]any{"content": "Run 'echo ioa_task_received' in bash and report the output."}, - Refs: &protocols.Ref{Nodes: []string{workerNodeID}}, - }) - if err != nil { - t.Fatal(err) - } - - time.Sleep(30 * time.Second) - - requireIOAMessageContains(t, controller, ctx, space.ID, "ioa_task_received") -} - -func TestIOALoopMultipleWorkers(t *testing.T) { - service := ioaserver.NewService(ioaserver.NewMemoryStore(), "") - srv := httptest.NewServer(ioaserver.NewHandler(service)) - defer srv.Close() - - h := New(t) - - for i := 1; i <= 2; i++ { - i := i - go func() { - h.RunWithTimeout(45*time.Second, - "agent", "--ioa-url", "http://127.0.0.1:8765", - "--ioa-url", srv.URL, - "--space", "multi-worker", - "--ioa-node-name", fmt.Sprintf("worker-%d", i), - "-p", fmt.Sprintf("I am worker %d", i), - "--timeout", "40", - ) - }() - } - - time.Sleep(4 * time.Second) - - controller, err := ioaclient.NewClient(srv.URL, "") - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil { - t.Fatal(err) - } - if _, err := controller.Space(ctx, "multi-worker", "e2e multi"); err != nil { - t.Fatal(err) - } - - nodes, err := controller.ListNodes(ctx) - if err != nil { - t.Fatal(err) - } - workerCount := 0 - for _, n := range nodes { - if strings.HasPrefix(n.Name, "worker-") { - workerCount++ - } - } - if workerCount < 2 { - t.Fatalf("expected ≥2 worker nodes, got %d (total nodes: %d)", workerCount, len(nodes)) - } -} - -func TestIOALoopPeerMessage(t *testing.T) { - service := ioaserver.NewService(ioaserver.NewMemoryStore(), "") - srv := httptest.NewServer(ioaserver.NewHandler(service)) - defer srv.Close() - - h := New(t) - - go func() { - h.RunWithTimeout(45*time.Second, - "agent", "--ioa-url", "http://127.0.0.1:8765", - "--ioa-url", srv.URL, - "--space", "peer-test", - "-p", "test worker", - "--timeout", "40", - ) - }() - - time.Sleep(3 * time.Second) - - controller, err := ioaclient.NewClient(srv.URL, "") - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil { - t.Fatal(err) - } - space, err := controller.Space(ctx, "peer-test", "e2e peer") - if err != nil { - t.Fatal(err) - } - - nodes, err := controller.ListNodes(ctx) - if err != nil { - t.Fatal(err) - } - if len(nodes) == 0 { - t.Fatal("no worker nodes") - } - workerNodeID := nodes[0].ID - - _, err = controller.Send(ctx, space.ID, protocols.SendMessage{ - Content: map[string]any{"content": "Run echo peer_hello and report result"}, - Refs: &protocols.Ref{Nodes: []string{workerNodeID}}, - }) - if err != nil { - t.Fatal(err) - } - - _, err = controller.Send(ctx, space.ID, protocols.SendMessage{ - Content: map[string]any{"content": "Additional context: also run 'echo peer_context_received'"}, - }) - if err != nil { - t.Fatal(err) - } - - time.Sleep(25 * time.Second) - - requireIOAMessageContains(t, controller, ctx, space.ID, "peer_hello") -} - -func TestIOATaskSpawnsSubagents(t *testing.T) { - service := ioaserver.NewService(ioaserver.NewMemoryStore(), "") - srv := httptest.NewServer(ioaserver.NewHandler(service)) - defer srv.Close() - - h := New(t) - - go func() { - h.RunWithTimeout(90*time.Second, - "agent", "--ioa-url", "http://127.0.0.1:8765", - "--ioa-url", srv.URL, - "--space", "subagent-fan", - "-p", "I am a worker that parallelizes tasks using subagents", - "--timeout", "80", - ) - }() - - time.Sleep(4 * time.Second) - - controller, err := ioaclient.NewClient(srv.URL, "") - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil { - t.Fatal(err) - } - space, err := controller.Space(ctx, "subagent-fan", "e2e") - if err != nil { - t.Fatal(err) - } - - nodes, err := controller.ListNodes(ctx) - if err != nil { - t.Fatal(err) - } - var workerNodeID string - for _, n := range nodes { - if n.Name != "controller" { - workerNodeID = n.ID - break - } - } - if workerNodeID == "" { - t.Fatal("no worker node found") - } - - _, err = controller.Send(ctx, space.ID, protocols.SendMessage{ - Content: map[string]any{ - "content": "I need you to gather system info in parallel. " + - "Create 2 async subagents: one runs 'echo subagent_alpha_ok' in bash, " + - "the other runs 'echo subagent_beta_ok' in bash. " + - "Wait for both results, then respond with a combined summary that includes both markers.", - }, - Refs: &protocols.Ref{Nodes: []string{workerNodeID}}, - }) - if err != nil { - t.Fatal(err) - } - - time.Sleep(60 * time.Second) - - requireIOAMessageContains(t, controller, ctx, space.ID, "subagent_alpha_ok") - requireIOAMessageContains(t, controller, ctx, space.ID, "subagent_beta_ok") -} - -func TestIOATwoWorkersDispatch(t *testing.T) { - service := ioaserver.NewService(ioaserver.NewMemoryStore(), "") - srv := httptest.NewServer(ioaserver.NewHandler(service)) - defer srv.Close() - - h := New(t) - - for i := 1; i <= 2; i++ { - i := i - go func() { - h.RunWithTimeout(75*time.Second, - "agent", "--ioa-url", "http://127.0.0.1:8765", - "--ioa-url", srv.URL, - "--space", "dispatch-2", - "--ioa-node-name", fmt.Sprintf("worker-%d", i), - "-p", fmt.Sprintf("I am worker %d", i), - "--timeout", "70", - ) - }() - } - - time.Sleep(5 * time.Second) - - controller, err := ioaclient.NewClient(srv.URL, "") - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil { - t.Fatal(err) - } - space, err := controller.Space(ctx, "dispatch-2", "e2e dispatch") - if err != nil { - t.Fatal(err) - } - - nodes, err := controller.ListNodes(ctx) - if err != nil { - t.Fatal(err) - } - var workers []protocols.Node - for _, n := range nodes { - if strings.HasPrefix(n.Name, "worker-") { - workers = append(workers, n) - } - } - if len(workers) < 2 { - t.Fatalf("expected ≥2 workers, got %d", len(workers)) - } - - for i, w := range workers { - marker := fmt.Sprintf("dispatch_marker_%d", i+1) - _, err = controller.Send(ctx, space.ID, protocols.SendMessage{ - Content: map[string]any{ - "content": fmt.Sprintf("Run 'echo %s' in bash and report.", marker), - }, - Refs: &protocols.Ref{Nodes: []string{w.ID}}, - }) - if err != nil { - t.Fatal(err) - } - } - - time.Sleep(45 * time.Second) - - requireIOAMessageContains(t, controller, ctx, space.ID, "dispatch_marker_1") - requireIOAMessageContains(t, controller, ctx, space.ID, "dispatch_marker_2") -} - -// ===================================================================== -// Loop tool — create, lifecycle -// ===================================================================== - -func TestAgentLoopCreate(t *testing.T) { - h := New(t) - Intent{ - Name: "loop-create", - Prompt: "Use bash to run these loop commands in order: " + - "(1) loop '*/10 * * * *' check system health " + - "(2) loop list " + - "(3) loop stop the loop that was just created. " + - "Report the results and stop.", - Steps: Steps( - Tool("bash").ArgContains("loop").NoError(), - Tool("bash").ArgContains("loop").ArgContains("list").NoError(), - Tool("bash").ArgContains("loop").ArgContains("stop").NoError(), - ), - Ordered: true, - NoErrors: true, - MaxTurns: 6, - Timeout: 60 * time.Second, - JudgeCriteria: "The agent must: (1) create a loop via cron expression, " + - "(2) list loops, (3) stop the loop. All calls must succeed.", - }.Run(t, h) -} - -func TestAgentLoopLifecycle(t *testing.T) { - h := New(t) - Intent{ - Name: "loop-lifecycle", - Prompt: "Use bash to run these loop commands in order: " + - "(1) loop 5m check status " + - "(2) loop list to confirm the loop exists " + - "(3) loop stop to stop it " + - "(4) loop list again to confirm it is gone. " + - "Report the results after each step and stop.", - Steps: Steps( - Tool("bash").ArgContains("loop").NoError(), - Tool("bash").ArgContains("loop list").NoError(), - Tool("bash").ArgContains("loop stop").NoError(), - Tool("bash").ArgContains("loop list").NoError(), - ), - Ordered: true, - NoErrors: true, - MaxTurns: 8, - Timeout: 90 * time.Second, - JudgeCriteria: "The agent must: (1) create a loop, (2) list loops showing it exists, " + - "(3) stop the loop, (4) list loops again confirming it is gone. " + - "All four commands must succeed without errors.", - }.Run(t, h) -} - -// ===================================================================== -// Pipeline / scan — scanner AI, scan with skills -// ===================================================================== - -func TestScannerAIGogo(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(90*time.Second, "--ai", "--timeout", "60", "gogo", "-i", "127.0.0.1", "-p", "80") - Verify(t, r).OK().Done() -} - -func TestAgentGogoScan(t *testing.T) { - h := New(t) - r := h.Agent("Use gogo to scan 127.0.0.1 port 80. Show the raw scanner output.") - Verify(t, r). - OK(). - ToolUsed("bash"). - Done() -} - -func TestAgentSprayScan(t *testing.T) { - h := New(t) - r := h.Agent("Run spray against http://127.0.0.1:1 with --limit 1 and report the result.") - Verify(t, r). - OK(). - ToolUsed("bash"). - Done() -} - -func TestAgentScanWithSkill(t *testing.T) { - h := New(t) - r := h.Agent("Use the scan command to scan 127.0.0.1 with --mode quick. Summarize the results.", "-s", "aiscan") - Verify(t, r).OK().Done() -} - -func TestAgentScanAnalyze(t *testing.T) { - h := New(t) - r := h.Agent("Run 'scan -i 127.0.0.1 --mode quick' and analyze the output. Tell me what services were found, if any.") - Verify(t, r). - OK(). - ToolUsed("bash"). - Done() -} - -func TestAgentScanAndVerify(t *testing.T) { - h := New(t) - r := h.Agent( - "Scan 127.0.0.1 with scan --mode quick. If any services are found, " + - "attempt to verify them by connecting to the reported port using bash (e.g. curl or nc). " + - "Report: services found, verification results.", - ) - Verify(t, r). - OK(). - ToolUsed("bash"). - Done() -} - -func TestAgentScanAnalyzeVerifyPipeline(t *testing.T) { - h := New(t) - r := h.Agent( - "Execute this pipeline:\n" + - "1. Run 'scan -i 127.0.0.1 --mode quick' to scan the target.\n" + - "2. Parse the scan results to identify any open ports or services.\n" + - "3. For each service found, attempt a basic verification:\n" + - " - If HTTP: run 'curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:' \n" + - " - If SSH: run 'echo | nc -w2 127.0.0.1 ' \n" + - " - If no services found, report that.\n" + - "4. Summarize: services found, verification status for each.", - ) - Verify(t, r). - OK(). - ToolUsed("bash"). - ToolArgMatch("bash", func(args string) bool { - return strings.Contains(args, "scan") && strings.Contains(args, "127.0.0.1") - }). - ToolResultMatch("bash", func(res string) bool { return res != "" }). - Done() -} - -func TestAgentParallelTargetScan(t *testing.T) { - h := New(t) - r := h.Agent( - "I need to check 3 targets in parallel. Create 3 async subagents:\n" + - "1. Named 'target-a': run 'echo target_a_scanned' in bash and report.\n" + - "2. Named 'target-b': run 'echo target_b_scanned' in bash and report.\n" + - "3. Named 'target-c': run 'echo target_c_scanned' in bash and report.\n" + - "Wait for ALL subagents to complete. List the subagents to track progress. " + - "Once all are done, produce a consolidated report with all 3 markers.", - ) - Verify(t, r). - OK(). - MinSubagentCreates(3). - OutputContains("target_a_scanned"). - OutputContains("target_b_scanned"). - OutputContains("target_c_scanned"). - Done() -} - -func TestAgentBackgroundTaskDrivesFollowUp(t *testing.T) { - h := New(t) - r := h.Agent( - "Start a detached tmux session: tmux new -d -s scan 'sleep 1 && echo SCAN_COMPLETE port=22 service=ssh'. " + - "Use tmux ls to confirm it's running. " + - "Use tmux wait -t scan to wait for it. Use tmux capture-pane -t scan to get output. " + - "Then run a follow-up command 'echo VERIFY_22_OK' to simulate verification. " + - "Report both the scan result and the verification result.", - ) - Verify(t, r). - OK(). - ToolUsed("bash"). - MinToolCalls(3). - AnyResultContains("SCAN_COMPLETE"). - AnyResultContains("VERIFY_22_OK"). - Done() -} - -func TestAgentTmuxAndSubagentCoordination(t *testing.T) { - h := New(t) - r := h.Agent( - "Do these in parallel:\n" + - "1. Start a detached tmux session: tmux new -d -s bg 'sleep 1 && echo bg_task_done_xyz'\n" + - "2. Create an async subagent named 'helper' with prompt: " + - "'Run echo subagent_helper_done in bash and report.'\n" + - "Monitor both: use tmux wait/capture-pane and wait for the subagent completion notification. " + - "Report both results when they complete.", - ) - Verify(t, r). - OK(). - ToolUsed("bash"). - ToolUsed("subagent"). - AnyResultContains("bg_task_done_xyz"). - AnyResultContains("subagent_helper_done"). - Done() -} - -// ===================================================================== -// Real scan — direct, AI, agent, subagent, loop, IOA -// ===================================================================== - -func sendMessage(content, nodeID string) protocols.SendMessage { - return protocols.SendMessage{ - Content: map[string]any{"content": content}, - Refs: &protocols.Ref{Nodes: []string{nodeID}}, - } -} - -const realTarget = "101.132.149.35/28" -const realSingleTarget = "101.132.149.35" - -// Layer 1: Direct scanner (no AI) — baseline - -func TestRealScanDirectGogo(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(120*time.Second, "gogo", "-i", realTarget, "-p", "top100") - Verify(t, r).OK().Done() - t.Logf("gogo output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000)) -} - -func TestRealScanDirectSpray(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(120*time.Second, "spray", "-i", fmt.Sprintf("http://%s", realSingleTarget), "--finger") - Verify(t, r).OK().Done() - t.Logf("spray output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000)) -} - -func TestRealScanDirectPipeline(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(300*time.Second, "scan", "-i", realSingleTarget, "--mode", "quick") - Verify(t, r).OK().Done() - t.Logf("scan output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000)) -} - -// Layer 2: Scanner AI analysis and scan pipeline AI skills - -func TestRealScanGogoAI(t *testing.T) { - h := New(t) - Intent{ - Name: "real-gogo-ai", - Prompt: "", // not used in scanner AI mode - Timeout: 180 * time.Second, - JudgeCriteria: "The scanner must have executed gogo against the target and the AI must have provided " + - "a meaningful analysis of discovered services. The analysis should mention specific ports, " + - "services, or results - not just a generic summary.", - }.verifyScanner(t, h, "--ai", "--timeout", "120", "gogo", "-i", realTarget, "-p", "top100") -} - -func TestRealScanPipelineAISkills(t *testing.T) { - h := New(t) - Intent{ - Name: "real-scan-pipeline-ai-skills", - Prompt: "", - Timeout: 300 * time.Second, - JudgeCriteria: "The scan pipeline must have run against the target with explicit AI verification " + - "and sniper options. The output should include concrete scan findings or AI skill results, " + - "not just a generic completion message.", - }.verifyScanner(t, h, "--timeout", "240", "scan", "-i", realSingleTarget, "--mode", "quick", "--verify=high", "--sniper") -} - -// Layer 3: Agent mode — LLM decides how to scan - -func TestRealAgentGogoScan(t *testing.T) { - h := New(t) - Intent{ - Name: "real-agent-gogo", - Prompt: fmt.Sprintf("Use gogo to scan %s with port range top100. Report all discovered services including port, protocol, and any fingerprints.", realTarget), - Steps: Steps( - Tool("bash").ArgContains("gogo").NoError(), - ), - Timeout: 300 * time.Second, - MaxTurns: 20, - JudgeCriteria: "The agent must have executed gogo against 101.132.149.35/28 with appropriate port arguments. " + - "The final output must list specific discovered services (port numbers, service names). " + - "Generic statements like 'scan completed' without specific results are a failure.", - }.Run(t, h) -} - -func TestRealAgentSprayScan(t *testing.T) { - h := New(t) - Intent{ - Name: "real-agent-spray", - Prompt: fmt.Sprintf("Use spray to probe http://%s and identify web technologies and fingerprints. Report what you find.", realSingleTarget), - Steps: Steps( - Tool("bash").ArgContains("spray").NoError(), - ), - Timeout: 300 * time.Second, - MaxTurns: 20, - JudgeCriteria: "The agent must run spray against the target URL. The output must include specific web " + - "technology fingerprints or HTTP response information — not just 'spray completed'.", - }.Run(t, h) -} - -func TestRealAgentFullPipeline(t *testing.T) { - h := New(t) - Intent{ - Name: "real-agent-full-pipeline", - Prompt: fmt.Sprintf("Perform a comprehensive scan of %s:\n"+ - "1. Use gogo to discover open ports and services\n"+ - "2. For any HTTP services found, use spray to fingerprint them\n"+ - "3. Summarize all results: IPs, ports, services, web technologies", realSingleTarget), - Steps: Steps( - Tool("bash").ArgContains("gogo").NoError(), - ), - Timeout: 300 * time.Second, - MaxTurns: 12, - JudgeCriteria: "The agent must execute a multi-step scan: (1) port discovery with gogo, " + - "(2) web fingerprinting with spray for any HTTP services found. " + - "The final summary must list concrete results (specific IPs, ports, services). " + - "If no HTTP services are found, the agent should report that and skip spray — that's acceptable.", - }.Run(t, h) -} - -// Layer 4: Agent + skills - verify and analyze results - -func TestRealAgentScanWithVerify(t *testing.T) { - h := New(t) - Intent{ - Name: "real-agent-scan-verify", - Prompt: fmt.Sprintf("Scan %s with gogo. For each service found, attempt basic verification "+ - "(e.g. curl for HTTP, or nc for other services). Report: service, port, verification status.", realSingleTarget), - Steps: Steps( - Tool("bash").ArgContains("gogo").NoError(), - ), - Timeout: 300 * time.Second, - MaxTurns: 15, - JudgeCriteria: "The agent must: (1) run gogo to discover services, (2) attempt verification of at least one " + - "discovered service using curl/nc/similar. The report must show per-service verification status. " + - "If gogo finds no services, the agent should report that — still a pass if handled correctly.", - }.Run(t, h) -} - -func TestRealAgentScanReport(t *testing.T) { - h := New(t) - Intent{ - Name: "real-agent-scan-report", - Prompt: fmt.Sprintf("Scan %s using the scan command with --mode quick. Generate a security assessment report.", realSingleTarget), - Steps: Steps( - Tool("bash").ArgContains("scan").NoError(), - ), - Timeout: 300 * time.Second, - MaxTurns: 10, - JudgeCriteria: "The agent must run the scan pipeline and produce a structured security report. " + - "The report must contain: target IP, discovered services, risk assessment or observations. " + - "A bare scan output dump without analysis is a failure.", - }.Run(t, h) -} - -// Layer 5: Agent + subagent fan-out — parallel scanning - -func TestRealAgentParallelScan(t *testing.T) { - h := New(t) - Intent{ - Name: "real-agent-parallel-scan", - Prompt: fmt.Sprintf("I need to scan %s efficiently. Create 2 async subagents:\n"+ - "1. Named 'port-scan': run gogo against the target with -p top100\n"+ - "2. Named 'web-probe': run spray against http://%s with --finger\n"+ - "Wait for both to complete, then produce a consolidated results report.", realSingleTarget, realSingleTarget), - Steps: Steps( - Tool("subagent").Arg("name", "port-scan"), - Tool("subagent").Arg("name", "web-probe"), - ), - Timeout: 300 * time.Second, - MaxTurns: 12, - JudgeCriteria: "The agent must create 2 async subagents for parallel scanning. " + - "Both subagents must complete. The final report must consolidate results from both " + - "port scanning (gogo) and web probing (spray).", - }.Run(t, h) -} - -// Layer 6: Agent + loop tool — recurring scan - -func TestRealAgentLoopScan(t *testing.T) { - h := New(t) - Intent{ - Name: "real-agent-loop-scan", - Prompt: fmt.Sprintf("Set up a recurring scan for %s:\n"+ - "1. First, run gogo -i %s -p top100 immediately and report results\n"+ - "2. Create a loop named 'monitor' with interval '30s' and prompt 'check if any new ports opened on %s'\n"+ - "3. List loops to confirm the monitor is active\n"+ - "4. Delete the loop named 'monitor'\n"+ - "Report the initial scan results.", realSingleTarget, realSingleTarget, realSingleTarget), - Steps: Steps( - Tool("bash").ArgContains("gogo").NoError(), - ), - Ordered: true, - Timeout: 180 * time.Second, - MaxTurns: 10, - NoErrors: true, - JudgeCriteria: "The agent must: (1) run an initial gogo scan and report results, " + - "(2) create a recurring loop for monitoring, (3) list loops to confirm, (4) delete the loop. " + - "All four steps must complete in order. The initial scan must produce actual results (ports/services).", - }.Run(t, h) -} - -// Layer 7: IOA loop mode — swarm worker receives scan task - -func TestRealIOALoopScanTask(t *testing.T) { - service := ioaserver.NewService(ioaserver.NewMemoryStore(), "") - srv := httptest.NewServer(ioaserver.NewHandler(service)) - defer srv.Close() - - h := New(t) - - go func() { - h.RunWithTimeout(180*time.Second, - "agent", "--ioa-url", "http://127.0.0.1:8765", - "--ioa-url", srv.URL, - "--space", "real-scan", - "--ioa-node-name", "scanner-worker", - "-p", "I am a scanner worker with gogo, spray, and neutron capabilities", - "--timeout", "150", - ) - }() - - time.Sleep(5 * time.Second) - - controller, err := ioaclient.NewClient(srv.URL, "") - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - if _, err := controller.RegisterNode(ctx, "controller", "", nil); err != nil { - t.Fatal(err) - } - space, err := controller.Space(ctx, "real-scan", "real scan test") - if err != nil { - t.Fatal(err) - } - - nodes, err := controller.ListNodes(ctx) - if err != nil { - t.Fatal(err) - } - var workerID string - for _, n := range nodes { - if n.Name == "scanner-worker" { - workerID = n.ID - break - } - } - if workerID == "" { - t.Fatal("scanner-worker not found") - } - - _, err = controller.Send(ctx, space.ID, sendMessage( - fmt.Sprintf("Run gogo against %s with -p top100 and report all discovered services with ports and fingerprints.", realSingleTarget), - workerID, - )) - if err != nil { - t.Fatal(err) - } - - time.Sleep(120 * time.Second) - - requireIOAMessageContains(t, controller, ctx, space.ID, realSingleTarget) -} - -// ===================================================================== -// Subagent — sync, async, fan-out, chain, message -// ===================================================================== - -func TestAgentSubagentSync(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-sync", - Prompt: "Use the subagent tool to create a sync subagent with prompt 'echo sub_sync_ok using bash and report the output'. Report the subagent result.", - Steps: Steps( - Tool("subagent").Action("create").NoError(), - ), - OutputContains: []string{"sub_sync_ok"}, - MaxTurns: 4, - JudgeCriteria: "The agent must create a sync subagent. The subagent must execute 'echo sub_sync_ok' via bash. " + - "The final output must contain 'sub_sync_ok' proving the subagent completed and returned its result.", - }.Run(t, h) -} - -func TestAgentSubagentAsync(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-async", - Prompt: "Create an async subagent with prompt 'Run echo async_marker_99 in bash'. Wait for its completion notification and report its result.", - Steps: Steps( - Tool("subagent").Action("create").NoError(), - ), - OutputContains: []string{"async_marker_99"}, - MaxTurns: 8, - JudgeCriteria: "The agent must create an async subagent. It must then wait for the subagent completion notification " + - "(which arrives via inbox). The final output must contain 'async_marker_99'.", - }.Run(t, h) -} - -func TestAgentSubagentSyncTimeout(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-sync-timeout", - Prompt: "Create a sync subagent with timeout '2s' and prompt 'Run sleep 30 in bash'. Report what happened (it should timeout).", - Steps: Steps( - Tool("subagent").ResultHas("timed out"), - ), - MaxTurns: 3, - JudgeCriteria: "The agent must create a sync subagent with a 2s timeout running 'sleep 30'. " + - "The subagent must timeout. The agent must report the timeout in its output.", - }.Run(t, h) -} - -func TestAgentSubagentList(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-list", - Prompt: "Create an async subagent named 'worker1' with prompt 'sleep 5'. Then immediately use subagent list action to show running subagents. Report the list.", - Steps: Steps( - Tool("subagent").Arg("name", "worker1"), - Tool("subagent").Action("list"), - ), - MaxTurns: 6, - JudgeCriteria: "The agent must: (1) create an async subagent named 'worker1', " + - "(2) call subagent list to show running subagents, " + - "(3) the list result should show 'worker1' as running.", - }.Run(t, h) -} - -func TestAgentMultiSubagentFanOut(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-fan-out", - Prompt: "You have 3 independent tasks. Use the subagent tool to create 3 SEPARATE async subagents, one for each:\n" + - "1. Subagent named 'host-info': run 'uname -a' in bash and report.\n" + - "2. Subagent named 'user-info': run 'whoami' in bash and report.\n" + - "3. Subagent named 'dir-info': run 'pwd' in bash and report.\n" + - "Create all 3 subagents, then wait for all completion notifications. " + - "Summarize all 3 results together.", - Steps: Steps( - Tool("subagent").Arg("name", "host-info"), - Tool("subagent").Arg("name", "user-info"), - Tool("subagent").Arg("name", "dir-info"), - ), - MaxTurns: 10, - JudgeCriteria: "The agent must create exactly 3 async subagents (host-info, user-info, dir-info). " + - "It must wait for all 3 completions. The final output must summarize results from all 3 subagents.", - }.Run(t, h) -} - -func TestAgentSubagentWithBashAndReport(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-bash-report", - Prompt: "Create 2 async subagents:\n" + - "1. Named 'counter': run 'seq 1 5' in bash.\n" + - "2. Named 'greeter': run 'echo hello_from_subagent' in bash.\n" + - "Wait for both to complete. Then report both outputs in your final answer.", - Steps: Steps( - Tool("subagent").Arg("name", "counter"), - Tool("subagent").Arg("name", "greeter"), - ), - OutputContains: []string{"hello_from_subagent"}, - MaxTurns: 10, - JudgeCriteria: "The agent must create 2 subagents and wait for both. " + - "The final output must include the output from both: the sequence 1-5 and 'hello_from_subagent'.", - }.Run(t, h) -} - -func TestAgentSubagentChain(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-chain", - Prompt: "Step 1: Create a sync subagent that runs 'echo chain_step_1' in bash and returns the output.\n" + - "Step 2: After you receive the result from step 1, create another sync subagent " + - "that runs 'echo chain_step_2' in bash.\n" + - "Report both results to confirm the chain completed.", - MaxTurns: 8, - JudgeCriteria: "The agent must create 2 sync subagents sequentially (not in parallel). " + - "Step 2 must happen AFTER step 1 completes. " + - "The final output must contain both 'chain_step_1' and 'chain_step_2'.", - Check: func(t *testing.T, r *RunResult) { - results := r.SubagentResults() - if len(results) < 2 { - t.Fatalf("expected ≥2 subagent results, got %d", len(results)) - } - s1, s2 := -1, -1 - for i, res := range results { - if strings.Contains(res, "chain_step_1") && s1 == -1 { - s1 = i - } - if strings.Contains(res, "chain_step_2") && s2 == -1 { - s2 = i - } - } - if s1 >= 0 && s2 >= 0 && s1 >= s2 { - t.Fatalf("chain order wrong: step1 at %d, step2 at %d", s1, s2) - } - }, - }.Run(t, h) -} - -func TestAgentSubagentMessage(t *testing.T) { - h := New(t) - Intent{ - Name: "subagent-message", - Prompt: "Create an async subagent named 'listener' with prompt: " + - "'Wait for a message. When you receive one, run echo GOT_MESSAGE in bash and report.'\n" + - "After creating it, use the subagent message action to send a message " + - "'hello from parent' to the 'listener' subagent.\n" + - "Wait for the listener to complete and report its result.", - Steps: Steps( - Tool("subagent").Arg("name", "listener"), - Tool("subagent").Action("message").Arg("name", "listener"), - ), - Ordered: true, - MaxTurns: 10, - JudgeCriteria: "The agent must: (1) create an async subagent named 'listener', " + - "(2) send a message to it via the subagent message action, " + - "(3) the listener must execute 'echo GOT_MESSAGE' after receiving the message, " + - "(4) the final output must contain 'GOT_MESSAGE' confirming the message was received and processed.", - }.Run(t, h) -} - -// ===================================================================== -// Task — tmux background tasks -// ===================================================================== - -func TestAgentBackgroundTask(t *testing.T) { - h := New(t) - r := h.Agent("Start a background shell session: tmux new -d -s bg 'sleep 1 && echo bg_done'. Then use tmux ls to list running sessions. Use tmux wait -t bg to wait for it to finish. Use tmux capture-pane -t bg to get the output. Report the final output.") - Verify(t, r). - OK(). - ToolUsed("bash"). - AnyResultContains("bg_done"). - NoToolErrors(). - Done() -} - -func TestAgentTmuxPeek(t *testing.T) { - h := New(t) - r := h.Agent("Run 'for i in 1 2 3; do echo line_$i; sleep 0.5; done' as a detached tmux session named 'lines'. Use tmux capture-pane -t lines --new to check its output, then wait for completion and report all lines.") - Verify(t, r). - OK(). - ToolUsed("bash"). - Done() -} - -func TestAgentTmuxKill(t *testing.T) { - h := New(t) - r := h.Agent("Start a detached tmux session: tmux new -d -s sleeper 'sleep 300'. Use tmux ls to confirm it's running. Kill it with tmux kill -t sleeper. List again to confirm it's killed. Report status.") - Verify(t, r). - OK(). - ToolUsed("bash"). - Done() -} - -// ===================================================================== -// Verify mechanism — scan verify/sniper mode tests -// ===================================================================== - -const verifyTarget = realSingleTarget - -// TestVerifyOffProducesNoAIOutput runs scan with --verify=off and confirms -// that no AI skill output appears in the results. -func TestVerifyOffProducesNoAIOutput(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(300*time.Second, - "scan", "-i", "127.0.0.1", "--mode", "quick", "--verify=off", "--timeout", "3", - ) - Verify(t, r).OK().Done() - - if hasAISkillOutput(r.Stdout) { - t.Fatalf("--verify=off should produce no AI skill output, got:\n%s", clip(r.Stdout, 2000)) - } - t.Logf("verify=off output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000)) -} - -// TestVerifyHighWithSniperTriggersAIVerification runs scan with explicit -// verify and sniper options and confirms that the scan pipeline completes with -// AI skills enabled. When targets have high-priority loots, AI verify and -// sniper skills produce output. -func TestVerifyHighWithSniperTriggersAIVerification(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(600*time.Second, - "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--timeout", "5", - ) - Verify(t, r).OK().Done() - - if !hasSummaryLine(r.Stdout) { - t.Fatal("expected [summary] line in output") - } - t.Logf("verify+sniper output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000)) -} - -// TestVerifyExplicitModeWithoutSniper runs scan with --verify=high explicitly -// (no --sniper) and checks that verify runs but sniper is NOT activated. -func TestVerifyExplicitModeWithoutSniper(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(600*time.Second, - "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--timeout", "5", - ) - Verify(t, r).OK().Done() - - if hasSniperOutput(r.Stdout) { - t.Fatal("--verify=high without --sniper should not produce sniper output") - } - if !hasSummaryLine(r.Stdout) { - t.Fatal("expected [summary] line in output") - } - t.Logf("verify=high output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000)) -} - -// TestScanVerifySniperNoPostAnalysis verifies that the old post-analysis -// one-shot LLM call no longer runs. Explicit scan AI skills trigger only -// in-pipeline AI work (verify + sniper), not a separate "analysis" step. -// The output should contain the [summary] line from the scan pipeline but -// should not contain the "analysis" output section that runScannerPostAnalysis -// used to produce. -func TestScanVerifySniperNoPostAnalysis(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(600*time.Second, - "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--timeout", "5", - ) - Verify(t, r).OK().Done() - - if !hasSummaryLine(r.Stdout) { - t.Fatal("expected [summary] line from scan pipeline") - } - t.Logf("output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000)) -} - -// TestScanDefaultModeCompletes runs scan without any explicit AI skill flags. -// The default verify mode is "auto" (mapped to "high"), which enables the -// provider optionally. If the provider initializes, AI verify can run; if not, -// the scan still completes successfully. -func TestScanDefaultModeCompletes(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(600*time.Second, - "scan", "-i", verifyTarget, "--mode", "quick", "--timeout", "5", - ) - Verify(t, r).OK().Done() - - if !hasSummaryLine(r.Stdout) { - t.Fatal("expected [summary] line in output") - } - t.Logf("default mode output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000)) -} - -// TestVerifyOffDisablesAllAISkills confirms that --verify=off combined with -// no --sniper and no --deep results in zero AI skill results in the summary. -func TestVerifyOffDisablesAllAISkills(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(300*time.Second, - "scan", "-i", "127.0.0.1", "--mode", "quick", "--verify=off", "--timeout", "3", - ) - Verify(t, r).OK().Done() - - summary := extractSummaryLine(r.Stdout) - if summary == "" { - t.Fatal("missing [summary] line") - } - if strings.Contains(summary, "verified") { - parts := strings.Fields(summary) - for i, p := range parts { - if p == "verified" && i > 0 && parts[i-1] != "0" { - t.Fatalf("expected 0 verified in summary with --verify=off, got: %s", summary) - } - } - } - t.Logf("verify=off summary: %s", summary) -} - -// TestScanVerifyWithReportIncludesVerification runs scan with explicit -// verification and report output and verifies the report includes AI -// verification metrics. -func TestScanVerifyWithReportIncludesVerification(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(600*time.Second, - "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--report", "--timeout", "5", - ) - Verify(t, r).OK().Done() - - hasMetrics := strings.Contains(r.Stdout, "AI verifications") || - strings.Contains(r.Stdout, "AI skill") || - strings.Contains(r.Stdout, "verified") - if !hasMetrics { - t.Fatal("--verify=high --sniper --report should include AI verification information in output") - } - t.Logf("report output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 3000)) -} - -// TestAssetReportFileOutputFormats runs scan with -f and -F and verifies both -// output formats include structured checkpoint loots. -func TestAssetReportFileOutputFormats(t *testing.T) { - h := New(t) - r := h.RunWithTimeout(600*time.Second, - "scan", "-i", verifyTarget, "--mode", "quick", "--verify=high", "--sniper", "--timeout", "5", - "-f", "output.txt", "-F", "asset_report.txt", - ) - Verify(t, r).OK().Done() - - plainBytes, err := os.ReadFile(h.WorkFile("output.txt")) - if err != nil { - t.Fatalf("read -f output: %v", err) - } - plain := string(plainBytes) - t.Logf("-f output (%d bytes):\n%s", len(plain), clip(plain, 3000)) - - assetReportBytes, err := os.ReadFile(h.WorkFile("asset_report.txt")) - if err != nil { - if !hasAISkillOutput(r.Stdout) { - t.Skip("no AI output produced, skipping -F check") - } - t.Fatalf("read -F output: %v", err) - } - assetReport := string(assetReportBytes) - t.Logf("-F output (%d bytes):\n%s", len(assetReport), clip(assetReport, 3000)) - - if len(assetReport) > 0 { - if !strings.Contains(assetReport, "Assets:") { - t.Fatal("-F output should contain 'Assets:' header") - } - } -} - -// ===================================================================== -// Shared helpers -// ===================================================================== - -// containsCount counts occurrences of substr in s. -func containsCount(s, substr string) int { - return strings.Count(s, substr) -} - -// requireIOAMessageContains checks that at least one message in the space contains substr. -func requireIOAMessageContains(t *testing.T, client *ioaclient.Client, ctx context.Context, spaceID, substr string) { - t.Helper() - msgs, err := client.Read(ctx, spaceID, protocols.ReadOptions{All: true}) - if err != nil { - t.Fatalf("read space: %v", err) - } - for _, m := range msgs { - raw, _ := json.Marshal(m.Content) - if strings.Contains(string(raw), substr) { - return - } - } - var summaries []string - for _, m := range msgs { - raw, _ := json.Marshal(m.Content) - summaries = append(summaries, clip(string(raw), 200)) - } - t.Fatalf("no IOA message contains %q:\n%s", substr, strings.Join(summaries, "\n")) -} - -// verifyScanner runs a direct scanner command and uses the judge to evaluate. -func (intent Intent) verifyScanner(t *testing.T, h *Harness, args ...string) *RunResult { - t.Helper() - r := h.RunWithTimeout(intent.Timeout, args...) - v := Verify(t, r).OK() - if intent.JudgeCriteria != "" { - prompt := fmt.Sprintf("Scanner command: %v", args) - v = v.JudgeWith(h.Judge(), prompt, intent.JudgeCriteria) - } - v.Done() - t.Logf("output (%d bytes):\n%s", len(r.Stdout), clip(r.Stdout, 2000)) - return r -} - -func hasAISkillOutput(output string) bool { - markers := []string{"[ai:", "[sniper:", "[ai]", "[sniper]"} - for _, m := range markers { - if strings.Contains(output, m) { - return true - } - } - return false -} - -func hasSniperOutput(output string) bool { - return strings.Contains(output, "[sniper:") || strings.Contains(output, "[sniper]") -} - -func hasSummaryLine(output string) bool { - return strings.Contains(output, "[summary]") || strings.Contains(output, "completed") -} - -func extractSummaryLine(output string) string { - for _, line := range strings.Split(output, "\n") { - if strings.Contains(line, "[summary]") { - return line - } - } - return "" -} diff --git a/core/harness/intent.go b/core/harness/intent.go deleted file mode 100644 index e5b57d2a..00000000 --- a/core/harness/intent.go +++ /dev/null @@ -1,167 +0,0 @@ -//go:build e2e - -package harness - -import ( - "fmt" - "strings" - "testing" - "time" -) - -// Intent describes a complete AI behavior test case declaratively. -// Instead of writing imperative test code, define an Intent and call Run. -// -// Intent{ -// Name: "subagent-lifecycle", -// Prompt: "Create a sync subagent to scan localhost.", -// Steps: Steps( -// Tool("subagent").Action("create").Arg("name", "scanner"), -// ), -// Ordered: true, -// MaxTurns: 4, -// NoErrors: true, -// }.Run(t, h) -type Intent struct { - Name string - Prompt string - ExtraArgs []string - Timeout time.Duration - - // Steps describes expected tool calls. - Steps []ToolPattern - - // Ordered requires steps to appear in sequence (subsequence match). - // When false, steps can appear in any order. - Ordered bool - - // OutputContains lists substrings that must appear in stdout/stderr. - OutputContains []string - - // OutputMissing lists substrings that must NOT appear in output. - OutputMissing []string - - // NoErrors requires all tool calls to succeed. - NoErrors bool - - // MaxTurns caps the number of turns (0 = no limit). - MaxTurns int - - // MaxToolCalls caps total tool invocations (0 = no limit). - MaxToolCalls int - - // MaxDuration caps wall-clock time (0 = no limit). - MaxDuration time.Duration - - // JudgeCriteria, when non-empty, enables LLM-as-judge evaluation. - // The judge receives the intent prompt, this criteria string, and the - // full execution trace. It returns a pass/fail verdict. - // Example: "The agent must have created exactly one loop named 'scanner', - // listed it to confirm it exists, then deleted it." - JudgeCriteria string - - // Check is an optional custom verification function. - Check func(t *testing.T, r *RunResult) -} - -// Steps is a convenience constructor for []ToolPattern. -func Steps(patterns ...ToolPattern) []ToolPattern { return patterns } - -// Run executes the intent against the harness and verifies all expectations. -func (intent Intent) Run(t *testing.T, h *Harness) *RunResult { - t.Helper() - - var r *RunResult - if intent.Timeout > 0 { - r = h.AgentWithTimeout(intent.Timeout, intent.Prompt, intent.ExtraArgs...) - } else { - r = h.Agent(intent.Prompt, intent.ExtraArgs...) - } - intent.verify(t, h, r) - return r -} - -func (intent Intent) verify(t *testing.T, h *Harness, r *RunResult) { - t.Helper() - - v := Verify(t, r).OK() - - // structural checks - if len(intent.Steps) > 0 { - if intent.Ordered { - v = v.ExpectInOrder(intent.Steps...) - } else { - v = v.Expect(intent.Steps...) - } - } - for _, s := range intent.OutputContains { - v = v.OutputContains(s) - } - for _, s := range intent.OutputMissing { - v = v.OutputMissing(s) - } - if intent.NoErrors { - v = v.NoToolErrors() - } - if intent.MaxTurns > 0 { - v = v.MaxTurns(intent.MaxTurns) - } - if intent.MaxToolCalls > 0 { - v = v.MaxToolCalls(intent.MaxToolCalls) - } - if intent.MaxDuration > 0 { - v = v.CompletedWithin(intent.MaxDuration) - } - - // semantic check via LLM judge - if intent.JudgeCriteria != "" { - v = v.JudgeWith(h.Judge(), intent.Prompt, intent.JudgeCriteria) - } - - v.Done() - - if intent.Check != nil { - intent.Check(t, r) - } -} - -// Describe returns a human-readable summary of the intent for logging. -func (intent Intent) Describe() string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Intent: %s\n", intent.Name)) - sb.WriteString(fmt.Sprintf(" Prompt: %s\n", clip(intent.Prompt, 80))) - if len(intent.Steps) > 0 { - order := "any order" - if intent.Ordered { - order = "in order" - } - sb.WriteString(fmt.Sprintf(" Steps (%s):\n", order)) - for i, s := range intent.Steps { - sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, s.describe())) - } - } - if len(intent.OutputContains) > 0 { - sb.WriteString(fmt.Sprintf(" Output must contain: %v\n", intent.OutputContains)) - } - if intent.MaxTurns > 0 { - sb.WriteString(fmt.Sprintf(" Max turns: %d\n", intent.MaxTurns)) - } - if intent.NoErrors { - sb.WriteString(" No tool errors allowed\n") - } - return sb.String() -} - -// IntentSuite runs multiple intents as subtests. -func IntentSuite(t *testing.T, h *Harness, intents ...Intent) { - t.Helper() - for _, intent := range intents { - name := intent.Name - if name == "" { - name = clip(intent.Prompt, 40) - } - t.Run(name, func(t *testing.T) { - intent.Run(t, h) - }) - } -} diff --git a/core/harness/judge.go b/core/harness/judge.go deleted file mode 100644 index 7fe26a93..00000000 --- a/core/harness/judge.go +++ /dev/null @@ -1,205 +0,0 @@ -//go:build e2e - -package harness - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// Verdict is the structured result from an LLM judge evaluation. -type Verdict struct { - Pass bool `json:"pass"` - Score int `json:"score"` - Reason string `json:"reason"` - Issues []string `json:"issues"` -} - -// Judge evaluates agent execution results using an LLM. -type Judge struct { - baseURL string - apiKey string - model string - timeout time.Duration -} - -func NewJudge(baseURL, apiKey, model string) *Judge { - return &Judge{ - baseURL: strings.TrimRight(baseURL, "/"), - apiKey: apiKey, - model: model, - timeout: 30 * time.Second, - } -} - -func (h *Harness) Judge() *Judge { - return NewJudge(h.baseURL, h.apiKey, h.model) -} - -const judgeMaxRetries = 3 - -// Evaluate sends the intent and execution trace to the LLM for judgment. -func (j *Judge) Evaluate(intent string, criteria string, r *RunResult) (*Verdict, error) { - trace := buildTrace(r) - prompt := buildJudgePrompt(intent, criteria, trace) - - var lastErr error - for attempt := 0; attempt < judgeMaxRetries; attempt++ { - v, err := j.call(prompt) - if err == nil { - return v, nil - } - lastErr = err - if attempt < judgeMaxRetries-1 { - time.Sleep(time.Duration(attempt+1) * time.Second) - } - } - return nil, fmt.Errorf("judge failed after %d attempts: %w", judgeMaxRetries, lastErr) -} - -func buildTrace(r *RunResult) string { - var sb strings.Builder - fmt.Fprintf(&sb, "Exit code: %d\n", r.ExitCode) - fmt.Fprintf(&sb, "Duration: %s\n", r.Duration.Round(time.Millisecond)) - fmt.Fprintf(&sb, "Turns: %d\n", r.Turns()) - fmt.Fprintf(&sb, "Tool calls: %d\n", len(r.ToolCalls())) - - sb.WriteString("\nTool call trace:\n") - for i, e := range r.ToolCalls() { - fmt.Fprintf(&sb, " [%d] %s", i+1, e.Name()) - if e.IsError() { - sb.WriteString(" (ERROR)") - } - sb.WriteByte('\n') - if args := argsText(e.Args()); args != "" { - fmt.Fprintf(&sb, " args: %s\n", clip(args, 200)) - } - if result := e.ResultText(); result != "" { - fmt.Fprintf(&sb, " result: %s\n", clip(result, 300)) - } - } - - if output := strings.TrimSpace(r.Stdout); output != "" { - fmt.Fprintf(&sb, "\nFinal output:\n%s\n", clip(output, 1000)) - } - return sb.String() -} - -const judgeSystemPrompt = `You are a strict test evaluator for an AI agent system. Given an intent (what was asked), evaluation criteria, and execution trace (what happened), determine whether the agent correctly fulfilled the intent. - -Respond with ONLY a JSON object: -{"pass": true/false, "score": 0-100, "reason": "one sentence summary", "issues": ["issue1", "issue2"]} - -Rules: -- pass=true only if the intent was fully and correctly completed -- score: 100=perfect, 80+=good, 60+=acceptable, <60=fail -- issues: list specific problems (empty if pass=true) -- Be strict: "ran without errors" is not the same as "fulfilled the intent" -- Check that the right tools were used with correct arguments -- Check that results contain expected data, not just that tools were called` - -func buildJudgePrompt(intent, criteria, trace string) string { - var sb strings.Builder - fmt.Fprintf(&sb, "## Intent\n%s\n\n", intent) - if criteria != "" { - fmt.Fprintf(&sb, "## Evaluation Criteria\n%s\n\n", criteria) - } - fmt.Fprintf(&sb, "## Execution Trace\n%s", trace) - return sb.String() -} - -type chatRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature"` -} - -type chatMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type chatResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` -} - -func (j *Judge) call(userPrompt string) (*Verdict, error) { - body := chatRequest{ - Model: j.model, - Messages: []chatMessage{ - {Role: "system", Content: judgeSystemPrompt}, - {Role: "user", Content: userPrompt}, - }, - MaxTokens: 512, - Temperature: 0, - } - - data, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), j.timeout) - defer cancel() - - url := j.baseURL + "/chat/completions" - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+j.apiKey) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("judge API call failed: %w", err) - } - defer resp.Body.Close() - - respData, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("judge API returned %d: %s", resp.StatusCode, clip(string(respData), 500)) - } - - var chatResp chatResponse - if err := json.Unmarshal(respData, &chatResp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - if len(chatResp.Choices) == 0 { - return nil, fmt.Errorf("judge returned no choices") - } - - return parseVerdict(chatResp.Choices[0].Message.Content) -} - -func parseVerdict(raw string) (*Verdict, error) { - raw = strings.TrimSpace(raw) - raw = stripJSONFences(raw) - - var v Verdict - if err := json.Unmarshal([]byte(raw), &v); err != nil { - return nil, fmt.Errorf("parse verdict JSON: %w\nraw: %s", err, clip(raw, 500)) - } - return &v, nil -} - -func stripJSONFences(s string) string { - s = strings.TrimPrefix(s, "```json") - s = strings.TrimPrefix(s, "```") - s = strings.TrimSuffix(s, "```") - return strings.TrimSpace(s) -} diff --git a/core/harness/monitor.go b/core/harness/monitor.go deleted file mode 100644 index 9b7da9dc..00000000 --- a/core/harness/monitor.go +++ /dev/null @@ -1,76 +0,0 @@ -//go:build e2e - -package harness - -import ( - "fmt" - "io" - - "github.com/chainreactors/aiscan/pkg/agent/truncate" - "github.com/chainreactors/aiscan/pkg/aop" -) - -// Monitor renders the AOP events received from the agent's webproto stdout. -// Attach it to a Harness with h.WithMonitor(). -// -// Output goes to the provided Writer (typically os.Stderr for live -// terminal view, or a test log adapter). -type Monitor struct { - out io.Writer - runSeen string -} - -func NewMonitor(out io.Writer) *Monitor { - return &Monitor{out: out} -} - -func (m *Monitor) printf(format string, args ...any) { - fmt.Fprintf(m.out, format, args...) -} - -func (m *Monitor) renderEvent(ev aop.Event) { - switch ev.Type { - case aop.TypeSessionStart: - m.runSeen = "" - - case aop.TypeTurnStart: - if ev.TurnID != "" && ev.TurnID != m.runSeen { - m.runSeen = ev.TurnID - m.printf("\n── run %s ──\n", ev.TurnID) - } - - case aop.TypeMessage: - data, err := aop.DecodeData[aop.MessageData](ev) - if err == nil && (data.Role == "" || data.Role == "assistant") { - if text := messageText(data); text != "" { - m.printf(" 💬 %s\n", truncate.Clip(text, 200)) - } - } - - case aop.TypeToolCall: - data, err := aop.DecodeData[aop.ToolCallData](ev) - if err == nil { - m.printf(" 🔧 %s %s\n", data.ToolName, truncate.Clip(argsText(data.Args), 120)) - } - - case aop.TypeToolResult: - data, err := aop.DecodeData[aop.ToolResultData](ev) - if err == nil { - result := valueText(data.Content) - switch { - case data.IsError: - m.printf(" ❌ %s error: %s\n", data.ToolName, truncate.Clip(result, 100)) - case result != "": - m.printf(" ✓ %s → %d bytes: %s\n", data.ToolName, len(result), truncate.Clip(result, 100)) - default: - m.printf(" ✓ %s → (empty)\n", data.ToolName) - } - } - - case aop.TypeTurnEnd: - data, err := aop.DecodeData[aop.TurnEndData](ev) - if err == nil { - m.printf("\n── run done (stop=%s) ──\n", data.Stop) - } - } -} diff --git a/core/harness/result.go b/core/harness/result.go deleted file mode 100644 index 6918cb8f..00000000 --- a/core/harness/result.go +++ /dev/null @@ -1,233 +0,0 @@ -//go:build e2e - -package harness - -import ( - "encoding/json" - "strings" - "time" - - "github.com/chainreactors/aiscan/pkg/aop" -) - -type RunResult struct { - Stdout string - Stderr string - ExitCode int - Duration time.Duration - Events []aop.Event -} - -// ToolExecution is an on-demand typed view that retains both original AOP -// envelopes. It is never stored in place of the protocol events. -type ToolExecution struct { - CallEvent aop.Event - ResultEvent aop.Event - Call aop.ToolCallData - Result aop.ToolResultData -} - -func (e ToolExecution) Name() string { - if e.Result.ToolName != "" { - return e.Result.ToolName - } - return e.Call.ToolName -} - -func (e ToolExecution) Args() any { return e.Call.Args } -func (e ToolExecution) ResultText() string { return valueText(e.Result.Content) } -func (e ToolExecution) IsError() bool { return e.Result.IsError } - -func (r *RunResult) OK() bool { return r.ExitCode == 0 } -func (r *RunResult) Output() string { return strings.TrimSpace(r.Stdout) } -func (r *RunResult) Combined() string { return r.Stdout + r.Stderr } - -func (r *RunResult) ContainsOutput(substr string) bool { - return strings.Contains(r.Stdout, substr) || strings.Contains(r.Stderr, substr) -} - -func (r *RunResult) ToolCalls() []ToolExecution { - calls := make(map[string]struct { - event aop.Event - data aop.ToolCallData - }) - for _, event := range r.Events { - if event.Type != aop.TypeToolCall { - continue - } - data, err := aop.DecodeData[aop.ToolCallData](event) - if err == nil && data.ToolCallID != "" { - calls[data.ToolCallID] = struct { - event aop.Event - data aop.ToolCallData - }{event: event, data: data} - } - } - var out []ToolExecution - for _, event := range r.Events { - if event.Type != aop.TypeToolResult { - continue - } - data, err := aop.DecodeData[aop.ToolResultData](event) - if err != nil { - continue - } - call := calls[data.ToolCallID] - out = append(out, ToolExecution{ - CallEvent: call.event, ResultEvent: event, Call: call.data, Result: data, - }) - } - return out -} - -func (r *RunResult) HasToolCall(name string) bool { - return len(r.ToolCallsNamed(name)) > 0 -} - -func (r *RunResult) ToolCallsNamed(name string) []ToolExecution { - var out []ToolExecution - for _, execution := range r.ToolCalls() { - if execution.Name() == name { - out = append(out, execution) - } - } - return out -} - -func (r *RunResult) Turns() int { - seen := make(map[string]struct{}) - for _, event := range r.Events { - if event.Type == aop.TypeTurnStart && event.TurnID != "" { - seen[event.TurnID] = struct{}{} - } - } - return len(seen) -} - -func (r *RunResult) ToolCallSequence() []string { - var names []string - for _, execution := range r.ToolCalls() { - names = append(names, execution.Name()) - } - return names -} - -func (r *RunResult) ToolResultContains(toolName, substr string) bool { - for _, execution := range r.ToolCallsNamed(toolName) { - if strings.Contains(execution.ResultText(), substr) { - return true - } - } - return false -} - -func (r *RunResult) ToolArgsContains(toolName, substr string) bool { - for _, execution := range r.ToolCallsNamed(toolName) { - if strings.Contains(argsText(execution.Args()), substr) { - return true - } - } - return false -} - -func (r *RunResult) AllToolResults() string { - var sb strings.Builder - for _, execution := range r.ToolCalls() { - sb.WriteString(execution.ResultText()) - sb.WriteByte('\n') - } - return sb.String() -} - -func (r *RunResult) ErroredToolCalls() []ToolExecution { - var out []ToolExecution - for _, execution := range r.ToolCalls() { - if execution.IsError() { - out = append(out, execution) - } - } - return out -} - -func (r *RunResult) StopReason() string { - for i := len(r.Events) - 1; i >= 0; i-- { - if r.Events[i].Type != aop.TypeTurnEnd { - continue - } - data, err := aop.DecodeData[aop.TurnEndData](r.Events[i]) - if err == nil { - return data.Stop - } - } - return "" -} - -func (r *RunResult) TotalTokens() int { - for i := len(r.Events) - 1; i >= 0; i-- { - if r.Events[i].Type != aop.TypeUsage { - continue - } - data, err := aop.DecodeData[aop.UsageData](r.Events[i]) - if err == nil && data.TotalTokens > 0 { - return data.TotalTokens - } - } - return 0 -} - -func (r *RunResult) SubagentCalls() []ToolExecution { return r.ToolCallsNamed("subagent") } - -func (r *RunResult) SubagentCreateCount() int { - n := 0 - for _, execution := range r.SubagentCalls() { - if isSubagentCreate(execution) { - n++ - } - } - return n -} - -func (r *RunResult) SubagentCreateArgs() []string { - var args []string - for _, execution := range r.SubagentCalls() { - if isSubagentCreate(execution) { - args = append(args, argsText(execution.Args())) - } - } - return args -} - -func (r *RunResult) SubagentResults() []string { - var results []string - for _, execution := range r.SubagentCalls() { - if isSubagentCreate(execution) { - results = append(results, execution.ResultText()) - } - } - return results -} - -func isSubagentCreate(execution ToolExecution) bool { - args, ok := execution.Args().(map[string]any) - if !ok { - return true - } - action, _ := args["action"].(string) - return action != "list" && action != "kill" && action != "message" -} - -func argsText(args any) string { return valueText(args) } - -func valueText(value any) string { - if value == nil { - return "" - } - if text, ok := value.(string); ok { - return text - } - encoded, err := json.Marshal(value) - if err != nil { - return "" - } - return string(encoded) -} diff --git a/core/harness/stdio.go b/core/harness/stdio.go deleted file mode 100644 index ac74aa59..00000000 --- a/core/harness/stdio.go +++ /dev/null @@ -1,128 +0,0 @@ -//go:build e2e - -package harness - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "strings" - - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/webproto" -) - -// messageText flattens the text parts of a complete AOP message. -func messageText(data aop.MessageData) string { - var sb strings.Builder - for _, part := range data.Parts { - if part.Type != aop.PartText || part.Text == "" { - continue - } - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString(part.Text) - } - return sb.String() -} - -func consumeAgentStream(input io.Reader, monitor *Monitor) (string, []aop.Event, error) { - var output string - var events []aop.Event - decoder := json.NewDecoder(input) - sessionID := "" - turnID := "" - var agentErr error - - for { - var message webproto.Message - if err := decoder.Decode(&message); err != nil { - if errors.Is(err, io.EOF) { - if sessionID == "" { - return output, events, fmt.Errorf("stdio stream ended without session.opened") - } - return output, events, fmt.Errorf("stdio stream ended without run turn.end") - } - return output, events, fmt.Errorf("decode stdio frame: %w", err) - } - switch message.Type { - case webproto.TypeSessionOpened: - var data webproto.SessionLifecyclePayload - if err := json.Unmarshal(message.Payload, &data); err != nil { - return output, events, fmt.Errorf("decode session.opened: %w", err) - } - if data.SessionID == "" { - return output, events, fmt.Errorf("session.opened has empty session_id") - } - if sessionID == "" { - sessionID = data.SessionID - } else if sessionID != data.SessionID { - return output, events, fmt.Errorf("unexpected session.opened for %q", data.SessionID) - } - - case webproto.TypeError: - var data webproto.ErrorPayload - if err := json.Unmarshal(message.Payload, &data); err != nil { - return output, events, fmt.Errorf("decode error frame: %w", err) - } - if strings.TrimSpace(data.Message) == "" { - return output, events, fmt.Errorf("stdio error frame has empty message") - } - return output, events, fmt.Errorf("agent error: %s", data.Message) - - case webproto.TypeAOP: - var event aop.Event - if err := json.Unmarshal(message.Payload, &event); err != nil { - return output, events, fmt.Errorf("decode AOP payload: %w", err) - } - if !event.Valid() { - return output, events, fmt.Errorf("invalid AOP envelope") - } - events = append(events, event) - if monitor != nil { - monitor.renderEvent(event) - } - if sessionID == "" && event.Type == aop.TypeSessionStart { - sessionID = event.SessionID - } - if event.SessionID != sessionID { - continue - } - if turnID == "" && event.Type == aop.TypeTurnStart { - turnID = event.TurnID - } - if turnID != "" && event.TurnID != "" && event.TurnID != turnID { - continue - } - switch event.Type { - case aop.TypeMessage: - var data aop.MessageData - if json.Unmarshal(event.Data, &data) == nil && data.Role != "user" { - if text := messageText(data); text != "" { - output = text - } - } - case aop.TypeError: - var data aop.ErrorData - if json.Unmarshal(event.Data, &data) != nil || strings.TrimSpace(data.Message) == "" { - return output, events, fmt.Errorf("run AOP error has empty message") - } - agentErr = fmt.Errorf("agent error: %s", data.Message) - case aop.TypeTurnEnd: - var data aop.TurnEndData - if err := json.Unmarshal(event.Data, &data); err != nil { - return output, events, fmt.Errorf("decode turn.end: %w", err) - } - if agentErr != nil { - return output, events, agentErr - } - if data.Stop == "error" || data.Error != "" { - return output, events, fmt.Errorf("agent error: %s", strings.TrimSpace(data.Error)) - } - return output, events, nil - } - } - } -} diff --git a/core/harness/stdio_test.go b/core/harness/stdio_test.go deleted file mode 100644 index adb5139a..00000000 --- a/core/harness/stdio_test.go +++ /dev/null @@ -1,195 +0,0 @@ -//go:build e2e - -package harness - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/webproto" -) - -func TestConsumeAgentStream(t *testing.T) { - input := encodeFrames(t, - sessionOpenedFrame("root"), - aopFrame(aopTestEvent("root", "", aop.TypeSessionStart, aop.SessionStartData{})), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnStart, aop.TurnStartData{})), - aopFrame(aopMessageEvent("root", "turn-1", "assistant", "hello")), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnEnd, aop.TurnEndData{Stop: "completed"})), - ) - - var monitorOutput bytes.Buffer - output, events, err := consumeAgentStream(input, NewMonitor(&monitorOutput)) - if err != nil { - t.Fatalf("consumeAgentStream() error = %v", err) - } - if output != "hello" || len(events) != 4 { - t.Fatalf("output=%q events=%#v", output, events) - } - if !strings.Contains(monitorOutput.String(), "hello") || !strings.Contains(monitorOutput.String(), "run turn-1") { - t.Fatalf("monitor output = %q", monitorOutput.String()) - } -} - -func TestConsumeAgentStreamKeepsTypedToolData(t *testing.T) { - callID := "call-1" - input := encodeFrames(t, - sessionOpenedFrame("root"), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnStart, aop.TurnStartData{})), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeToolCall, aop.ToolCallData{ - ToolCallID: callID, - ToolName: "bash", - Args: map[string]any{ - "command": "echo hello", - "nested": []any{map[string]any{"enabled": true}}, - }, - })), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeToolResult, aop.ToolResultData{ - ToolCallID: callID, - ToolName: "bash", - Content: map[string]any{"output": []any{"hello", map[string]any{"code": float64(0)}}}, - })), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnEnd, aop.TurnEndData{Stop: "completed"})), - ) - - _, events, err := consumeAgentStream(input, nil) - if err != nil { - t.Fatalf("consumeAgentStream() error = %v", err) - } - calls := (&RunResult{Events: events}).ToolCalls() - if len(calls) != 1 { - t.Fatalf("tool calls = %#v", calls) - } - args, ok := calls[0].Args().(map[string]any) - if !ok || args["command"] != "echo hello" { - t.Fatalf("args = %#v", calls[0].Args()) - } - if !strings.Contains(calls[0].ResultText(), `"output":["hello"`) { - t.Fatalf("result = %q", calls[0].ResultText()) - } -} - -func TestConsumeAgentStreamWaitsForRootRunEnd(t *testing.T) { - input := encodeFrames(t, - sessionOpenedFrame("root"), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnStart, aop.TurnStartData{})), - aopFrame(aopTestEvent("child", "child-turn", aop.TypeTurnStart, aop.TurnStartData{})), - aopFrame(aopTestEvent("child", "child-turn", aop.TypeTurnEnd, aop.TurnEndData{Stop: "completed"})), - aopFrame(aopMessageEvent("root", "turn-1", "assistant", "root done")), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnEnd, aop.TurnEndData{Stop: "completed"})), - ) - output, events, err := consumeAgentStream(input, nil) - if err != nil || output != "root done" || len(events) != 5 { - t.Fatalf("output=%q events=%d err=%v", output, len(events), err) - } -} - -func TestConsumeAgentStreamReportsRootError(t *testing.T) { - input := encodeFrames(t, - sessionOpenedFrame("root"), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnStart, aop.TurnStartData{})), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeError, aop.ErrorData{Message: "provider failed"})), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnEnd, aop.TurnEndData{Stop: "error", Error: "provider failed"})), - ) - _, events, err := consumeAgentStream(input, nil) - if err == nil || !strings.Contains(err.Error(), "provider failed") || len(events) != 3 { - t.Fatalf("events=%d error=%v", len(events), err) - } -} - -func TestConsumeAgentStreamReportsProtocolError(t *testing.T) { - input := encodeFrames(t, webproto.Message{ - Type: webproto.TypeError, - Payload: webproto.MustJSON(webproto.ErrorPayload{Message: "session rejected"}), - }) - _, _, err := consumeAgentStream(input, nil) - if err == nil || !strings.Contains(err.Error(), "session rejected") { - t.Fatalf("error = %v", err) - } -} - -func TestConsumeAgentStreamRejectsInvalidStreams(t *testing.T) { - tests := []struct { - name string - input *bytes.Buffer - needle string - }{ - { - name: "invalid envelope", - input: encodeFrames(t, - sessionOpenedFrame("root"), - webproto.Message{Type: webproto.TypeAOP, Payload: webproto.MustJSON(map[string]any{"type": "text"})}, - ), - needle: "invalid AOP envelope", - }, - { - name: "missing run terminal", - input: encodeFrames(t, - sessionOpenedFrame("root"), - aopFrame(aopTestEvent("root", "turn-1", aop.TypeTurnStart, aop.TurnStartData{})), - aopFrame(aopMessageEvent("root", "turn-1", "assistant", "hello")), - ), - needle: "without run turn.end", - }, - { - name: "no opened session", - input: encodeFrames(t), - needle: "without session.opened", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, _, err := consumeAgentStream(tt.input, nil) - if err == nil || !strings.Contains(err.Error(), tt.needle) { - t.Fatalf("error = %v, want containing %q", err, tt.needle) - } - }) - } -} - -func aopTestEvent(sessionID, turnID, eventType string, data any) aop.Event { - raw, _ := json.Marshal(data) - return aop.Event{ - Type: eventType, - TS: "2026-07-19T00:00:00Z", - SessionID: sessionID, - TurnID: turnID, - Agent: "aiscan", - Data: raw, - } -} - -func aopMessageEvent(sessionID, turnID, role, text string) aop.Event { - return aopTestEvent(sessionID, turnID, aop.TypeMessage, aop.MessageData{ - MessageID: "m-1", - Role: role, - Parts: []aop.MessagePart{{Type: aop.PartText, Text: text}}, - }) -} - -func sessionOpenedFrame(sessionID string) webproto.Message { - return webproto.Message{ - Type: webproto.TypeSessionOpened, - Payload: webproto.MustJSON(webproto.SessionLifecyclePayload{SessionID: sessionID}), - } -} - -func aopFrame(event aop.Event) webproto.Message { - return webproto.Message{Type: webproto.TypeAOP, TurnID: event.TurnID, Payload: webproto.MustJSON(event)} -} - -func encodeFrames(t *testing.T, messages ...webproto.Message) *bytes.Buffer { - t.Helper() - var output bytes.Buffer - encoder := json.NewEncoder(&output) - for _, message := range messages { - if err := encoder.Encode(message); err != nil { - t.Fatal(err) - } - } - return &output -} diff --git a/core/harness/verify.go b/core/harness/verify.go deleted file mode 100644 index c903d22c..00000000 --- a/core/harness/verify.go +++ /dev/null @@ -1,278 +0,0 @@ -//go:build e2e - -package harness - -import ( - "fmt" - "strings" - "testing" - "time" -) - -// Verifier provides chainable assertions on a RunResult. -// Accumulates all failures; Done() reports them together. -// -// Two verification layers: -// -// Layer 1 — Structural (tool-level): -// -// Verify(t, r). -// OK(). -// Expect(Tool("bash").ArgContains("gogo").NoError()). -// Expect(Tool("subagent").Action("create").Arg("name", "worker")). -// Done() -// -// Layer 2 — Intent (outcome-level): -// -// Verify(t, r). -// OK(). -// ExpectInOrder( -// Tool("subagent").Action("create").Arg("name", "worker"), -// Tool("bash").ArgContains("scan"), -// ). -// OutputContains("worker"). -// NoToolErrors(). -// MaxTurns(5). -// Done() -type Verifier struct { - t *testing.T - r *RunResult - failures []string -} - -func Verify(t *testing.T, r *RunResult) *Verifier { - t.Helper() - return &Verifier{t: t, r: r} -} - -func (v *Verifier) fail(msg string) { v.failures = append(v.failures, msg) } - -func (v *Verifier) Done() { - v.t.Helper() - if len(v.failures) == 0 { - return - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("verification failed (%d issue(s)):\n", len(v.failures))) - for i, f := range v.failures { - sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, f)) - } - sb.WriteString(fmt.Sprintf("\nresult: exit=%d turns=%d tools=%d duration=%s\n", - v.r.ExitCode, v.r.Turns(), len(v.r.ToolCalls()), v.r.Duration)) - sb.WriteString(fmt.Sprintf("tool sequence: %v\n", v.r.ToolCallSequence())) - v.t.Fatal(sb.String()) -} - -// ===================================================================== -// Exit / Output -// ===================================================================== - -func (v *Verifier) OK() *Verifier { - if !v.r.OK() { - v.fail(fmt.Sprintf("exit code %d, expected 0\nstderr: %s", v.r.ExitCode, clip(v.r.Stderr, 500))) - } - return v -} - -func (v *Verifier) OutputContains(substr string) *Verifier { - if !v.r.ContainsOutput(substr) { - v.fail(fmt.Sprintf("output missing %q", substr)) - } - return v -} - -func (v *Verifier) OutputMissing(substr string) *Verifier { - if v.r.ContainsOutput(substr) { - v.fail(fmt.Sprintf("output should not contain %q", substr)) - } - return v -} - -// ===================================================================== -// Constraints -// ===================================================================== - -func (v *Verifier) MinTurns(n int) *Verifier { - if v.r.Turns() < n { - v.fail(fmt.Sprintf("expected >= %d turns, got %d", n, v.r.Turns())) - } - return v -} - -func (v *Verifier) MaxTurns(n int) *Verifier { - if v.r.Turns() > n { - v.fail(fmt.Sprintf("expected <= %d turns, got %d", n, v.r.Turns())) - } - return v -} - -func (v *Verifier) MinToolCalls(n int) *Verifier { - if len(v.r.ToolCalls()) < n { - v.fail(fmt.Sprintf("expected >= %d tool calls, got %d", n, len(v.r.ToolCalls()))) - } - return v -} - -func (v *Verifier) MaxToolCalls(n int) *Verifier { - if len(v.r.ToolCalls()) > n { - v.fail(fmt.Sprintf("expected <= %d tool calls, got %d", n, len(v.r.ToolCalls()))) - } - return v -} - -func (v *Verifier) CompletedWithin(d time.Duration) *Verifier { - if v.r.Duration > d { - v.fail(fmt.Sprintf("expected completion within %s, took %s", d, v.r.Duration)) - } - return v -} - -func (v *Verifier) ToolCount(name string, min, max int) *Verifier { - n := len(v.r.ToolCallsNamed(name)) - if n < min || n > max { - v.fail(fmt.Sprintf("tool %q called %d times, expected [%d, %d]", name, n, min, max)) - } - return v -} - -func (v *Verifier) ToolUsed(name string) *Verifier { - if !v.r.HasToolCall(name) { - v.fail(fmt.Sprintf("tool %q was not used", name)) - } - return v -} - -func (v *Verifier) ToolArgMatch(name string, match func(string) bool) *Verifier { - for _, call := range v.r.ToolCallsNamed(name) { - if match(argsText(call.Args())) { - return v - } - } - v.fail(fmt.Sprintf("no %q tool arguments matched", name)) - return v -} - -func (v *Verifier) ToolResultMatch(name string, match func(string) bool) *Verifier { - for _, call := range v.r.ToolCallsNamed(name) { - if match(call.ResultText()) { - return v - } - } - v.fail(fmt.Sprintf("no %q tool result matched", name)) - return v -} - -func (v *Verifier) AnyResultContains(substr string) *Verifier { - if !strings.Contains(v.r.AllToolResults(), substr) { - v.fail(fmt.Sprintf("no tool result contains %q", substr)) - } - return v -} - -// ===================================================================== -// Expect — pattern-based tool call verification -// ===================================================================== - -// Expect verifies that each pattern matches at least one tool call (any order). -func (v *Verifier) Expect(patterns ...ToolPattern) *Verifier { - result := matchUnordered(patterns, v.r.ToolCalls()) - for _, p := range result.unmatched { - v.fail(fmt.Sprintf("expected tool call not found: %s", p.describe())) - } - return v -} - -// ExpectInOrder verifies that patterns match tool calls in sequence -// (subsequence — other calls may appear between them). -func (v *Verifier) ExpectInOrder(patterns ...ToolPattern) *Verifier { - result := matchOrdered(patterns, v.r.ToolCalls()) - if len(result.unmatched) > 0 { - var descs []string - for _, p := range result.unmatched { - descs = append(descs, p.describe()) - } - v.fail(fmt.Sprintf("tool call sequence incomplete, unmatched: [%s]\nactual: %v", - strings.Join(descs, ", "), v.r.ToolCallSequence())) - } - return v -} - -// ExpectNone verifies that NO tool call matches the pattern. -func (v *Verifier) ExpectNone(patterns ...ToolPattern) *Verifier { - for _, p := range patterns { - for _, e := range v.r.ToolCalls() { - if p.Match(e) { - v.fail(fmt.Sprintf("unexpected tool call matched: %s", p.describe())) - break - } - } - } - return v -} - -// ===================================================================== -// Errors -// ===================================================================== - -func (v *Verifier) NoToolErrors() *Verifier { - errs := v.r.ErroredToolCalls() - if len(errs) > 0 { - names := make([]string, len(errs)) - for i, e := range errs { - names[i] = fmt.Sprintf("%s(%s)", e.Name(), clip(e.ResultText(), 80)) - } - v.fail(fmt.Sprintf("%d tool call(s) errored: %s", len(errs), strings.Join(names, ", "))) - } - return v -} - -// ===================================================================== -// Subagent shortcuts (built on Expect) -// ===================================================================== - -func (v *Verifier) SubagentCreated(name string) *Verifier { - return v.Expect(Tool("subagent").Arg("name", name)) -} - -func (v *Verifier) MinSubagentCreates(n int) *Verifier { - if v.r.SubagentCreateCount() < n { - v.fail(fmt.Sprintf("expected >= %d subagent creates, got %d", n, v.r.SubagentCreateCount())) - } - return v -} - -func (v *Verifier) SubagentResultContains(substr string) *Verifier { - for _, res := range v.r.SubagentResults() { - if strings.Contains(res, substr) { - return v - } - } - v.fail(fmt.Sprintf("no subagent result contains %q", substr)) - return v -} - -// ===================================================================== -// LLM Judge -// ===================================================================== - -// JudgeWith uses an LLM to evaluate whether the execution fulfilled the -// intent. The judge receives the full tool trace and final output, and -// returns a structured verdict. -// -// Verify(t, r). -// OK(). -// JudgeWith(h.Judge(), "create a loop, list it, delete it", ""). -// Done() -func (v *Verifier) JudgeWith(j *Judge, intent, criteria string) *Verifier { - verdict, err := j.Evaluate(intent, criteria, v.r) - if err != nil { - v.t.Logf("judge unavailable (degraded to warning): %s", err) - return v - } - v.t.Logf("judge: pass=%v score=%d reason=%q", verdict.Pass, verdict.Score, verdict.Reason) - if !verdict.Pass { - issues := strings.Join(verdict.Issues, "; ") - v.fail(fmt.Sprintf("judge failed (score=%d): %s [%s]", verdict.Score, verdict.Reason, issues)) - } - return v -} diff --git a/core/output/format.go b/core/output/format.go index 53de61cf..8cd3d6af 100644 --- a/core/output/format.go +++ b/core/output/format.go @@ -4,7 +4,7 @@ import ( "regexp" "strings" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) var ansiPattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[PX^_].*?\x1b\\|[@-_])`) diff --git a/core/output/format_asset.go b/core/output/format_asset.go index 25f1c3a9..4d541f9f 100644 --- a/core/output/format_asset.go +++ b/core/output/format_asset.go @@ -1,288 +1,23 @@ package output import ( - "fmt" "net/url" - "sort" "strconv" "strings" ) +// FormatAssetReport renders the terminal asset report. The sitemap tree is the +// terminal report's own feature; everything else comes from the shared +// renderer in report.go. func FormatAssetReport(result *Result, color bool) string { - if result == nil { - return "Assets: 0 total\n" - } - c := NewColor(color) - - var sb strings.Builder - fmt.Fprintf(&sb, "Assets: %d total\n", len(result.Assets)) - fmt.Fprintf(&sb, "Summary: %d target(s), %d service(s), %d web endpoint(s), %d probe(s), %d loot(s), %d error(s), %s\n\n", - result.Summary.Targets, - result.Summary.Services, - result.Summary.Webs, - result.Summary.Probes, - result.Summary.Loots, - result.Summary.Errors, - result.Summary.Duration, - ) - - if len(result.Assets) == 0 { - return sb.String() - } - for i, asset := range result.Assets { - title := FirstNonEmpty(asset.Title, asset.Target, asset.Key) - fmt.Fprintf(&sb, "%d. %s\n", i+1, c.GreenBold(title)) - if asset.Target != "" && asset.Target != title { - fmt.Fprintf(&sb, " target: %s\n", asset.Target) - } - if asset.Status != "" { - fmt.Fprintf(&sb, " status: %s\n", asset.Status) - } - writeAssetTopItems(&sb, asset.Items, c) - writeAssetSitemap(&sb, asset, c) - if i < len(result.Assets)-1 { - sb.WriteByte('\n') - } - } - return sb.String() -} - -func writeAssetTopItems(sb *strings.Builder, items []AssetItem, c Color) { - for _, item := range items { - switch item.Kind { - case AssetItemPath: - continue - case AssetItemService: - line := strings.Join(CompactStrings( - AssetDataString(item.Data, "protocol"), - AssetDataString(item.Data, "service"), - AssetDataString(item.Data, "port"), - ), " ") - if line == "" { - line = FirstNonEmpty(item.Title, item.Target, item.Raw) - } - fmt.Fprintf(sb, " %s %s\n", c.Cyan("service:"), line) - case AssetItemFingerprint: - name := FirstNonEmpty(item.Title, item.Summary, item.Target) - fmt.Fprintf(sb, " %s %s\n", c.Cyan("fingerprint:"), name) - case AssetItemLoot, AssetItemNote, AssetItemResponse: - detail := AssetItemDetail(item) - line := FirstNonEmpty(item.Summary, item.Title, firstContentLine(detail), item.Raw) - if item.Status != "" { - line = c.Yellow("["+item.Status+"]") + " " + line - } - label := FirstNonEmpty(item.Source, item.Kind) - fmt.Fprintf(sb, " %s %s\n", c.Yellow(label+":"), line) - if detail != "" && detail != line && !strings.Contains(line, detail) { - for _, dl := range strings.Split(strings.TrimSpace(detail), "\n") { - if dl = strings.TrimSpace(dl); dl != "" { - fmt.Fprintf(sb, " %s\n", c.Dim(dl)) - } - } - } - case AssetItemError: - fmt.Fprintf(sb, " %s %s\n", c.Red("error:"), item.Summary) - } - } -} - -// --- sitemap rendering --- - -type sitemapEntry struct { - path string - status string - length int - title string - fingers []string - validated bool -} - -type sitemapNode struct { - segment string - status string - length int - title string - fingers []string - validated bool - isLeaf bool - annotations []string - children []*sitemapNode -} - -func writeAssetSitemap(sb *strings.Builder, asset Asset, c Color) { - var entries []sitemapEntry - for _, item := range asset.Items { - if item.Kind != AssetItemPath { - continue - } - p := FirstNonEmpty(AssetDataString(item.Data, "path"), WebPath(item.Target), item.Target) - if p == "" { - continue - } - entries = append(entries, sitemapEntry{ - path: p, - status: item.Status, - length: AssetDataInt(item.Data, "length"), - title: item.Title, - fingers: AssetDataStrings(item.Data, "fingers"), - validated: HasTag(item.Tags, "validated"), - }) - } - if len(entries) == 0 { - return - } - - sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path }) - - sb.WriteString(" sitemap:\n") - root := buildSitemapTree(entries) - attachAnnotations(root, collectAnnotations(asset)) - renderNode(sb, root, " ", true, c) -} - -func buildSitemapTree(entries []sitemapEntry) *sitemapNode { - root := &sitemapNode{segment: "/"} - for _, e := range entries { - parts := splitPath(e.path) - if len(parts) == 0 { - root.isLeaf = true - root.status = e.status - root.length = e.length - root.title = e.title - root.fingers = mergeStrings(root.fingers, e.fingers) - root.validated = root.validated || e.validated - continue - } - node := root - for i, part := range parts { - child := findChild(node, part) - if child == nil { - child = &sitemapNode{segment: part} - node.children = append(node.children, child) - } - if i == len(parts)-1 { - child.isLeaf = true - child.status = e.status - child.length = e.length - child.title = e.title - child.fingers = mergeStrings(child.fingers, e.fingers) - child.validated = child.validated || e.validated - } - node = child - } - } - return root -} - -func collectAnnotations(asset Asset) map[string][]string { - out := make(map[string][]string) - for _, item := range asset.Items { - switch item.Kind { - case AssetItemFingerprint: - p := pathFromTarget(item.Target, asset.Target) - if p != "" { - out[p] = appendUniq(out[p], item.Title) - } - case AssetItemLoot, AssetItemNote, AssetItemResponse: - p := pathFromTarget(item.Target, asset.Target) - if p == "" { - p = "/" - } - skill := FirstNonEmpty(item.Source, item.Kind) - label := skill - if item.Status != "" { - label += ":" + item.Status - } - summary := FirstNonEmpty(item.Title, item.Summary) - if summary != "" && len(summary) <= 40 { - label += " " + summary - } - out[p] = appendUniq(out[p], label) - } - } - return out -} - -func attachAnnotations(root *sitemapNode, anns map[string][]string) { - if a, ok := anns["/"]; ok { - root.annotations = append(root.annotations, a...) - } - for path, a := range anns { - if path == "/" { - continue - } - parts := splitPath(path) - node := root - for _, part := range parts { - child := findChild(node, part) - if child == nil { - child = &sitemapNode{segment: part, isLeaf: true} - node.children = append(node.children, child) - } - node = child - } - node.annotations = append(node.annotations, a...) - } + return RenderReport(result, ReportOptions{ + Style: StyleANSI, + Color: color, + Sitemap: true, + }) } -func renderNode(sb *strings.Builder, node *sitemapNode, indent string, isRoot bool, c Color) { - var line strings.Builder - - if isRoot { - line.WriteString(indent) - } else { - line.WriteString(indent) - line.WriteString("├── ") - } - - if node.isLeaf && node.status != "" { - line.WriteString(c.Status(fmt.Sprintf("[%-3s]", node.status))) - } else { - line.WriteString(" ") - } - line.WriteString(" ") - - path := "/" + node.segment - if isRoot { - path = "/" - } - if node.validated { - line.WriteString(c.GreenBold(path)) - } else if node.isLeaf { - line.WriteString(path) - } else { - line.WriteString(c.Dim(path)) - } - - if node.isLeaf && node.length > 0 { - line.WriteString(" " + c.YellowBold(fmt.Sprintf("%d", node.length))) - } - - if node.title != "" && !isStaticTitle(node.title) { - line.WriteString(" " + c.Green(strconv.Quote(node.title))) - } - - if len(node.fingers) > 0 { - line.WriteString(" " + c.Cyan("["+strings.Join(node.fingers, ",")+"]")) - } - - for _, ann := range node.annotations { - line.WriteString(" " + c.Yellow("{"+ann+"}")) - } - - sb.WriteString(line.String()) - sb.WriteByte('\n') - - for _, child := range node.children { - childIndent := indent - if !isRoot { - childIndent += "│ " - } - renderNode(sb, child, childIndent, false, c) - } -} - -// --- shared helpers --- +// --- shared asset helpers --- func WebPath(rawURL string) string { parsed, err := url.Parse(strings.TrimSpace(rawURL)) @@ -381,74 +116,3 @@ func AssetDataStrings(data map[string]any, key string) []string { return nil } } - -func findChild(node *sitemapNode, segment string) *sitemapNode { - for _, c := range node.children { - if c.segment == segment { - return c - } - } - return nil -} - -func splitPath(p string) []string { - p = strings.Trim(p, "/") - if p == "" { - return nil - } - parts := strings.Split(p, "/") - if idx := strings.Index(parts[len(parts)-1], "?"); idx >= 0 { - parts[len(parts)-1] = parts[len(parts)-1][:idx] - } - return parts -} - -func pathFromTarget(target, assetTarget string) string { - if target == "" { - return "" - } - p := WebPath(target) - if p == target && assetTarget != "" { - if strings.HasPrefix(target, assetTarget) { - p = strings.TrimPrefix(target, assetTarget) - if p == "" { - p = "/" - } - } - } - return p -} - -func isStaticTitle(title string) bool { - switch strings.ToLower(title) { - case "js data", "css data", "ico data", "image data": - return true - } - return false -} - -func mergeStrings(a, b []string) []string { - if len(b) == 0 { - return a - } - seen := make(map[string]struct{}, len(a)) - for _, s := range a { - seen[strings.ToLower(s)] = struct{}{} - } - for _, s := range b { - if _, ok := seen[strings.ToLower(s)]; !ok { - a = append(a, s) - seen[strings.ToLower(s)] = struct{}{} - } - } - return a -} - -func appendUniq(slice []string, val string) []string { - for _, s := range slice { - if s == val { - return slice - } - } - return append(slice, val) -} diff --git a/core/output/report.go b/core/output/report.go new file mode 100644 index 00000000..12dd6d3b --- /dev/null +++ b/core/output/report.go @@ -0,0 +1,854 @@ +package output + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +// ReportStyle selects the emitter RenderReport hands the neutral model to. +type ReportStyle uint8 + +const ( + // StyleANSI is the operator-facing terminal report. + StyleANSI ReportStyle = iota + // StyleMarkdown is the report shipped to the web UI and to tool output. + StyleMarkdown +) + +// ReportOptions is the single knob set behind every asset report. The three +// renderers this replaced each owned a feature nobody else had (the sitemap +// tree, zh/en text + bare-host folding, the counter table), so the flags are +// what a caller opts into rather than what a style implies. +type ReportOptions struct { + Style ReportStyle + // Color enables ANSI escapes. StyleMarkdown ignores it — markdown output + // is never colorized. + Color bool + // Lang is "zh" or "en" (anything else, including "", means "en"). + // StyleANSI is English-only, so it ignores this. + Lang string + // Title is the report subject: the scan target for a web job, or a plain + // report name. Mode is the scan mode ("quick" / "full"); leaving Mode empty + // suppresses the target/mode/timestamp line and makes Title the bare H1. + // Markdown only. + Title string + Mode string + // Sitemap renders the per-asset path tree. + Sitemap bool + // CollapseBare folds live hosts that answered with nothing but non-web + // services into a trailing list instead of giving each one a section. + // Markdown only. + CollapseBare bool + // Metrics adds the counter table. Markdown only. + Metrics bool + // Inventory adds the flat per-kind sections (services / web evidence / + // findings / errors) the scan tool report carries. Markdown only. + Inventory bool +} + +// RenderReport walks Result → Asset → AssetItem exactly once into a neutral +// model, then emits it in the requested style. +func RenderReport(result *Result, opts ReportOptions) string { + model := buildReportModel(result, opts) + var report string + if opts.Style == StyleMarkdown { + report = renderMarkdownReport(model, opts) + } else { + report = renderANSIReport(model, opts) + } + return strings.TrimRight(report, " \t\r\n") + "\n" +} + +// --- neutral model --- + +type reportModel struct { + nilResult bool + summary Summary + total int + hosts int + fingers int + assets []reportAsset + bare []reportAsset +} + +type reportAsset struct { + title string // Title > Target > Key — the headline + label string // Target > Title > Key — the bare-host list entry + target string + status string + paths int + services []string + statuses []string + fingers []string + items []reportItem + sitemap *sitemapNode + isBare bool +} + +// reportItem is the per-item extraction — the part that used to exist in three +// places. text is the one-line rendering, name the short label used where a +// full line will not fit (sitemap annotations). +type reportItem struct { + kind string + label string // note-like items: Source > Kind + status string + target string + text string + name string + detail string + length int + fingers []string + validated bool +} + +func buildReportModel(result *Result, opts ReportOptions) reportModel { + if result == nil { + return reportModel{nilResult: true} + } + model := reportModel{summary: result.Summary, total: len(result.Assets)} + + hosts := make(map[string]struct{}) + fingers := make(map[string]struct{}) + for _, asset := range result.Assets { + item := buildReportAsset(asset, opts.Sitemap) + if host := reportAssetHost(asset); host != "" { + hosts[host] = struct{}{} + } + for _, finger := range item.fingers { + fingers[strings.ToLower(finger)] = struct{}{} + } + if opts.CollapseBare && item.isBare { + model.bare = append(model.bare, item) + continue + } + model.assets = append(model.assets, item) + } + + // An asset whose target parses to nothing still counts as a host. + model.hosts = len(hosts) + if model.hosts == 0 { + model.hosts = len(result.Assets) + } + model.fingers = len(fingers) + return model +} + +func buildReportAsset(asset Asset, sitemap bool) reportAsset { + out := reportAsset{ + title: FirstNonEmpty(asset.Title, asset.Target, asset.Key), + label: FirstNonEmpty(asset.Target, asset.Title, asset.Key), + target: asset.Target, + status: asset.Status, + } + + var services, statuses, fingers []string + annotations := make(map[string][]string) + hasService, onlyPlainServices := false, true + + for _, item := range asset.Items { + entry := reportItem{kind: item.Kind, status: item.Status, target: item.Target} + switch item.Kind { + case AssetItemService: + hasService = true + facts := strings.Join(CompactStrings( + AssetDataString(item.Data, "protocol"), + AssetDataString(item.Data, "service"), + AssetDataString(item.Data, "port"), + ), " ") + services = append(services, facts) + // A service with no structured facts still has a name to show. + entry.text = FirstNonEmpty(facts, item.Title, item.Target, item.Raw) + if isWebServiceItem(item) { + onlyPlainServices = false + } + case AssetItemFingerprint: + onlyPlainServices = false + entry.text = FirstNonEmpty(item.Title, item.Summary, AssetDataString(item.Data, "name"), item.Target) + entry.name = entry.text + fingers = append(fingers, entry.text) + if path := pathFromTarget(item.Target, asset.Target); path != "" { + annotations[path] = appendUniq(annotations[path], entry.text) + } + case AssetItemPath: + onlyPlainServices = false + out.paths++ + entry.text = FirstNonEmpty(AssetDataString(item.Data, "path"), WebPath(item.Target), item.Target) + entry.name = item.Title + entry.length = AssetDataInt(item.Data, "length") + entry.fingers = AssetDataStrings(item.Data, "fingers") + entry.validated = HasTag(item.Tags, "validated") + fingers = append(fingers, entry.fingers...) + if item.Status != "" { + statuses = append(statuses, item.Status) + } + case AssetItemLoot, AssetItemNote, AssetItemResponse, AssetItemError: + onlyPlainServices = false + entry.label = FirstNonEmpty(item.Source, item.Kind) + entry.detail = AssetItemDetail(item) + entry.text = FirstNonEmpty(item.Summary, item.Title, firstContentLine(entry.detail), item.Raw) + entry.name = FirstNonEmpty(item.Title, item.Summary) + if item.Kind != AssetItemError { + path := lootAnnotationPath(item, asset.Target) + annotations[path] = appendUniq(annotations[path], lootAnnotation(entry)) + } + default: + onlyPlainServices = false + entry.text = FirstNonEmpty(item.Summary, item.Title, item.Raw) + } + out.items = append(out.items, entry) + } + + out.isBare = hasService && onlyPlainServices + out.services = CompactStrings(services...) + out.statuses = CompactStrings(statuses...) + out.fingers = CompactStrings(fingers...) + if sitemap { + out.sitemap = buildSitemapTree(out.items, annotations) + } + return out +} + +// isWebServiceItem reports whether a service item is an HTTP-ish one, which is +// what keeps its host out of the "bare live host" bucket. +func isWebServiceItem(item AssetItem) bool { + svc := strings.ToLower(AssetDataString(item.Data, "service") + " " + AssetDataString(item.Data, "protocol")) + return strings.Contains(svc, "http") +} + +func lootAnnotationPath(item AssetItem, assetTarget string) string { + if path := pathFromTarget(item.Target, assetTarget); path != "" { + return path + } + return "/" +} + +// lootAnnotation is the compact "{skill:status summary}" tag hung off a +// sitemap node. Long summaries are dropped rather than wrapped. +func lootAnnotation(entry reportItem) string { + label := entry.label + if entry.status != "" { + label += ":" + entry.status + } + if entry.name != "" && len(entry.name) <= 40 { + label += " " + entry.name + } + return label +} + +// reportAssetHost reduces an asset to its host, so an IP that answered on both +// icmp and http counts once. +func reportAssetHost(asset Asset) string { + value := FirstNonEmpty(asset.Target, asset.Key, asset.Title) + if i := strings.Index(value, "://"); i >= 0 { + value = value[i+3:] + } + if i := strings.IndexAny(value, "/?#"); i >= 0 { + value = value[:i] + } + if strings.Count(value, ":") == 1 { // host:port — drop the port, leave IPv6 alone + value = value[:strings.LastIndex(value, ":")] + } + return value +} + +// --- ANSI emitter --- + +func renderANSIReport(model reportModel, opts ReportOptions) string { + if model.nilResult { + return "Assets: 0 total\n" + } + c := NewColor(opts.Color) + + var sb strings.Builder + fmt.Fprintf(&sb, "Assets: %d total\n", model.total) + fmt.Fprintf(&sb, "Summary: %d target(s), %d service(s), %d web endpoint(s), %d probe(s), %d loot(s), %d error(s), %s\n\n", + model.summary.Targets, + model.summary.Services, + model.summary.Webs, + model.summary.Probes, + model.summary.Loots, + model.summary.Errors, + model.summary.Duration, + ) + if model.total == 0 { + return sb.String() + } + + for i, asset := range model.assets { + fmt.Fprintf(&sb, "%d. %s\n", i+1, c.GreenBold(asset.title)) + if asset.target != "" && asset.target != asset.title { + fmt.Fprintf(&sb, " target: %s\n", asset.target) + } + if asset.status != "" { + fmt.Fprintf(&sb, " status: %s\n", asset.status) + } + for _, item := range asset.items { + writeANSIItem(&sb, item, c) + } + if asset.sitemap != nil { + sb.WriteString(" sitemap:\n") + renderSitemapNode(&sb, asset.sitemap, " ", true, c) + } + if i < len(model.assets)-1 { + sb.WriteByte('\n') + } + } + return sb.String() +} + +func writeANSIItem(sb *strings.Builder, item reportItem, c Color) { + switch item.kind { + case AssetItemPath: + return + case AssetItemService: + fmt.Fprintf(sb, " %s %s\n", c.Cyan("service:"), item.text) + case AssetItemFingerprint: + fmt.Fprintf(sb, " %s %s\n", c.Cyan("fingerprint:"), item.text) + case AssetItemLoot, AssetItemNote, AssetItemResponse: + line := item.text + if item.status != "" { + line = c.Yellow("["+item.status+"]") + " " + line + } + fmt.Fprintf(sb, " %s %s\n", c.Yellow(item.label+":"), line) + if item.detail != "" && item.detail != line && !strings.Contains(line, item.detail) { + for _, detailLine := range strings.Split(strings.TrimSpace(item.detail), "\n") { + if detailLine = strings.TrimSpace(detailLine); detailLine != "" { + fmt.Fprintf(sb, " %s\n", c.Dim(detailLine)) + } + } + } + case AssetItemError: + fmt.Fprintf(sb, " %s %s\n", c.Red("error:"), item.text) + } +} + +// --- markdown emitter --- + +// reportLang is the whole i18n surface: one flag, one lookup. +type reportLang struct{ zh bool } + +func newReportLang(lang string) reportLang { + return reportLang{zh: strings.HasPrefix(strings.ToLower(lang), "zh")} +} + +func (t reportLang) tr(zh, en string) string { + if t.zh { + return zh + } + return en +} + +func (t reportLang) sep() string { return t.tr(":", ": ") } + +func (t reportLang) modeName(mode string) string { + if strings.EqualFold(mode, "full") { + return t.tr("全面侦察", "Full recon") + } + return t.tr("快速侦察", "Quick recon") +} + +// renderMarkdownReport writes an operator-facing report: prose instead of a +// metric dump, no internal scanner names leaking into the text. +func renderMarkdownReport(model reportModel, opts ReportOptions) string { + t := newReportLang(opts.Lang) + + var sb strings.Builder + writeMarkdownHeader(&sb, t, opts) + if model.nilResult { + sb.WriteString(t.tr("本次扫描未返回结构化结果。\n", "No structured result was returned.\n")) + return sb.String() + } + + sb.WriteString("## " + t.tr("概述", "Overview") + "\n\n") + var overview strings.Builder + writeMarkdownOverview(&overview, t, model) + sb.WriteString(strings.TrimSpace(overview.String())) + sb.WriteString("\n\n") + + if opts.Metrics { + writeMarkdownMetrics(&sb, t, model) + } + if len(model.assets) > 0 { + sb.WriteString("## " + t.tr("资产明细", "Assets") + "\n\n") + for _, asset := range model.assets { + writeMarkdownAsset(&sb, t, asset, opts) + } + } + if len(model.bare) > 0 { + sb.WriteString("## " + t.tr("其他存活主机", "Other live hosts") + "\n\n") + for _, asset := range model.bare { + if len(asset.services) > 0 { + fmt.Fprintf(&sb, "- `%s` · %s\n", asset.label, strings.Join(asset.services, ", ")) + continue + } + fmt.Fprintf(&sb, "- `%s`\n", asset.label) + } + sb.WriteString("\n") + } + if opts.Inventory { + writeMarkdownInventory(&sb, t, model) + } + return sb.String() +} + +func writeMarkdownHeader(sb *strings.Builder, t reportLang, opts ReportOptions) { + if opts.Mode == "" { + fmt.Fprintf(sb, "# %s\n\n", FirstNonEmpty(opts.Title, t.tr("侦察报告", "Recon report"))) + sb.WriteString("---\n\n") + return + } + fmt.Fprintf(sb, "# %s%s\n\n", t.tr("侦察报告 · ", "Recon report · "), FirstNonEmpty(opts.Title, t.tr("目标", "target"))) + fmt.Fprintf(sb, "%s `%s` · %s · %s\n\n", + t.tr("目标", "Target"), opts.Title, + t.modeName(opts.Mode), + time.Now().Format("2006-01-02 15:04:05")) + sb.WriteString("---\n\n") +} + +// writeMarkdownOverview is the executive summary — one flowing paragraph that +// names only the numbers actually present, so a clean scan reads like a +// sentence rather than a table full of zeros. +func writeMarkdownOverview(sb *strings.Builder, t reportLang, model reportModel) { + s := model.summary + if t.zh { + fmt.Fprintf(sb, "本次侦察共识别 %d 台主机、%d 个开放服务", model.hosts, s.Services) + if s.Webs > 0 { + fmt.Fprintf(sb, "(含 %d 个 Web 站点)", s.Webs) + } + sb.WriteString("。") + if s.Probes > 0 { + fmt.Fprintf(sb, "累计探测 %d 条路径", s.Probes) + if model.fingers > 0 { + fmt.Fprintf(sb, "、命中 %d 项 Web 指纹", model.fingers) + } + sb.WriteString("。") + } else if model.fingers > 0 { + fmt.Fprintf(sb, "命中 %d 项 Web 指纹。", model.fingers) + } + if s.Loots > 0 { + fmt.Fprintf(sb, "**发现 %d 项需优先复核的安全发现(凭证 / 弱口令 / 漏洞)。**", s.Loots) + } + if s.Errors > 0 { + fmt.Fprintf(sb, "另有 %d 处探测报错。", s.Errors) + } + if s.Duration != "" { + fmt.Fprintf(sb, "全程耗时 %s。", s.Duration) + } + return + } + + fmt.Fprintf(sb, "The scan identified %s across %s", reportPlural(model.hosts, "host", "hosts"), reportPlural(s.Services, "open service", "open services")) + if s.Webs > 0 { + fmt.Fprintf(sb, " (%s)", reportPlural(s.Webs, "web site", "web sites")) + } + sb.WriteString(". ") + if s.Probes > 0 { + fmt.Fprintf(sb, "It probed %s", reportPlural(s.Probes, "path", "paths")) + if model.fingers > 0 { + fmt.Fprintf(sb, " and matched %s", reportPlural(model.fingers, "fingerprint", "fingerprints")) + } + sb.WriteString(". ") + } else if model.fingers > 0 { + fmt.Fprintf(sb, "It matched %s. ", reportPlural(model.fingers, "fingerprint", "fingerprints")) + } + if s.Loots > 0 { + fmt.Fprintf(sb, "**%s surfaced (credentials / weak passwords / vulnerabilities) — review these first.** ", reportPlural(s.Loots, "security finding", "security findings")) + } + if s.Errors > 0 { + fmt.Fprintf(sb, "%s occurred during probing. ", reportPlural(s.Errors, "error", "errors")) + } + if s.Duration != "" { + fmt.Fprintf(sb, "The scan took %s.", s.Duration) + } +} + +func writeMarkdownMetrics(sb *strings.Builder, t reportLang, model reportModel) { + s := model.summary + sb.WriteString("## " + t.tr("指标", "Metrics") + "\n\n") + fmt.Fprintf(sb, "| %s | %s |\n", t.tr("指标", "Metric"), t.tr("数值", "Value")) + sb.WriteString("| --- | ---: |\n") + for _, row := range []struct { + label string + value any + }{ + {t.tr("输入目标", "Inputs"), s.Targets}, + {t.tr("开放服务", "Open services"), s.Services}, + {t.tr("Web 站点", "Web endpoints"), s.Webs}, + {t.tr("路径探测", "Web probes"), s.Probes}, + {t.tr("Web 指纹", "Fingerprints"), model.fingers}, + {t.tr("安全发现", "Loots"), s.Loots}, + {t.tr("错误", "Errors"), s.Errors}, + {t.tr("任务", "Tasks"), s.Tasks}, + {t.tr("请求", "Requests"), s.Requests}, + {t.tr("耗时", "Duration"), s.Duration}, + } { + fmt.Fprintf(sb, "| %s | %v |\n", row.label, row.value) + } + sb.WriteString("\n") +} + +func writeMarkdownAsset(sb *strings.Builder, t reportLang, asset reportAsset, opts ReportOptions) { + title := FirstNonEmpty(asset.title, t.tr("资产", "Asset")) + if asset.target != "" && asset.target != title { + fmt.Fprintf(sb, "### %s — `%s`\n\n", title, asset.target) + } else { + fmt.Fprintf(sb, "### %s\n\n", title) + } + + writeMarkdownFact(sb, t, t.tr("开放服务", "Services"), asset.services) + writeMarkdownFact(sb, t, t.tr("HTTP 响应", "HTTP"), asset.statuses) + writeMarkdownFact(sb, t, t.tr("Web 指纹", "Fingerprints"), asset.fingers) + if asset.paths > 0 { + fmt.Fprintf(sb, "- %s%s%s\n", t.tr("已探测路径", "Paths"), t.sep(), t.tr(fmt.Sprintf("%d 条", asset.paths), strconv.Itoa(asset.paths))) + } + if asset.status != "" { + fmt.Fprintf(sb, "- %s%s%s\n", t.tr("状态", "State"), t.sep(), markdownCode(asset.status)) + } + sb.WriteString("\n") + + if opts.Sitemap && asset.sitemap != nil { + sb.WriteString("#### " + t.tr("站点地图", "Sitemap") + "\n\n```text\n") + renderSitemapNode(sb, asset.sitemap, "", true, NewColor(false)) + sb.WriteString("```\n\n") + } + writeMarkdownAnalysis(sb, t, asset.items) +} + +func writeMarkdownFact(sb *strings.Builder, t reportLang, label string, values []string) { + if len(values) == 0 { + return + } + coded := make([]string, 0, len(values)) + for _, value := range values { + coded = append(coded, markdownCode(value)) + } + fmt.Fprintf(sb, "- %s%s%s\n", label, t.sep(), strings.Join(coded, t.tr("、", ", "))) +} + +func writeMarkdownAnalysis(sb *strings.Builder, t reportLang, items []reportItem) { + wrote := false + for _, item := range items { + switch item.kind { + case AssetItemLoot, AssetItemNote, AssetItemResponse, AssetItemError: + default: + continue + } + if item.text == "" { + continue + } + if !wrote { + sb.WriteString("#### " + t.tr("分析研判", "Analysis") + "\n\n") + wrote = true + } + fmt.Fprintf(sb, "##### %s\n\n", markdownHeading(item.text)) + switch { + case item.detail != "" && strings.TrimSpace(item.text) != strings.TrimSpace(item.detail): + sb.WriteString(item.detail) + sb.WriteString("\n\n") + case item.detail == "": + sb.WriteString(item.text) + sb.WriteString("\n\n") + } + } +} + +// writeMarkdownInventory is the flat cross-asset listing the scan tool report +// has always carried: every service, probe, finding and error in one place. +func writeMarkdownInventory(sb *strings.Builder, t reportLang, model reportModel) { + assets := make([]reportAsset, 0, len(model.assets)+len(model.bare)) + assets = append(assets, model.assets...) + assets = append(assets, model.bare...) + + var services, paths, findings, errors []string + for _, asset := range assets { + for _, item := range asset.items { + switch item.kind { + case AssetItemService: + services = append(services, fmt.Sprintf("- %s · %s\n", + markdownCode(FirstNonEmpty(item.target, asset.label)), item.text)) + case AssetItemPath: + paths = append(paths, "- "+strings.Join(pathInventoryParts(item), " · ")+"\n") + case AssetItemLoot, AssetItemNote, AssetItemResponse: + findings = append(findings, markdownStatusLine(findingInventoryLine(item), item.status)) + case AssetItemError: + errors = append(errors, "- "+item.text+"\n") + } + } + } + + writeMarkdownSection(sb, t.tr("开放服务", "Open Services"), services) + writeMarkdownSection(sb, t.tr("Web 证据", "Web Evidence"), paths) + writeMarkdownSection(sb, t.tr("安全发现", "Findings"), findings) + writeMarkdownSection(sb, t.tr("错误", "Errors"), errors) +} + +func pathInventoryParts(item reportItem) []string { + parts := []string{markdownCode(FirstNonEmpty(item.target, item.text))} + if item.status != "" { + parts = append(parts, markdownCode(item.status)) + } + if item.name != "" && !isStaticTitle(item.name) { + parts = append(parts, strconv.Quote(item.name)) + } + if len(item.fingers) > 0 { + parts = append(parts, markdownCode(strings.Join(item.fingers, ","))) + } + return parts +} + +func findingInventoryLine(item reportItem) string { + line := item.text + if item.target != "" { + line += " — " + markdownCode(item.target) + } + return line +} + +// markdownStatusLine carries the verification verdict into the bullet, so an +// unconfirmed finding cannot be mistaken for a proven one. +func markdownStatusLine(line, status string) string { + if line == "" { + return "" + } + switch status { + case "not_confirmed": + return "- ~~" + line + "~~ *(not confirmed)*\n" + case "confirmed": + return "- **[verified]** " + line + "\n" + case "inconclusive": + return "- **[inconclusive]** " + line + "\n" + case "failed": + return "- **[verification failed]** " + line + "\n" + default: + return "- " + line + "\n" + } +} + +func writeMarkdownSection(sb *strings.Builder, heading string, lines []string) { + if len(lines) == 0 { + return + } + sb.WriteString("## " + heading + "\n\n") + for _, line := range lines { + sb.WriteString(line) + } + sb.WriteString("\n") +} + +func markdownCode(value string) string { + value = strings.ReplaceAll(value, "`", "'") + return "`" + value + "`" +} + +func markdownHeading(value string) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "\n", " ") + if value == "" { + return "Analysis" + } + return strings.TrimLeft(value, "# ") +} + +func reportPlural(n int, one, many string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, one) + } + return fmt.Sprintf("%d %s", n, many) +} + +// --- sitemap tree --- + +type sitemapNode struct { + segment string + status string + length int + title string + fingers []string + validated bool + isLeaf bool + annotations []string + children []*sitemapNode +} + +// buildSitemapTree folds the asset's path items into a directory tree and hangs +// the fingerprint / finding annotations off the node they were found on. +// Returns nil when the asset has no paths, so callers can skip the section. +func buildSitemapTree(items []reportItem, annotations map[string][]string) *sitemapNode { + paths := make([]reportItem, 0, len(items)) + for _, item := range items { + if item.kind == AssetItemPath && item.text != "" { + paths = append(paths, item) + } + } + if len(paths) == 0 { + return nil + } + sort.Slice(paths, func(i, j int) bool { return paths[i].text < paths[j].text }) + + root := &sitemapNode{segment: "/"} + for _, item := range paths { + node := root + for _, part := range splitPath(item.text) { + child := findSitemapChild(node, part) + if child == nil { + child = &sitemapNode{segment: part} + node.children = append(node.children, child) + } + node = child + } + node.isLeaf = true + node.status = item.status + node.length = item.length + node.title = item.name + node.fingers = mergeStrings(node.fingers, item.fingers) + node.validated = node.validated || item.validated + } + attachSitemapAnnotations(root, annotations) + return root +} + +func attachSitemapAnnotations(root *sitemapNode, annotations map[string][]string) { + if values, ok := annotations["/"]; ok { + root.annotations = append(root.annotations, values...) + } + for path, values := range annotations { + if path == "/" { + continue + } + node := root + for _, part := range splitPath(path) { + child := findSitemapChild(node, part) + if child == nil { + child = &sitemapNode{segment: part, isLeaf: true} + node.children = append(node.children, child) + } + node = child + } + node.annotations = append(node.annotations, values...) + } +} + +func renderSitemapNode(sb *strings.Builder, node *sitemapNode, indent string, isRoot bool, c Color) { + var line strings.Builder + + line.WriteString(indent) + if !isRoot { + line.WriteString("├── ") + } + + if node.isLeaf && node.status != "" { + line.WriteString(c.Status(fmt.Sprintf("[%-3s]", node.status))) + } else { + line.WriteString(" ") + } + line.WriteString(" ") + + path := "/" + node.segment + if isRoot { + path = "/" + } + switch { + case node.validated: + line.WriteString(c.GreenBold(path)) + case node.isLeaf: + line.WriteString(path) + default: + line.WriteString(c.Dim(path)) + } + + if node.isLeaf && node.length > 0 { + line.WriteString(" " + c.YellowBold(strconv.Itoa(node.length))) + } + if node.title != "" && !isStaticTitle(node.title) { + line.WriteString(" " + c.Green(strconv.Quote(node.title))) + } + if len(node.fingers) > 0 { + line.WriteString(" " + c.Cyan("["+strings.Join(node.fingers, ",")+"]")) + } + for _, annotation := range node.annotations { + line.WriteString(" " + c.Yellow("{"+annotation+"}")) + } + + sb.WriteString(line.String()) + sb.WriteByte('\n') + + for _, child := range node.children { + childIndent := indent + if !isRoot { + childIndent += "│ " + } + renderSitemapNode(sb, child, childIndent, false, c) + } +} + +func findSitemapChild(node *sitemapNode, segment string) *sitemapNode { + for _, child := range node.children { + if child.segment == segment { + return child + } + } + return nil +} + +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + parts := strings.Split(p, "/") + if idx := strings.Index(parts[len(parts)-1], "?"); idx >= 0 { + parts[len(parts)-1] = parts[len(parts)-1][:idx] + } + return parts +} + +func pathFromTarget(target, assetTarget string) string { + if target == "" { + return "" + } + p := WebPath(target) + if p == target && assetTarget != "" && strings.HasPrefix(target, assetTarget) { + p = strings.TrimPrefix(target, assetTarget) + if p == "" { + p = "/" + } + } + return p +} + +func isStaticTitle(title string) bool { + switch strings.ToLower(title) { + case "js data", "css data", "ico data", "image data": + return true + } + return false +} + +func mergeStrings(a, b []string) []string { + if len(b) == 0 { + return a + } + seen := make(map[string]struct{}, len(a)) + for _, s := range a { + seen[strings.ToLower(s)] = struct{}{} + } + for _, s := range b { + if _, ok := seen[strings.ToLower(s)]; !ok { + a = append(a, s) + seen[strings.ToLower(s)] = struct{}{} + } + } + return a +} + +func appendUniq(slice []string, val string) []string { + for _, s := range slice { + if s == val { + return slice + } + } + return append(slice, val) +} diff --git a/core/output/report_golden_test.go b/core/output/report_golden_test.go new file mode 100644 index 00000000..014cc660 --- /dev/null +++ b/core/output/report_golden_test.go @@ -0,0 +1,113 @@ +package output + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "regexp" + "testing" +) + +// updateReportGolden rewrites the .golden files instead of comparing against +// them, so the diff of a deliberate rendering change is reviewable on its own. +var updateReportGolden = flag.Bool("update-report-golden", false, "rewrite report golden files") + +// LoadReportFixture reads one of the shared report fixtures. It lives in +// core/output because that is where the fixtures live, but pkg/web reads the +// same files so the two renderers are pinned against identical input. +func loadReportFixture(t *testing.T, name string) *Result { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name+".json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + result := &Result{} + if err := json.Unmarshal(raw, result); err != nil { + t.Fatalf("decode fixture: %v", err) + } + return result +} + +// reportStamp matches the "generated at" header timestamp, the one part of the +// markdown report that cannot be pinned. +var reportStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`) + +func checkReportGolden(t *testing.T, name, got string) { + t.Helper() + got = reportStamp.ReplaceAllString(got, "") + path := filepath.Join("testdata", name+".golden") + if *updateReportGolden { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden (run go test -run %s -update-report-golden): %v", t.Name(), err) + } + if got != string(want) { + t.Errorf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, got, string(want)) + } +} + +// TestAssetReportGolden pins the terminal asset report. FormatAssetReport is a +// wrapper over RenderReport, so this is also the ANSI emitter's contract. +func TestAssetReportGolden(t *testing.T) { + for _, tc := range []struct { + name string + fixture string + color bool + }{ + {name: "asset_plain", fixture: "report_fixture"}, + {name: "asset_color", fixture: "report_fixture", color: true}, + {name: "asset_empty", fixture: "report_empty"}, + } { + t.Run(tc.name, func(t *testing.T) { + checkReportGolden(t, tc.name, FormatAssetReport(loadReportFixture(t, tc.fixture), tc.color)) + }) + } +} + +// TestRenderReportMarkdownGolden pins the markdown emitter under the option +// sets its two callers use. The web_* goldens must stay byte-identical to +// pkg/web/testdata/web_*.golden — that is the cross-package parity check. +func TestRenderReportMarkdownGolden(t *testing.T) { + web := ReportOptions{Style: StyleMarkdown, Title: "10.0.0.1", Mode: "quick", CollapseBare: true} + tool := ReportOptions{Style: StyleMarkdown, Title: "Scan Report", Sitemap: true, CollapseBare: true, Metrics: true, Inventory: true} + + for _, tc := range []struct { + name string + fixture string + opts ReportOptions + nilRes bool + }{ + {name: "md_web_zh", fixture: "report_fixture", opts: withLang(web, "zh")}, + {name: "md_web_en", fixture: "report_fixture", opts: withLang(web, "en")}, + {name: "md_web_empty_zh", fixture: "report_empty", opts: withLang(web, "zh")}, + {name: "md_web_empty_en", fixture: "report_empty", opts: withLang(web, "en")}, + {name: "md_web_nil", opts: withLang(web, "en"), nilRes: true}, + {name: "md_tool", fixture: "report_fixture", opts: tool}, + {name: "md_tool_empty", fixture: "report_empty", opts: tool}, + } { + t.Run(tc.name, func(t *testing.T) { + var result *Result + if !tc.nilRes { + result = loadReportFixture(t, tc.fixture) + } + checkReportGolden(t, tc.name, RenderReport(result, tc.opts)) + }) + } +} + +func withLang(opts ReportOptions, lang string) ReportOptions { + opts.Lang = lang + return opts +} + +func TestAssetReportNilResult(t *testing.T) { + if got := FormatAssetReport(nil, false); got != "Assets: 0 total\n" { + t.Fatalf("nil result = %q", got) + } +} diff --git a/core/output/testdata/asset_color.golden b/core/output/testdata/asset_color.golden new file mode 100644 index 00000000..144a4085 --- /dev/null +++ b/core/output/testdata/asset_color.golden @@ -0,0 +1,43 @@ +Assets: 4 total +Summary: 2 target(s), 4 service(s), 2 web endpoint(s), 3 probe(s), 3 loot(s), 1 error(s), 22.266s + +1. Example App + target: http://10.0.0.1 + status: confirmed + service: tcp http 80 + fingerprint: nginx + vuln: [confirmed] CVE-2021-41773 path traversal + ## Impact + Arbitrary file read through `/cgi-bin/`. + | Field | Value | + |---|---| + | CVSS | 9.8 | + weakpass: [high] ssh root:root + deep: [info] Admin console is reachable without authentication. + deep: [response] Let me analyze the collected browser evidence. + Let me analyze the collected browser evidence. + ## Evidence Analysis + | Asset | Details | + |---|---| + | API | GET /api/scans | + error: dial tcp 10.0.0.1:8443: connect: connection refused + sitemap: + [200] / 1256 "Example App" [nginx] {nginx} {deep:response} + ├── /admin {vuln:confirmed CVE-2021-41773 path traversal} {deep:info} + │ ├── [401] /login 512 "Login" [basic-auth] + ├── /static + │ ├── [200] /app.js 9001 + ├── /10.0.0.1:22 {weakpass:high ssh root:root} + +2. MySQL 5.7.32 + target: 10.0.0.2:3306 + status: loot + service: tcp mysql 3306 + fingerprint: [loot] mysql 5.7.32 + +3. icmp + target: 10.0.0.2:icmp + service: icmp + +4. 10.0.0.3:445 + service: tcp smb 445 diff --git a/core/output/testdata/asset_empty.golden b/core/output/testdata/asset_empty.golden new file mode 100644 index 00000000..90553463 --- /dev/null +++ b/core/output/testdata/asset_empty.golden @@ -0,0 +1,2 @@ +Assets: 0 total +Summary: 0 target(s), 0 service(s), 0 web endpoint(s), 0 probe(s), 0 loot(s), 0 error(s), diff --git a/core/output/testdata/asset_plain.golden b/core/output/testdata/asset_plain.golden new file mode 100644 index 00000000..703a0faf --- /dev/null +++ b/core/output/testdata/asset_plain.golden @@ -0,0 +1,43 @@ +Assets: 4 total +Summary: 2 target(s), 4 service(s), 2 web endpoint(s), 3 probe(s), 3 loot(s), 1 error(s), 22.266s + +1. Example App + target: http://10.0.0.1 + status: confirmed + service: tcp http 80 + fingerprint: nginx + vuln: [confirmed] CVE-2021-41773 path traversal + ## Impact + Arbitrary file read through `/cgi-bin/`. + | Field | Value | + |---|---| + | CVSS | 9.8 | + weakpass: [high] ssh root:root + deep: [info] Admin console is reachable without authentication. + deep: [response] Let me analyze the collected browser evidence. + Let me analyze the collected browser evidence. + ## Evidence Analysis + | Asset | Details | + |---|---| + | API | GET /api/scans | + error: dial tcp 10.0.0.1:8443: connect: connection refused + sitemap: + [200] / 1256 "Example App" [nginx] {nginx} {deep:response} + ├── /admin {vuln:confirmed CVE-2021-41773 path traversal} {deep:info} + │ ├── [401] /login 512 "Login" [basic-auth] + ├── /static + │ ├── [200] /app.js 9001 + ├── /10.0.0.1:22 {weakpass:high ssh root:root} + +2. MySQL 5.7.32 + target: 10.0.0.2:3306 + status: loot + service: tcp mysql 3306 + fingerprint: [loot] mysql 5.7.32 + +3. icmp + target: 10.0.0.2:icmp + service: icmp + +4. 10.0.0.3:445 + service: tcp smb 445 diff --git a/core/output/testdata/md_tool.golden b/core/output/testdata/md_tool.golden new file mode 100644 index 00000000..8b197cd1 --- /dev/null +++ b/core/output/testdata/md_tool.golden @@ -0,0 +1,116 @@ +# Scan Report + +--- + +## Overview + +The scan identified 3 hosts across 4 open services (2 web sites). It probed 3 paths and matched 2 fingerprints. **3 security findings surfaced (credentials / weak passwords / vulnerabilities) — review these first.** 1 error occurred during probing. The scan took 22.266s. + +## Metrics + +| Metric | Value | +| --- | ---: | +| Inputs | 2 | +| Open services | 4 | +| Web endpoints | 2 | +| Web probes | 3 | +| Fingerprints | 2 | +| Loots | 3 | +| Errors | 1 | +| Tasks | 7 | +| Requests | 19 | +| Duration | 22.266s | + +## Assets + +### Example App — `http://10.0.0.1` + +- Services: `tcp http 80` +- HTTP: `200`, `401` +- Fingerprints: `nginx`, `basic-auth` +- Paths: 3 +- State: `confirmed` + +#### Sitemap + +```text +[200] / 1256 "Example App" [nginx] {nginx} {deep:response} +├── /admin {vuln:confirmed CVE-2021-41773 path traversal} {deep:info} +│ ├── [401] /login 512 "Login" [basic-auth] +├── /static +│ ├── [200] /app.js 9001 +├── /10.0.0.1:22 {weakpass:high ssh root:root} +``` + +#### Analysis + +##### CVE-2021-41773 path traversal + +## Impact + +Arbitrary file read through `/cgi-bin/`. + +| Field | Value | +|---|---| +| CVSS | 9.8 | + +##### ssh root:root + +ssh root:root + +##### Admin console is reachable without authentication. + +##### Let me analyze the collected browser evidence. + +Let me analyze the collected browser evidence. + +## Evidence Analysis + +| Asset | Details | +|---|---| +| API | GET /api/scans | + +##### dial tcp 10.0.0.1:8443: connect: connection refused + +dial tcp 10.0.0.1:8443: connect: connection refused + +### MySQL 5.7.32 — `10.0.0.2:3306` + +- Services: `tcp mysql 3306` +- State: `loot` + +#### Analysis + +##### mysql 5.7.32 + +mysql 5.7.32 + +## Other live hosts + +- `10.0.0.2:icmp` · icmp +- `10.0.0.3:445` · tcp smb 445 + +## Open Services + +- `10.0.0.1:80` · tcp http 80 +- `10.0.0.2:3306` · tcp mysql 3306 +- `10.0.0.2:icmp` · icmp +- `10.0.0.3:445` · tcp smb 445 + +## Web Evidence + +- `http://10.0.0.1/` · `200` · "Example App" · `nginx` +- `http://10.0.0.1/admin/login` · `401` · "Login" · `basic-auth` +- `http://10.0.0.1/static/app.js` · `200` + +## Findings + +- **[verified]** CVE-2021-41773 path traversal — `http://10.0.0.1/admin` +- ssh root:root — `10.0.0.1:22` +- Admin console is reachable without authentication. — `http://10.0.0.1/admin` +- Let me analyze the collected browser evidence. — `http://10.0.0.1` +- mysql 5.7.32 — `10.0.0.2:3306` + +## Errors + +- dial tcp 10.0.0.1:8443: connect: connection refused diff --git a/core/output/testdata/md_tool_empty.golden b/core/output/testdata/md_tool_empty.golden new file mode 100644 index 00000000..a016ccdc --- /dev/null +++ b/core/output/testdata/md_tool_empty.golden @@ -0,0 +1,22 @@ +# Scan Report + +--- + +## Overview + +The scan identified 0 hosts across 0 open services. + +## Metrics + +| Metric | Value | +| --- | ---: | +| Inputs | 0 | +| Open services | 0 | +| Web endpoints | 0 | +| Web probes | 0 | +| Fingerprints | 0 | +| Loots | 0 | +| Errors | 0 | +| Tasks | 0 | +| Requests | 0 | +| Duration | | diff --git a/core/output/testdata/md_web_empty_en.golden b/core/output/testdata/md_web_empty_en.golden new file mode 100644 index 00000000..99152d2b --- /dev/null +++ b/core/output/testdata/md_web_empty_en.golden @@ -0,0 +1,9 @@ +# Recon report · 10.0.0.1 + +Target `10.0.0.1` · Quick recon · + +--- + +## Overview + +The scan identified 0 hosts across 0 open services. diff --git a/core/output/testdata/md_web_empty_zh.golden b/core/output/testdata/md_web_empty_zh.golden new file mode 100644 index 00000000..ef7f420d --- /dev/null +++ b/core/output/testdata/md_web_empty_zh.golden @@ -0,0 +1,9 @@ +# 侦察报告 · 10.0.0.1 + +目标 `10.0.0.1` · 快速侦察 · + +--- + +## 概述 + +本次侦察共识别 0 台主机、0 个开放服务。 diff --git a/core/output/testdata/md_web_en.golden b/core/output/testdata/md_web_en.golden new file mode 100644 index 00000000..178138df --- /dev/null +++ b/core/output/testdata/md_web_en.golden @@ -0,0 +1,67 @@ +# Recon report · 10.0.0.1 + +Target `10.0.0.1` · Quick recon · + +--- + +## Overview + +The scan identified 3 hosts across 4 open services (2 web sites). It probed 3 paths and matched 2 fingerprints. **3 security findings surfaced (credentials / weak passwords / vulnerabilities) — review these first.** 1 error occurred during probing. The scan took 22.266s. + +## Assets + +### Example App — `http://10.0.0.1` + +- Services: `tcp http 80` +- HTTP: `200`, `401` +- Fingerprints: `nginx`, `basic-auth` +- Paths: 3 +- State: `confirmed` + +#### Analysis + +##### CVE-2021-41773 path traversal + +## Impact + +Arbitrary file read through `/cgi-bin/`. + +| Field | Value | +|---|---| +| CVSS | 9.8 | + +##### ssh root:root + +ssh root:root + +##### Admin console is reachable without authentication. + +##### Let me analyze the collected browser evidence. + +Let me analyze the collected browser evidence. + +## Evidence Analysis + +| Asset | Details | +|---|---| +| API | GET /api/scans | + +##### dial tcp 10.0.0.1:8443: connect: connection refused + +dial tcp 10.0.0.1:8443: connect: connection refused + +### MySQL 5.7.32 — `10.0.0.2:3306` + +- Services: `tcp mysql 3306` +- State: `loot` + +#### Analysis + +##### mysql 5.7.32 + +mysql 5.7.32 + +## Other live hosts + +- `10.0.0.2:icmp` · icmp +- `10.0.0.3:445` · tcp smb 445 diff --git a/core/output/testdata/md_web_nil.golden b/core/output/testdata/md_web_nil.golden new file mode 100644 index 00000000..15413fd8 --- /dev/null +++ b/core/output/testdata/md_web_nil.golden @@ -0,0 +1,7 @@ +# Recon report · 10.0.0.1 + +Target `10.0.0.1` · Quick recon · + +--- + +No structured result was returned. diff --git a/core/output/testdata/md_web_zh.golden b/core/output/testdata/md_web_zh.golden new file mode 100644 index 00000000..d7408503 --- /dev/null +++ b/core/output/testdata/md_web_zh.golden @@ -0,0 +1,67 @@ +# 侦察报告 · 10.0.0.1 + +目标 `10.0.0.1` · 快速侦察 · + +--- + +## 概述 + +本次侦察共识别 3 台主机、4 个开放服务(含 2 个 Web 站点)。累计探测 3 条路径、命中 2 项 Web 指纹。**发现 3 项需优先复核的安全发现(凭证 / 弱口令 / 漏洞)。**另有 1 处探测报错。全程耗时 22.266s。 + +## 资产明细 + +### Example App — `http://10.0.0.1` + +- 开放服务:`tcp http 80` +- HTTP 响应:`200`、`401` +- Web 指纹:`nginx`、`basic-auth` +- 已探测路径:3 条 +- 状态:`confirmed` + +#### 分析研判 + +##### CVE-2021-41773 path traversal + +## Impact + +Arbitrary file read through `/cgi-bin/`. + +| Field | Value | +|---|---| +| CVSS | 9.8 | + +##### ssh root:root + +ssh root:root + +##### Admin console is reachable without authentication. + +##### Let me analyze the collected browser evidence. + +Let me analyze the collected browser evidence. + +## Evidence Analysis + +| Asset | Details | +|---|---| +| API | GET /api/scans | + +##### dial tcp 10.0.0.1:8443: connect: connection refused + +dial tcp 10.0.0.1:8443: connect: connection refused + +### MySQL 5.7.32 — `10.0.0.2:3306` + +- 开放服务:`tcp mysql 3306` +- 状态:`loot` + +#### 分析研判 + +##### mysql 5.7.32 + +mysql 5.7.32 + +## 其他存活主机 + +- `10.0.0.2:icmp` · icmp +- `10.0.0.3:445` · tcp smb 445 diff --git a/core/output/testdata/report_empty.json b/core/output/testdata/report_empty.json new file mode 100644 index 00000000..cad98679 --- /dev/null +++ b/core/output/testdata/report_empty.json @@ -0,0 +1,3 @@ +{ + "summary": {} +} diff --git a/core/output/testdata/report_fixture.json b/core/output/testdata/report_fixture.json new file mode 100644 index 00000000..1a4b73ba --- /dev/null +++ b/core/output/testdata/report_fixture.json @@ -0,0 +1,173 @@ +{ + "summary": { + "targets": 2, + "services": 4, + "webs": 2, + "probes": 3, + "loots": 3, + "errors": 1, + "tasks": 7, + "requests": 19, + "duration": "22.266s" + }, + "assets": [ + { + "id": "asset:http://10.0.0.1", + "key": "http://10.0.0.1", + "target": "http://10.0.0.1", + "title": "Example App", + "status": "confirmed", + "items": [ + { + "kind": "service", + "source": "gogo_portscan", + "target": "10.0.0.1:80", + "title": "http", + "summary": "nginx/1.18.0", + "tags": ["tcp", "http", "80"], + "data": {"ip": "10.0.0.1", "port": "80", "protocol": "tcp", "service": "http", "banner": "nginx/1.18.0", "is_web": true} + }, + { + "kind": "fingerprint", + "source": "gogo_portscan", + "target": "http://10.0.0.1", + "title": "nginx", + "tags": ["gogo_portscan", "nginx"], + "data": {"name": "nginx", "focus": false} + }, + { + "kind": "loot", + "source": "vuln", + "target": "http://10.0.0.1/admin", + "status": "confirmed", + "title": "CVE-2021-41773 path traversal", + "summary": "CVE-2021-41773 path traversal", + "tags": ["vuln", "apache"], + "detail": "## Impact\n\nArbitrary file read through `/cgi-bin/`.\n\n| Field | Value |\n|---|---|\n| CVSS | 9.8 |", + "data": {"kind": "vuln", "verification_status": "confirmed"} + }, + { + "kind": "loot", + "source": "weakpass", + "target": "10.0.0.1:22", + "status": "high", + "title": "ssh root:root", + "summary": "ssh root:root", + "tags": ["weakpass", "ssh"], + "data": {"kind": "weakpass"} + }, + { + "kind": "note", + "source": "deep", + "target": "http://10.0.0.1/admin", + "status": "info", + "summary": "Admin console is reachable without authentication.", + "detail": "Admin console is reachable without authentication." + }, + { + "kind": "response", + "source": "deep", + "target": "http://10.0.0.1", + "status": "response", + "detail": "Let me analyze the collected browser evidence.\n\n## Evidence Analysis\n\n| Asset | Details |\n|---|---|\n| API | GET /api/scans |" + }, + { + "kind": "path", + "source": "spray_check", + "target": "http://10.0.0.1/", + "status": "200", + "title": "Example App", + "summary": "/", + "tags": ["spray_check", "nginx", "validated"], + "data": {"url": "http://10.0.0.1/", "path": "/", "status": 200, "length": 1256, "title": "Example App", "fingers": ["nginx"], "validated": true} + }, + { + "kind": "path", + "source": "spray_check", + "target": "http://10.0.0.1/admin/login", + "status": "401", + "title": "Login", + "summary": "/admin/login", + "tags": ["spray_check", "basic-auth", "validated"], + "data": {"url": "http://10.0.0.1/admin/login", "path": "/admin/login", "status": 401, "length": 512, "title": "Login", "fingers": ["basic-auth"], "validated": true} + }, + { + "kind": "path", + "source": "spray_crawl", + "target": "http://10.0.0.1/static/app.js", + "status": "200", + "title": "js data", + "summary": "/static/app.js", + "tags": ["spray_crawl"], + "data": {"url": "http://10.0.0.1/static/app.js", "path": "/static/app.js", "status": 200, "length": 9001, "title": "js data"} + }, + { + "kind": "error", + "source": "spray_check", + "target": "scan", + "status": "error", + "summary": "dial tcp 10.0.0.1:8443: connect: connection refused", + "data": {"message": "dial tcp 10.0.0.1:8443: connect: connection refused"} + } + ] + }, + { + "id": "asset:10.0.0.2:3306", + "key": "10.0.0.2:3306", + "target": "10.0.0.2:3306", + "title": "MySQL 5.7.32", + "status": "loot", + "items": [ + { + "kind": "service", + "source": "gogo_portscan", + "target": "10.0.0.2:3306", + "title": "mysql", + "summary": "MySQL 5.7.32", + "tags": ["tcp", "mysql", "3306"], + "data": {"ip": "10.0.0.2", "port": "3306", "protocol": "tcp", "service": "mysql", "banner": "MySQL 5.7.32"} + }, + { + "kind": "loot", + "source": "fingerprint", + "target": "10.0.0.2:3306", + "status": "loot", + "title": "mysql 5.7.32", + "summary": "mysql 5.7.32", + "tags": ["fingerprint", "mysql"], + "data": {"kind": "fingerprint"} + } + ] + }, + { + "id": "asset:10.0.0.2:icmp", + "key": "10.0.0.2:icmp", + "target": "10.0.0.2:icmp", + "title": "icmp", + "items": [ + { + "kind": "service", + "source": "gogo_portscan", + "target": "10.0.0.2:icmp", + "title": "icmp", + "tags": ["icmp"], + "data": {"ip": "10.0.0.2", "protocol": "icmp", "service": "icmp"} + } + ] + }, + { + "id": "asset:10.0.0.3:445", + "key": "10.0.0.3:445", + "target": "10.0.0.3:445", + "items": [ + { + "kind": "service", + "source": "gogo_portscan", + "target": "10.0.0.3:445", + "tags": ["tcp", "smb", "445"], + "data": {"ip": "10.0.0.3", "port": "445", "protocol": "tcp", "service": "smb"} + } + ] + } + ] +} diff --git a/core/output/timeline.go b/core/output/timeline.go index 22b0ba77..da2c6b87 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/aop" - xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" + "github.com/chainreactors/aiscan/core/aop" + xcommand "github.com/chainreactors/aiscan/core/aop/x/command" "github.com/chainreactors/utils/parsers" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index baac2462..b0d1b884 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/aop" - xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" + "github.com/chainreactors/aiscan/core/aop" + xcommand "github.com/chainreactors/aiscan/core/aop/x/command" ) func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { diff --git a/core/resources/keys.go b/core/resources/keys.go new file mode 100644 index 00000000..b235f9dc --- /dev/null +++ b/core/resources/keys.go @@ -0,0 +1,7 @@ +package resources + +import "github.com/chainreactors/aiscan/core/deps" + +// SetKey carries the loaded scanner resources (fingers, neutron, proton +// configs) to the command factories. +var SetKey = deps.NewKey[*Set]("core.resources.Set") diff --git a/pkg/telemetry/logger.go b/core/telemetry/logger.go similarity index 100% rename from pkg/telemetry/logger.go rename to core/telemetry/logger.go diff --git a/pkg/telemetry/logger_test.go b/core/telemetry/logger_test.go similarity index 100% rename from pkg/telemetry/logger_test.go rename to core/telemetry/logger_test.go diff --git a/pkg/telemetry/recover.go b/core/telemetry/recover.go similarity index 100% rename from pkg/telemetry/recover.go rename to core/telemetry/recover.go diff --git a/pkg/telemetry/recover_test.go b/core/telemetry/recover_test.go similarity index 100% rename from pkg/telemetry/recover_test.go rename to core/telemetry/recover_test.go diff --git a/pkg/telemetry/startup.go b/core/telemetry/startup.go similarity index 100% rename from pkg/telemetry/startup.go rename to core/telemetry/startup.go diff --git a/pkg/telemetry/startup_test.go b/core/telemetry/startup_test.go similarity index 100% rename from pkg/telemetry/startup_test.go rename to core/telemetry/startup_test.go diff --git a/pkg/agent/truncate/clip.go b/core/truncate/clip.go similarity index 100% rename from pkg/agent/truncate/clip.go rename to core/truncate/clip.go diff --git a/pkg/agent/truncate/clip_test.go b/core/truncate/clip_test.go similarity index 100% rename from pkg/agent/truncate/clip_test.go rename to core/truncate/clip_test.go diff --git a/pkg/agent/truncate/truncate.go b/core/truncate/truncate.go similarity index 99% rename from pkg/agent/truncate/truncate.go rename to core/truncate/truncate.go index 1e33e43c..a39287ca 100644 --- a/pkg/agent/truncate/truncate.go +++ b/core/truncate/truncate.go @@ -4,7 +4,7 @@ import ( "strings" "unicode/utf8" - "github.com/chainreactors/aiscan/pkg/util" + "github.com/chainreactors/aiscan/core/util" ) // ── Tier 1: 通用工具结果 (bash/read/grep/find/ls/inbox/agent result) ── diff --git a/pkg/agent/truncate/truncate_test.go b/core/truncate/truncate_test.go similarity index 100% rename from pkg/agent/truncate/truncate_test.go rename to core/truncate/truncate_test.go diff --git a/pkg/util/format.go b/core/util/format.go similarity index 100% rename from pkg/util/format.go rename to core/util/format.go diff --git a/docs/development.md b/docs/development.md index 2ed17424..06d02d51 100644 --- a/docs/development.md +++ b/docs/development.md @@ -187,7 +187,7 @@ import ( "fmt" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) type Command struct { @@ -268,7 +268,7 @@ package whatweb import ( "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) func init() { @@ -570,7 +570,7 @@ agent_background: true Agent 类型 Skill 通过 `skills.Store.AgentTypes()` 收集,注入到 `SubAgentTool` 中: ```go -// core/runner/runner.go +// pkg/runner/runner.go subAgentTool := agent.NewSubAgentTool(parentAgent, ib, func(name string) (agent.AgentType, error) { s, ok := rt.App.Skills.ByName(name) if !ok || !s.Agent { return error } diff --git a/docs/mechanisms.md b/docs/mechanisms.md index cd6ce37d..24b0503f 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -60,7 +60,7 @@ Settings UI 保存 **并发模型**: hub 的 `saveMu` 防止多个配置事务交错;本地扫描通过 managed App 租约继续使用旧运行时,不会被保存设置中断。agent 侧 `Agent.SetProvider()` / `SetMaxTurns()` 在 `mu.Lock` 下修改 `Cfg`,`Run`/`Continue` 开始时 `configSnapshot()` 在锁下拷贝,已在飞的 run 不受影响。 -**文件**: `pkg/web/service.go`, `cmd/aiscan/web_full.go`, `pkg/web/agents.go`, `pkg/webagent/agent.go`, `core/runner/runner.go`, `pkg/agent/agent.go` +**文件**: `pkg/web/service.go`, `cmd/aiscan/web_full.go`, `pkg/web/agents.go`, `pkg/webagent/agent.go`, `pkg/runner/runner.go`, `agent/agent.go` --- @@ -94,7 +94,7 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事 **评估器门控修正**: 旧逻辑只对 Terminated/Completed 执行评估,turn-capped(Stopped)或 token-capped(Budget)的 agent 被静默跳过。新逻辑只在 Error/Canceled 时跳过。 -**文件**: `pkg/agent/event_json.go`, `pkg/agent/evaluator/loop.go`, `pkg/web/agents.go`, `pkg/web/service.go` +**文件**: `agent/aop_emit.go`, `agent/evaluator/loop.go`, `pkg/web/agents.go`, `pkg/web/service.go` --- @@ -147,7 +147,7 @@ chat endpoint 返回 404 时包裹 actionable 建议(如"设置 `llm.provider= 这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。品牌默认地址由 preset 解析,不依赖域名猜测。 -**文件**: `pkg/agent/provider/anthropic.go`, `pkg/agent/provider/openai.go`, `pkg/agent/provider/http.go`, `pkg/agent/provider/provider.go` +**文件**: `agent/provider/anthropic.go`, `agent/provider/openai.go`, `agent/provider/http.go`, `agent/provider/provider.go` --- @@ -234,7 +234,7 @@ AOP error 事件把 code 保存在标准 data 中,并把 params 保存在 `ext scan、agent joined、session cleared 等产品事件保留独立的 `DomainEvent`,不携带 Agent 的 role/content/message ID 字段。 -**文件**: `core/runner/`, `pkg/aop/`, `pkg/web/service.go` +**文件**: `pkg/runner/`, `core/aop/`, `pkg/web/service.go` --- @@ -260,7 +260,7 @@ scan、agent joined、session cleared 等产品事件保留独立的 `DomainEven 跨界面 Runtime 命令通过 typed AOP command detail 标记 `presentation: preformatted`。Web timeline 在最终展示边界生成自适应 Markdown code fence;Runtime、Session 和 transport 不再处理 Markdown 或终端格式。 -**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/aop/x/command/command.go`, `core/output/timeline.go` +**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `core/aop/x/command/command.go`, `core/output/timeline.go` --- diff --git a/pkg/commands/bash.go b/pkg/commands/bash.go index f50aca9b..720832eb 100644 --- a/pkg/commands/bash.go +++ b/pkg/commands/bash.go @@ -10,10 +10,10 @@ import ( "strings" "time" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/agent/tmux" coretool "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/agent/tmux" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) const ( @@ -110,7 +110,7 @@ func (t *BashTool) Execute(ctx context.Context, arguments string) (coretool.Resu return coretool.Result{}, err } - return t.waitOrBackground(execution, ctx, inboxFromContext(ctx)), nil + return t.waitOrBackground(execution, ctx, inbox.FromContext(ctx)), nil } // RunForeground executes command through the same tmux/registered-command diff --git a/pkg/commands/bash_inbox_test.go b/pkg/commands/bash_inbox_test.go index 4441bcb4..c3024f0d 100644 --- a/pkg/commands/bash_inbox_test.go +++ b/pkg/commands/bash_inbox_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/agent/inbox" + "github.com/chainreactors/aiscan/agent/inbox" ) func TestBashBackgroundMonitorUsesInvocationInbox(t *testing.T) { diff --git a/pkg/commands/bash_test.go b/pkg/commands/bash_test.go index cdd41c81..b6a2c589 100644 --- a/pkg/commands/bash_test.go +++ b/pkg/commands/bash_test.go @@ -14,10 +14,10 @@ import ( "testing" "time" + tmux "github.com/chainreactors/aiscan/agent/tmux" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" - tmux "github.com/chainreactors/aiscan/pkg/agent/tmux" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ) // --------------------------------------------------------------------------- diff --git a/pkg/commands/command.go b/pkg/commands/command.go index bd5fbc8f..2e1775a0 100644 --- a/pkg/commands/command.go +++ b/pkg/commands/command.go @@ -7,8 +7,8 @@ import ( "strings" "sync" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/telemetry" ) var _ tool.Executor = (*CommandRegistry)(nil) diff --git a/pkg/commands/context.go b/pkg/commands/context.go deleted file mode 100644 index 70d44c52..00000000 --- a/pkg/commands/context.go +++ /dev/null @@ -1,27 +0,0 @@ -package commands - -import ( - "context" - - "github.com/chainreactors/aiscan/pkg/agent/inbox" -) - -type inboxContextKey struct{} - -// ContextWithInbox scopes asynchronous command notifications to the agent -// session that invoked the tool. -func ContextWithInbox(ctx context.Context, ib inbox.Inbox) context.Context { - if ctx == nil { - ctx = context.Background() - } - return context.WithValue(ctx, inboxContextKey{}, ib) -} - -func inboxFromContext(ctx context.Context) inbox.Inbox { - if ctx != nil { - if ib, ok := ctx.Value(inboxContextKey{}).(inbox.Inbox); ok && ib != nil { - return ib - } - } - return nil -} diff --git a/pkg/commands/execution.go b/pkg/commands/execution.go index 67ee0ea1..699a4a2a 100644 --- a/pkg/commands/execution.go +++ b/pkg/commands/execution.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/agent/tmux" + "github.com/chainreactors/aiscan/agent/tmux" ) // Execution is one shell or built-in command invocation. Its ID is always the diff --git a/pkg/commands/factory.go b/pkg/commands/factory.go index 9847a050..3ba9b0c9 100644 --- a/pkg/commands/factory.go +++ b/pkg/commands/factory.go @@ -3,33 +3,58 @@ package commands import ( "sync" + "github.com/chainreactors/aiscan/agent/hooks" + "github.com/chainreactors/aiscan/agent/provider" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) type Factory struct { - Group string - Build func(deps *Deps, reg *CommandRegistry) + Capability capability.ID + Build func(deps *Deps, reg *CommandRegistry) } +// SkillSource is the slice of skills.Store the built-in tools need; declaring +// it here keeps pkg/commands from importing the skill store itself. +type SkillSource interface { + VirtualFileReader + VirtualGlobber +} + +// Deps carries everything a Factory may need. Values whose type pkg/commands +// must not import (scanner engines, resources, IOA client, scan options) travel +// in the Bag under keys owned by their own package. type Deps struct { + *deps.Bag + WorkDir string BashTimeout int - SkillStore any + SkillStore SkillSource RunnerMode bool - EngineSet any - Resources any - IOAClient any - Provider any + Provider provider.Provider ScannerProxy string - ScanOpts []any Logger telemetry.Logger NodeName string NodeMeta map[string]any TavilyKeys string // comma-separated Tavily API keys (build-time fallback) DataBus *eventbus.Bus[output.ToolDataEvent] + Hooks *hooks.Registry +} + +// Provide stores a typed dependency, allocating the bag on first use so a +// literal-constructed Deps cannot drop it silently. +func Provide[T any](d *Deps, key deps.Key[T], value T) { + if d == nil { + return + } + if d.Bag == nil { + d.Bag = deps.New() + } + deps.Set(d.Bag, key, value) } func (d *Deps) GetLogger() telemetry.Logger { @@ -39,6 +64,13 @@ func (d *Deps) GetLogger() telemetry.Logger { return telemetry.NopLogger() } +// Skip reports that a factory bailed out because a dependency is missing. It +// exists because these sites used to return silently, leaving the tool absent +// from the registry with nothing in the log to explain it. +func (d *Deps) Skip(id, dep string) { + d.GetLogger().Warnf("%s", telemetry.StartupLine("skip", id, "missing dependency "+dep)) +} + var ( factoryMu sync.Mutex factories []Factory @@ -50,25 +82,16 @@ func RegisterFactory(f Factory) { factories = append(factories, f) } -func BuildAll(deps *Deps, reg *CommandRegistry) { - factoryMu.Lock() - snapshot := make([]Factory, len(factories)) - copy(snapshot, factories) - factoryMu.Unlock() - - for _, f := range snapshot { - f.Build(deps, reg) - } -} - -func BuildGroup(group string, deps *Deps, reg *CommandRegistry) { +// BuildPlan is the only factory assembly path. Factory membership is keyed by +// capability identity; groups are selection metadata owned by descriptors. +func BuildPlan(plan capability.Plan, deps *Deps, reg *CommandRegistry) { factoryMu.Lock() snapshot := make([]Factory, len(factories)) copy(snapshot, factories) factoryMu.Unlock() for _, f := range snapshot { - if f.Group != group { + if f.Capability == "" || !plan.Has(f.Capability) { continue } f.Build(deps, reg) diff --git a/pkg/commands/glob.go b/pkg/commands/glob.go index 1006ffa5..c86a75f7 100644 --- a/pkg/commands/glob.go +++ b/pkg/commands/glob.go @@ -9,7 +9,7 @@ import ( "strings" coretool "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) const maxGlobResults = truncate.MaxGlobResults diff --git a/pkg/commands/image_optimize.go b/pkg/commands/image_optimize.go index 4295b9b8..f7d21e96 100644 --- a/pkg/commands/image_optimize.go +++ b/pkg/commands/image_optimize.go @@ -14,7 +14,7 @@ import ( ) const ( - maxDimension = 2000 + maxDimension = 2000 maxPayloadBytes = 4_500_000 // 4.5MB base64, below Anthropic's 5MB limit ) diff --git a/pkg/commands/list.go b/pkg/commands/list.go index 62b4da28..19007547 100644 --- a/pkg/commands/list.go +++ b/pkg/commands/list.go @@ -8,7 +8,7 @@ import ( "path/filepath" coretool "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) // ListTool lists directory entries through the host filesystem API. diff --git a/pkg/commands/read.go b/pkg/commands/read.go index 29e0984e..7d1cc580 100644 --- a/pkg/commands/read.go +++ b/pkg/commands/read.go @@ -10,7 +10,7 @@ import ( "unicode/utf8" coretool "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) const ( diff --git a/pkg/commands/register.go b/pkg/commands/register.go index 0efd9294..367fddc2 100644 --- a/pkg/commands/register.go +++ b/pkg/commands/register.go @@ -1,11 +1,15 @@ package commands +import "github.com/chainreactors/aiscan/core/capability" + func init() { + capability.Register(capability.Descriptor{ID: "core", Kind: capability.KindTool, Group: "core"}) RegisterFactory(Factory{ - Group: "core", + Capability: "core", Build: func(deps *Deps, reg *CommandRegistry) { workDir := deps.WorkDir if workDir == "" { + deps.Skip("core", "WorkDir") return } timeout := deps.BashTimeout @@ -15,12 +19,8 @@ func init() { var readers []VirtualFileReader var globbers []VirtualGlobber if deps.SkillStore != nil { - if r, ok := deps.SkillStore.(VirtualFileReader); ok { - readers = append(readers, r) - } - if g, ok := deps.SkillStore.(VirtualGlobber); ok { - globbers = append(globbers, g) - } + readers = append(readers, deps.SkillStore) + globbers = append(globbers, deps.SkillStore) } reg.RegisterTool(NewReadTool(workDir, readers...)) reg.RegisterTool(NewWriteTool(workDir)) diff --git a/pkg/commands/register_test.go b/pkg/commands/register_test.go index 072bc4eb..1b625953 100644 --- a/pkg/commands/register_test.go +++ b/pkg/commands/register_test.go @@ -1,6 +1,10 @@ package commands -import "testing" +import ( + "testing" + + "github.com/chainreactors/aiscan/core/capability" +) func closeRegistryTools(registry *CommandRegistry) { for _, tool := range registry.Tools() { @@ -12,14 +16,14 @@ func closeRegistryTools(registry *CommandRegistry) { func TestNativeListToolIsRunnerOnly(t *testing.T) { regular := NewRegistry() - BuildGroup("core", &Deps{WorkDir: t.TempDir()}, regular) + BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &Deps{WorkDir: t.TempDir()}, regular) defer closeRegistryTools(regular) if _, ok := regular.GetTool("ls"); ok { t.Fatal("regular agent must not expose the runner-only ls tool") } runner := NewRegistry() - BuildGroup("core", &Deps{WorkDir: t.TempDir(), RunnerMode: true}, runner) + BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &Deps{WorkDir: t.TempDir(), RunnerMode: true}, runner) defer closeRegistryTools(runner) if _, ok := runner.GetTool("ls"); !ok { t.Fatal("runner mode must expose the native ls tool") diff --git a/pkg/commands/tmux.go b/pkg/commands/tmux.go index ff0ad471..a22a40ac 100644 --- a/pkg/commands/tmux.go +++ b/pkg/commands/tmux.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/pkg/agent/tmux" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/agent/tmux" + "github.com/chainreactors/aiscan/core/truncate" ) type tmuxCommand struct { diff --git a/pkg/commands/tmux_test.go b/pkg/commands/tmux_test.go index d20841c8..3c32c0dc 100644 --- a/pkg/commands/tmux_test.go +++ b/pkg/commands/tmux_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - tmuxpkg "github.com/chainreactors/aiscan/pkg/agent/tmux" + tmuxpkg "github.com/chainreactors/aiscan/agent/tmux" ) type testOutputWriter struct{ bytes.Buffer } diff --git a/pkg/commands/write.go b/pkg/commands/write.go index e30c09a8..1554b3b4 100644 --- a/pkg/commands/write.go +++ b/pkg/commands/write.go @@ -9,7 +9,7 @@ import ( "strings" coretool "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) type WriteTool struct { diff --git a/pkg/agent/probe/config.go b/pkg/probe/config.go similarity index 100% rename from pkg/agent/probe/config.go rename to pkg/probe/config.go diff --git a/pkg/agent/probe/conn.go b/pkg/probe/conn.go similarity index 100% rename from pkg/agent/probe/conn.go rename to pkg/probe/conn.go diff --git a/core/runner/app.go b/pkg/runner/app.go similarity index 89% rename from core/runner/app.go rename to pkg/runner/app.go index ee718b8c..95e0d8ef 100644 --- a/core/runner/app.go +++ b/pkg/runner/app.go @@ -8,16 +8,19 @@ import ( "sync" "time" - cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/hooks" + "github.com/chainreactors/aiscan/agent/probe" + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/probe" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/skills" + ioatools "github.com/chainreactors/aiscan/tools/ioa" ioaclient "github.com/chainreactors/ioa/client" + "github.com/chainreactors/ioa/protocols" ) type App struct { @@ -25,6 +28,7 @@ type App struct { ProviderConfig agent.ProviderConfig ProviderFallbacks []agent.ProviderEntry Commands *commands.CommandRegistry + Hooks *hooks.Registry Engines any Skills *skills.Store SkillDiagnostics []skills.Diagnostic @@ -37,7 +41,7 @@ type App struct { logger telemetry.Logger } -func NewApp(ctx context.Context, rc cfg.RuntimeConfig) (*App, error) { +func NewApp(ctx context.Context, rc ApplicationConfig) (*App, error) { a := &App{} logger := rc.Logger if logger == nil { @@ -45,6 +49,10 @@ func NewApp(ctx context.Context, rc cfg.RuntimeConfig) (*App, error) { } a.logger = logger logger = a.Logger() + a.Hooks = hooks.New() + a.Hooks.SetErrorSink(func(he *hooks.HandlerError) { + a.Logger().Warnf("hook failed kind=%s source=%s error=%q", he.Kind, he.Source, he.Err) + }) a.DataBus = eventbus.New[output.ToolDataEvent]() a.SCOSidecar = output.NewSCOSidecar(a.DataBus, output.CSTXTransform) @@ -79,7 +87,7 @@ func NewApp(ctx context.Context, rc cfg.RuntimeConfig) (*App, error) { } } - a.Commands = initCoreCommands(rc, a.Provider, a.Skills, logger) + a.Commands = initCoreCommands(rc, a.Provider, a.Skills, a.Hooks, logger) a.enginesReady = make(chan struct{}) go func() { @@ -232,11 +240,7 @@ func llmConfigLabel(providerName, model string) string { return providerName + "/" + model } -// optionalToolGroups lists all selectable tool groups that can be enabled via -// --tools or config. Arsenal is always loaded and is NOT in this list. -var optionalToolGroups = []string{"search", "browser"} - -func initCoreCommands(rc cfg.RuntimeConfig, llmProvider agent.Provider, skillStore *skills.Store, logger telemetry.Logger) *commands.CommandRegistry { +func initCoreCommands(rc ApplicationConfig, llmProvider agent.Provider, skillStore *skills.Store, hookRegistry *hooks.Registry, logger telemetry.Logger) *commands.CommandRegistry { cmdReg := commands.NewRegistry() workDir, _ := os.Getwd() deps := &commands.Deps{ @@ -246,20 +250,13 @@ func initCoreCommands(rc cfg.RuntimeConfig, llmProvider agent.Provider, skillSto Provider: llmProvider, Logger: logger, TavilyKeys: rc.Tools.TavilyKeys, + Hooks: hookRegistry, } - commands.BuildGroup("core", deps, cmdReg) - commands.BuildGroup("arsenal", deps, cmdReg) - - enabled := rc.Tools.OptionalTools - if len(enabled) == 0 { - for _, g := range optionalToolGroups { - commands.BuildGroup(g, deps, cmdReg) - } - } else { - for _, g := range enabled { - commands.BuildGroup(g, deps, cmdReg) - } - } + plan := capability.Select(capability.Options{ + Groups: []string{"core", "arsenal", "search", "browser"}, + OptionalTools: rc.Tools.OptionalTools, + }) + commands.BuildPlan(plan, deps, cmdReg) return cmdReg } @@ -321,7 +318,7 @@ func quoteCommandArg(value string) string { return `"` + value + `"` } -func (a *App) InitIOA(ctx context.Context, ioa cfg.IOAConfig) error { +func (a *App) InitIOA(ctx context.Context, ioa IOAConfig) error { client, err := newIOAClient(ioa) if err != nil { return err @@ -338,11 +335,11 @@ func (a *App) InitIOA(ctx context.Context, ioa cfg.IOAConfig) error { a.IOAStreamClient = client if ioa.RegisterTools && a.Commands != nil { deps := &commands.Deps{ - IOAClient: client, - NodeName: ioa.NodeName, - NodeMeta: ioa.NodeMeta, + NodeName: ioa.NodeName, + NodeMeta: ioa.NodeMeta, } - commands.BuildGroup("ioa", deps, a.Commands) + commands.Provide(deps, ioatools.ClientKey, protocols.ClientAPI(client)) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"ioa"}}), deps, a.Commands) } if ioa.AutoRegister { if err := client.EnsureRegistered(ctx, ioa.NodeName, "", ioa.NodeMeta); err != nil { @@ -355,7 +352,7 @@ func (a *App) InitIOA(ctx context.Context, ioa cfg.IOAConfig) error { return nil } -func (a *App) retryIOARegistration(ctx context.Context, client *ioaclient.Client, ioa cfg.IOAConfig) { +func (a *App) retryIOARegistration(ctx context.Context, client *ioaclient.Client, ioa IOAConfig) { for attempt := 0; ; attempt++ { delay := agent.RetryDelay(attempt) select { @@ -371,7 +368,7 @@ func (a *App) retryIOARegistration(ctx context.Context, client *ioaclient.Client } } -func (a *App) configureIOASpace(ctx context.Context, client *ioaclient.Client, ioa cfg.IOAConfig) { +func (a *App) configureIOASpace(ctx context.Context, client *ioaclient.Client, ioa IOAConfig) { if ioa.Space != "" && client != nil && client.Bound() { info, err := client.Space(ctx, ioa.Space, "aiscan agent") if err == nil { @@ -388,7 +385,7 @@ func (a *App) setIOASpace(spaceID string) { } } -func newIOAClient(ioa cfg.IOAConfig) (*ioaclient.Client, error) { +func newIOAClient(ioa IOAConfig) (*ioaclient.Client, error) { if ioa.URL == "" { return nil, nil } diff --git a/core/runner/app_test.go b/pkg/runner/app_test.go similarity index 96% rename from core/runner/app_test.go rename to pkg/runner/app_test.go index 86cc4343..daa7335b 100644 --- a/core/runner/app_test.go +++ b/pkg/runner/app_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/telemetry" ) func TestLogLLMProbeStatusReady(t *testing.T) { diff --git a/core/config/app_config.go b/pkg/runner/application_builder.go similarity index 74% rename from core/config/app_config.go rename to pkg/runner/application_builder.go index 7f21298e..48562905 100644 --- a/core/config/app_config.go +++ b/pkg/runner/application_builder.go @@ -1,9 +1,10 @@ -package config +package runner import ( "strings" - "github.com/chainreactors/aiscan/pkg/telemetry" + cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/telemetry" ) type RuntimeFeatures struct { @@ -15,9 +16,9 @@ type RuntimeFeatures struct { Warning string } -func AppConfig(option *Option, features RuntimeFeatures, logger telemetry.Logger) RuntimeConfig { - return RuntimeConfig{ - Provider: RuntimeProviderConfig{ +func AppConfig(option *cfg.Option, features RuntimeFeatures, logger telemetry.Logger) ApplicationConfig { + return ApplicationConfig{ + Provider: ApplicationProviderConfig{ Enabled: features.ProviderEnabled, Config: ProviderConfig(option), Fallbacks: FallbackProviderConfigs(option), @@ -28,7 +29,7 @@ func AppConfig(option *Option, features RuntimeFeatures, logger telemetry.Logger CyberhubKey: option.CyberhubKey, CyberhubMode: option.CyberhubMode, AIEnabled: features.AIEnabled, - VerifyMode: ResolveString(option.ScanConfig.Verify, DefaultVerify), + VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), Proxy: option.Proxy, FofaEmail: option.FofaEmail, FofaKey: option.FofaKey, @@ -40,7 +41,7 @@ func AppConfig(option *Option, features RuntimeFeatures, logger telemetry.Logger Tools: ToolConfig{ Enabled: features.ToolsEnabled, BashTimeout: 300, - TavilyKeys: resolveTavilyKeys(option.TavilyKey, ResolveString(option.SearchConfig.TavilyKeys, DefaultTavilyKeys)), + TavilyKeys: resolveTavilyKeys(option.TavilyKey, cfg.ResolveString(option.SearchConfig.TavilyKeys, cfg.DefaultTavilyKeys)), OptionalTools: option.Tools, }, Logger: logger, @@ -48,7 +49,7 @@ func AppConfig(option *Option, features RuntimeFeatures, logger telemetry.Logger } } -func skillPathsFromOptions(option *Option) []string { +func skillPathsFromOptions(option *cfg.Option) []string { var paths []string for _, s := range option.Skills { if looksLikePath(s) { diff --git a/core/config/runtime.go b/pkg/runner/application_config.go similarity index 81% rename from core/config/runtime.go rename to pkg/runner/application_config.go index 3ee27c91..8aaade0c 100644 --- a/core/config/runtime.go +++ b/pkg/runner/application_config.go @@ -1,13 +1,13 @@ -package config +package runner import ( - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/ioa/protocols" ) -type RuntimeConfig struct { - Provider RuntimeProviderConfig +type ApplicationConfig struct { + Provider ApplicationProviderConfig Scanner ScannerConfig Tools ToolConfig IOA *IOAConfig @@ -16,7 +16,7 @@ type RuntimeConfig struct { SkipEngines bool } -type RuntimeProviderConfig struct { +type ApplicationProviderConfig struct { Enabled bool Config agent.ProviderConfig Fallbacks []agent.ProviderConfig diff --git a/core/runner/hooks.go b/pkg/runner/hooks.go similarity index 86% rename from core/runner/hooks.go rename to pkg/runner/hooks.go index 240608a1..55ff5021 100644 --- a/core/runner/hooks.go +++ b/pkg/runner/hooks.go @@ -4,12 +4,12 @@ import ( "context" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) // ScannerInitFunc initializes scanner engines and registers scanner commands. // Set via init() from the package imported by cmd/aiscan. -var ScannerInitFunc func(ctx context.Context, a *App, rc cfg.RuntimeConfig, logger telemetry.Logger) +var ScannerInitFunc func(ctx context.Context, a *App, rc ApplicationConfig, logger telemetry.Logger) // ScannerWithAgentFunc runs a scanner command with AI agent assistance. // Set via init() from the package imported by cmd/aiscan. diff --git a/core/runner/ioa.go b/pkg/runner/ioa.go similarity index 95% rename from core/runner/ioa.go rename to pkg/runner/ioa.go index ddf132ff..121edf9b 100644 --- a/core/runner/ioa.go +++ b/pkg/runner/ioa.go @@ -9,7 +9,7 @@ import ( "time" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) func RunIOAServe(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { diff --git a/core/runner/local_repl.go b/pkg/runner/local_repl.go similarity index 100% rename from core/runner/local_repl.go rename to pkg/runner/local_repl.go diff --git a/pkg/agent/loop_tool.go b/pkg/runner/loop_command.go similarity index 70% rename from pkg/agent/loop_tool.go rename to pkg/runner/loop_command.go index e059b49c..14617a4d 100644 --- a/pkg/agent/loop_tool.go +++ b/pkg/runner/loop_command.go @@ -1,4 +1,4 @@ -package agent +package runner import ( "context" @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/pkg/commands" ) @@ -16,32 +17,13 @@ import ( // bash(command="loop 30s check status") // bash(command="loop list") // bash(command="loop stop loop-a1b2c3d4") -type LoopCommand struct{} +type loopCommand struct{} -type loopSchedulerContextKey struct{} +func newLoopCommand() *loopCommand { return &loopCommand{} } -// ContextWithLoopScheduler scopes direct REPL command execution to one runtime -// session. Agent tool calls obtain the scheduler from their Config snapshot. -func ContextWithLoopScheduler(ctx context.Context, scheduler *LoopScheduler) context.Context { - if ctx == nil { - ctx = context.Background() - } - return context.WithValue(ctx, loopSchedulerContextKey{}, scheduler) -} - -func loopSchedulerFromContext(ctx context.Context) *LoopScheduler { - if ctx == nil { - return nil - } - scheduler, _ := ctx.Value(loopSchedulerContextKey{}).(*LoopScheduler) - return scheduler -} +func (c *loopCommand) Name() string { return "loop" } -func NewLoopCommand() *LoopCommand { return &LoopCommand{} } - -func (c *LoopCommand) Name() string { return "loop" } - -func (c *LoopCommand) Usage() string { +func (c *loopCommand) Usage() string { return `loop — recurring task scheduler Usage: @@ -62,13 +44,8 @@ Examples: loop 5m monitor targets every 5 minutes` } -func (c *LoopCommand) Run(ctx context.Context, execution *commands.Execution) (any, error) { - scheduler := loopSchedulerFromContext(ctx) - if scheduler == nil { - if cfg, ok := toolAgentConfig(ctx); ok { - scheduler = cfg.LoopScheduler - } - } +func (c *loopCommand) Run(ctx context.Context, execution *commands.Execution) (any, error) { + scheduler := agent.LoopSchedulerFromContext(ctx) if scheduler == nil { return nil, fmt.Errorf("loop scheduler is not configured") } @@ -99,12 +76,12 @@ func (c *LoopCommand) Run(ctx context.Context, execution *commands.Execution) (a } } -func (c *LoopCommand) create(ctx context.Context, scheduler *LoopScheduler, output io.Writer, args []string) error { +func (c *loopCommand) create(ctx context.Context, scheduler *agent.LoopScheduler, output io.Writer, args []string) error { if len(args) < 2 { return fmt.Errorf("usage: loop ") } - entry := LoopEntry{Mode: ModeInbox} + entry := agent.LoopEntry{Mode: agent.ModeInbox} // try cron first (5 space-separated fields), then duration if cron, rest, ok := tryCronPrefix(args); ok { @@ -131,9 +108,9 @@ func (c *LoopCommand) create(ctx context.Context, scheduler *LoopScheduler, outp // tryCronPrefix attempts to parse the first 5 args as a cron expression. // Returns the parsed expression, remaining args, and whether it succeeded. -func tryCronPrefix(args []string) (*CronExpr, []string, bool) { +func tryCronPrefix(args []string) (*agent.CronExpr, []string, bool) { if len(args) >= 2 && strings.Contains(args[0], " ") { - if cron, err := ParseCron(args[0]); err == nil { + if cron, err := agent.ParseCron(args[0]); err == nil { return cron, args[1:], true } } @@ -141,14 +118,14 @@ func tryCronPrefix(args []string) (*CronExpr, []string, bool) { return nil, nil, false } expr := strings.Join(args[:5], " ") - cron, err := ParseCron(expr) + cron, err := agent.ParseCron(expr) if err != nil { return nil, nil, false } return cron, args[5:], true } -func (c *LoopCommand) list(scheduler *LoopScheduler, output io.Writer) error { +func (c *loopCommand) list(scheduler *agent.LoopScheduler, output io.Writer) error { loops := scheduler.List() if len(loops) == 0 { _, _ = fmt.Fprint(output, "No active loops.\n") @@ -167,7 +144,7 @@ func (c *LoopCommand) list(scheduler *LoopScheduler, output io.Writer) error { return nil } -func (c *LoopCommand) stop(scheduler *LoopScheduler, output io.Writer, name string) error { +func (c *loopCommand) stop(scheduler *agent.LoopScheduler, output io.Writer, name string) error { if err := scheduler.Remove(name); err != nil { return err } diff --git a/core/runner/prompt.go b/pkg/runner/prompt.go similarity index 99% rename from core/runner/prompt.go rename to pkg/runner/prompt.go index daf5e653..d3d42958 100644 --- a/core/runner/prompt.go +++ b/pkg/runner/prompt.go @@ -7,7 +7,7 @@ import ( "text/template" "time" - "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/skills" ) diff --git a/core/runner/prompt_test.go b/pkg/runner/prompt_test.go similarity index 98% rename from core/runner/prompt_test.go rename to pkg/runner/prompt_test.go index 3970a9d0..fc32aae1 100644 --- a/core/runner/prompt_test.go +++ b/pkg/runner/prompt_test.go @@ -6,8 +6,8 @@ import ( "testing" cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/skills" ) diff --git a/core/config/provider.go b/pkg/runner/provider_config.go similarity index 62% rename from core/config/provider.go rename to pkg/runner/provider_config.go index f31efdca..de1eff24 100644 --- a/core/config/provider.go +++ b/pkg/runner/provider_config.go @@ -1,43 +1,24 @@ -package config +package runner -import "github.com/chainreactors/aiscan/pkg/agent" - -var ( - DefaultProvider = "openai" - DefaultBaseURL = "" - DefaultAPIKey = "" - DefaultModel = "" - - DefaultScannerProxy = "" - - DefaultCyberhubURL = "" - DefaultCyberhubKey = "" - DefaultCyberhubMode = "merge" - - DefaultVerify = "auto" - - DefaultIOAURL = "" - DefaultIOANodeID = "" - DefaultIOANodeName = "" - DefaultSpace = "" - - DefaultTavilyKeys = "" +import ( + "github.com/chainreactors/aiscan/agent" + cfg "github.com/chainreactors/aiscan/core/config" ) func defaultProviderConfig() agent.ProviderConfig { return agent.ProviderConfig{ - Provider: DefaultProvider, - BaseURL: DefaultBaseURL, - APIKey: DefaultAPIKey, - Model: DefaultModel, + Provider: cfg.DefaultProvider, + BaseURL: cfg.DefaultBaseURL, + APIKey: cfg.DefaultAPIKey, + Model: cfg.DefaultModel, } } -func hasSingleProviderFields(option *Option) bool { +func hasSingleProviderFields(option *cfg.Option) bool { return option.Provider != "" || option.BaseURL != "" || option.APIKey != "" || option.Model != "" } -func entryToProviderConfig(entry LLMProviderEntry) agent.ProviderConfig { +func entryToProviderConfig(entry cfg.LLMProviderEntry) agent.ProviderConfig { cfg := agent.ProviderConfig{ Provider: entry.Provider, BaseURL: entry.BaseURL, @@ -57,7 +38,7 @@ func entryToProviderConfig(entry LLMProviderEntry) agent.ProviderConfig { // activeProviderIndex resolves the primary provider profile by ActiveProfile // id; list position is meaningless, so an unset or unknown id selects index 0. -func activeProviderIndex(option *Option) int { +func activeProviderIndex(option *cfg.Option) int { if option.ActiveProfile != "" { for i, entry := range option.Providers { if entry.ID == option.ActiveProfile { @@ -68,16 +49,16 @@ func activeProviderIndex(option *Option) int { return 0 } -func applyProviderLimits(cfg *agent.ProviderConfig, option *Option) { +func applyProviderLimits(providerConfig *agent.ProviderConfig, option *cfg.Option) { if option.MaxTokens != 0 { - cfg.MaxTokens = option.MaxTokens + providerConfig.MaxTokens = option.MaxTokens } if option.ContextWindow != 0 { - cfg.ContextWindow = option.ContextWindow + providerConfig.ContextWindow = option.ContextWindow } } -func ProviderConfig(option *Option) agent.ProviderConfig { +func ProviderConfig(option *cfg.Option) agent.ProviderConfig { if !hasSingleProviderFields(option) && len(option.Providers) > 0 { cfg := entryToProviderConfig(option.Providers[activeProviderIndex(option)]) applyProviderLimits(&cfg, option) @@ -107,7 +88,7 @@ func ProviderConfig(option *Option) agent.ProviderConfig { return cfg } -func FallbackProviderConfigs(option *Option) []agent.ProviderConfig { +func FallbackProviderConfigs(option *cfg.Option) []agent.ProviderConfig { if !hasSingleProviderFields(option) && len(option.Providers) > 0 { active := activeProviderIndex(option) var configs []agent.ProviderConfig @@ -126,11 +107,11 @@ func FallbackProviderConfigs(option *Option) []agent.ProviderConfig { return configs } -func ApplyResolvedProviderOptions(option *Option, cfg agent.ProviderConfig) { - option.Provider = cfg.Provider - option.BaseURL = cfg.BaseURL - option.APIKey = cfg.APIKey - option.Model = cfg.Model - option.MaxTokens = cfg.MaxTokens - option.ContextWindow = cfg.ContextWindow +func ApplyResolvedProviderOptions(option *cfg.Option, providerConfig agent.ProviderConfig) { + option.Provider = providerConfig.Provider + option.BaseURL = providerConfig.BaseURL + option.APIKey = providerConfig.APIKey + option.Model = providerConfig.Model + option.MaxTokens = providerConfig.MaxTokens + option.ContextWindow = providerConfig.ContextWindow } diff --git a/pkg/runner/provider_config_test.go b/pkg/runner/provider_config_test.go new file mode 100644 index 00000000..9476676b --- /dev/null +++ b/pkg/runner/provider_config_test.go @@ -0,0 +1,39 @@ +package runner + +import ( + "testing" + + cfg "github.com/chainreactors/aiscan/core/config" +) + +func TestProviderConfigSelectsActiveProfileAndFallbacks(t *testing.T) { + option := cfg.Option{LLMOptions: cfg.LLMOptions{ + ActiveProfile: "openai", + Providers: []cfg.LLMProviderEntry{ + {ID: "deepseek", Provider: "deepseek", APIKey: "dk-111", Model: "deepseek-chat", MaxTokens: 8192}, + {ID: "openai", Provider: "openai", APIKey: "sk-222", Model: "gpt-4o", MaxTokens: 32768}, + }, + }} + primary := ProviderConfig(&option) + if primary.Provider != "openai" || primary.APIKey != "sk-222" || primary.MaxTokens != 32768 { + t.Fatalf("primary profile = %+v", primary) + } + fallbacks := FallbackProviderConfigs(&option) + if len(fallbacks) != 1 || fallbacks[0].Provider != "deepseek" || fallbacks[0].APIKey != "dk-111" { + t.Fatalf("fallback profiles = %+v", fallbacks) + } +} + +func TestProviderConfigExplicitFieldsWin(t *testing.T) { + option := cfg.Option{LLMOptions: cfg.LLMOptions{ + Provider: "anthropic", APIKey: "cli-key", Model: "cli-model", + Providers: []cfg.LLMProviderEntry{{Provider: "deepseek", APIKey: "fallback-key", Model: "deepseek-chat"}}, + }} + primary := ProviderConfig(&option) + if primary.Provider != "anthropic" || primary.APIKey != "cli-key" || primary.Model != "cli-model" { + t.Fatalf("explicit provider = %+v", primary) + } + if fallbacks := FallbackProviderConfigs(&option); len(fallbacks) != 1 || fallbacks[0].Provider != "deepseek" { + t.Fatalf("fallback profiles = %+v", fallbacks) + } +} diff --git a/core/runner/remote_repl.go b/pkg/runner/remote_repl.go similarity index 100% rename from core/runner/remote_repl.go rename to pkg/runner/remote_repl.go diff --git a/core/runner/remote_repl_test.go b/pkg/runner/remote_repl_test.go similarity index 99% rename from core/runner/remote_repl_test.go rename to pkg/runner/remote_repl_test.go index 6e49bb4e..7a8ca656 100644 --- a/core/runner/remote_repl_test.go +++ b/pkg/runner/remote_repl_test.go @@ -7,7 +7,7 @@ import ( "time" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/utils/pty" ) diff --git a/core/runner/runner.go b/pkg/runner/runner.go similarity index 96% rename from core/runner/runner.go rename to pkg/runner/runner.go index a0df525b..20d15b00 100644 --- a/core/runner/runner.go +++ b/pkg/runner/runner.go @@ -9,15 +9,15 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/agent" + inboxpkg "github.com/chainreactors/aiscan/agent/inbox" + tmuxpkg "github.com/chainreactors/aiscan/agent/tmux" + "github.com/chainreactors/aiscan/core/aop" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/telemetry" coretool "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent" - inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" - tmuxpkg "github.com/chainreactors/aiscan/pkg/agent/tmux" - "github.com/chainreactors/aiscan/pkg/aop" cmdpkg "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" "github.com/chainreactors/aiscan/tools/toolargs" @@ -67,7 +67,7 @@ const ( type RuntimeConfig struct { ExistingApp *App - IOA *cfg.IOAConfig + IOA *IOAConfig PromptConfig *PromptConfig NoOutput bool InteractiveOutput bool @@ -107,7 +107,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L rt.app = rc.ExistingApp } else { providerOptional := rc != nil && (rc.IOA != nil || rc.ProviderOptional) - appCfg := cfg.AppConfig(option, cfg.RuntimeFeatures{ + appCfg := AppConfig(option, RuntimeFeatures{ ProviderEnabled: true, ProviderOptional: providerOptional, ToolsEnabled: true, @@ -122,7 +122,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L } rt.app = application rt.ownsApp = true - cfg.ApplyResolvedProviderOptions(option, application.ProviderConfig) + ApplyResolvedProviderOptions(option, application.ProviderConfig) for _, d := range application.SkillDiagnostics { logger.Warnf("skill %s: %s", d.Path, d.Message) @@ -227,6 +227,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L Logger: logger, CacheRetention: agent.CacheShort, Bus: rt.kernelBus, + Hooks: rt.app.Hooks, } if option.SaveSession { @@ -269,7 +270,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L } handoffCancel = subscribeIOAHandoffContext(rt.ctx, publicBus, rt.app.IOAClient, ioaSpace, logger) rt.app.Commands.RegisterTool(subAgentTool) - loop := agent.NewLoopCommand() + loop := newLoopCommand() rt.app.Commands.Register(cmdpkg.Command{Name: loop.Name(), Usage: loop.Usage(), Run: loop.Run}, "loop") if option.Resume != "" { @@ -384,7 +385,7 @@ func (rt *AgentRuntime) ReloadProvider(option *cfg.Option) (agent.Provider, stri if logger == nil { logger = telemetry.NopLogger() } - provider, resolved, err := initProvider(cfg.ProviderConfig(option), logger) + provider, resolved, err := initProvider(ProviderConfig(option), logger) if err != nil { return nil, "", err } @@ -543,7 +544,7 @@ func RunDirectScannerMode(ctx context.Context, option *cfg.Option, rest []string defer restoreLogs() } - application, err := NewApp(ctx, cfg.AppConfig(option, features, scannerLogger)) + application, err := NewApp(ctx, AppConfig(option, features, scannerLogger)) if err != nil { return fmt.Errorf("init app: %w", err) } @@ -551,7 +552,7 @@ func RunDirectScannerMode(ctx context.Context, option *cfg.Option, rest []string if err := application.WaitEngines(ctx); err != nil { return fmt.Errorf("engine init: %w", err) } - cfg.ApplyResolvedProviderOptions(option, application.ProviderConfig) + ApplyResolvedProviderOptions(option, application.ProviderConfig) if !application.Commands.Has(scannerArgs[0]) { return fmt.Errorf("unknown subcommand: %s", scannerArgs[0]) @@ -691,7 +692,7 @@ func registerIOATools(ctx context.Context, application *App, option *cfg.Option) if ioaURL == "" { return nil } - ioaCfg := cfg.IOAConfig{ + ioaCfg := IOAConfig{ URL: ioaURL, NodeID: option.IOANodeID, NodeName: option.IOANodeName, diff --git a/pkg/runner/runtime_config.go b/pkg/runner/runtime_config.go new file mode 100644 index 00000000..3ef69b83 --- /dev/null +++ b/pkg/runner/runtime_config.go @@ -0,0 +1,18 @@ +package runner + +import ( + "github.com/chainreactors/aiscan/agent" + cfg "github.com/chainreactors/aiscan/core/config" +) + +// ResolveRuntimeConfig resolves the process configuration and applies process +// state such as the data directory. +func ResolveRuntimeConfig(option *cfg.Option) (string, error) { + return cfg.ResolveRuntimeConfig(option, true, agent.InferProviderFromBaseURL) +} + +// ResolveRuntimeConfigCandidate resolves a staged Web configuration without +// mutating process-wide state before the candidate is committed. +func ResolveRuntimeConfigCandidate(option *cfg.Option) (string, error) { + return cfg.ResolveRuntimeConfig(option, false, agent.InferProviderFromBaseURL) +} diff --git a/core/runner/runtime_protocol.go b/pkg/runner/runtime_protocol.go similarity index 100% rename from core/runner/runtime_protocol.go rename to pkg/runner/runtime_protocol.go diff --git a/core/runner/runtime_protocol_test.go b/pkg/runner/runtime_protocol_test.go similarity index 100% rename from core/runner/runtime_protocol_test.go rename to pkg/runner/runtime_protocol_test.go diff --git a/core/runner/runtime_semantics_test.go b/pkg/runner/runtime_semantics_test.go similarity index 93% rename from core/runner/runtime_semantics_test.go rename to pkg/runner/runtime_semantics_test.go index e2b1c4e3..7d5a7166 100644 --- a/core/runner/runtime_semantics_test.go +++ b/pkg/runner/runtime_semantics_test.go @@ -8,12 +8,13 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/aop" - xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/aop" + xcommand "github.com/chainreactors/aiscan/core/aop/x/command" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ) type runtimeSemanticProvider struct { @@ -155,7 +156,7 @@ func TestSessionContextCancellationStopsActiveRun(t *testing.T) { func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { registry := commands.NewRegistry() - commands.BuildGroup("core", &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5, Logger: telemetry.NopLogger()}, registry) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5, Logger: telemetry.NopLogger()}, registry) rt := newBareRuntime(t, registry, nil) session, err := rt.OpenSession(context.Background(), SessionOptions{ID: "session-1"}) if err != nil { diff --git a/core/runner/runtime_session.go b/pkg/runner/runtime_session.go similarity index 98% rename from core/runner/runtime_session.go rename to pkg/runner/runtime_session.go index 747f17ec..f114c163 100644 --- a/core/runner/runtime_session.go +++ b/pkg/runner/runtime_session.go @@ -9,14 +9,14 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/evaluator" + inboxpkg "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/core/aop" + xcommand "github.com/chainreactors/aiscan/core/aop/x/command" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/evaluator" - inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" - "github.com/chainreactors/aiscan/pkg/aop" - xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" ) @@ -184,7 +184,7 @@ func (s *commandSession) execute(ctx context.Context, input string) commandOutco if line == "/continue" || strings.HasPrefix(line, "/followup ") || strings.HasPrefix(line, "/skill:") { return commandOutcome{err: fmt.Errorf("%s requires a Run", line)} } - ctx = commands.ContextWithInbox(ctx, s.state.inbox) + ctx = inboxpkg.ContextWithInbox(ctx, s.state.inbox) ctx = agent.ContextWithLoopScheduler(ctx, s.state.scheduler) if strings.HasPrefix(line, "!") { diff --git a/core/runner/runtime_session_isolation_test.go b/pkg/runner/runtime_session_isolation_test.go similarity index 87% rename from core/runner/runtime_session_isolation_test.go rename to pkg/runner/runtime_session_isolation_test.go index 14b328d1..ec67b017 100644 --- a/core/runner/runtime_session_isolation_test.go +++ b/pkg/runner/runtime_session_isolation_test.go @@ -6,12 +6,13 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/capability" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ) func newBareRuntime(t *testing.T, reg *commands.CommandRegistry, provider agent.Provider) *AgentRuntime { @@ -36,8 +37,8 @@ func newBareRuntime(t *testing.T, reg *commands.CommandRegistry, provider agent. func TestRuntimeSessionDirectLoopUsesSessionScheduler(t *testing.T) { reg := commands.NewRegistry() - commands.BuildGroup("core", &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5, Logger: telemetry.NopLogger()}, reg) - loop := agent.NewLoopCommand() + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5, Logger: telemetry.NopLogger()}, reg) + loop := newLoopCommand() reg.Register(commands.Command{Name: loop.Name(), Usage: loop.Usage(), Run: loop.Run}, "loop") rt := newBareRuntime(t, reg, nil) t.Cleanup(func() { diff --git a/core/runner/scanner.go b/pkg/runner/scanner.go similarity index 90% rename from core/runner/scanner.go rename to pkg/runner/scanner.go index 5608e181..69862489 100644 --- a/core/runner/scanner.go +++ b/pkg/runner/scanner.go @@ -7,19 +7,19 @@ import ( "github.com/chainreactors/aiscan/core/config" ) -func DirectScannerRuntimeFeatures(rest []string) (config.RuntimeFeatures, []string, error) { +func DirectScannerRuntimeFeatures(rest []string) (RuntimeFeatures, []string, error) { if len(rest) == 0 { - return config.RuntimeFeatures{}, nil, fmt.Errorf("missing scanner command") + return RuntimeFeatures{}, nil, fmt.Errorf("missing scanner command") } if rest[0] != "scan" { - return config.RuntimeFeatures{}, rest, nil + return RuntimeFeatures{}, rest, nil } verifyMode, explicit := scannerVerifyMode(rest[1:]) sniperEnabled := HasScannerFlag(rest[1:], "--sniper") deepEnabled := HasScannerFlag(rest[1:], "--deep") aiSkillRequested := sniperEnabled || deepEnabled - features := config.RuntimeFeatures{} + features := RuntimeFeatures{} if aiSkillRequested { features.ProviderEnabled = true @@ -52,7 +52,7 @@ func DirectScannerRuntimeFeatures(rest []string) (config.RuntimeFeatures, []stri return features, rest, nil default: if explicit { - return config.RuntimeFeatures{}, nil, fmt.Errorf("invalid --verify value %q: expected auto, off, low, medium, high, or critical", verifyMode) + return RuntimeFeatures{}, nil, fmt.Errorf("invalid --verify value %q: expected auto, off, low, medium, high, or critical", verifyMode) } return features, rest, nil } diff --git a/core/runner/stdio.go b/pkg/runner/stdio.go similarity index 96% rename from core/runner/stdio.go rename to pkg/runner/stdio.go index aa7c4516..c7c9d34a 100644 --- a/core/runner/stdio.go +++ b/pkg/runner/stdio.go @@ -9,9 +9,9 @@ import ( "strings" "sync" + "github.com/chainreactors/aiscan/core/aop" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/core/runner/stdio_concurrency_test.go b/pkg/runner/stdio_concurrency_test.go similarity index 98% rename from core/runner/stdio_concurrency_test.go rename to pkg/runner/stdio_concurrency_test.go index d8729d6c..42100898 100644 --- a/core/runner/stdio_concurrency_test.go +++ b/pkg/runner/stdio_concurrency_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/core/runner/stdio_test.go b/pkg/runner/stdio_test.go similarity index 98% rename from core/runner/stdio_test.go rename to pkg/runner/stdio_test.go index a09b1b67..d13edce0 100644 --- a/core/runner/stdio_test.go +++ b/pkg/runner/stdio_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/core/runner/subagent_handoff.go b/pkg/runner/subagent_handoff.go similarity index 97% rename from core/runner/subagent_handoff.go rename to pkg/runner/subagent_handoff.go index 6650676d..79b60442 100644 --- a/core/runner/subagent_handoff.go +++ b/pkg/runner/subagent_handoff.go @@ -8,11 +8,11 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/aop/x/delegation" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/aop/x/delegation" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/ioa/protocols" ) diff --git a/core/runner/subagent_handoff_test.go b/pkg/runner/subagent_handoff_test.go similarity index 98% rename from core/runner/subagent_handoff_test.go rename to pkg/runner/subagent_handoff_test.go index 77adc5e5..554b4598 100644 --- a/core/runner/subagent_handoff_test.go +++ b/pkg/runner/subagent_handoff_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/aop/x/delegation" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/aop/x/delegation" "github.com/chainreactors/ioa/protocols" ) diff --git a/core/transport/transport.go b/pkg/transport/transport.go similarity index 89% rename from core/transport/transport.go rename to pkg/transport/transport.go index a5cac729..cede239f 100644 --- a/core/transport/transport.go +++ b/pkg/transport/transport.go @@ -5,8 +5,8 @@ import ( "io" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/runner" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/webagent" ) diff --git a/pkg/tui/banner_render_test.go b/pkg/tui/banner_render_test.go index 11ed20f5..486a4188 100644 --- a/pkg/tui/banner_render_test.go +++ b/pkg/tui/banner_render_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" + "github.com/chainreactors/aiscan/agent" outputpkg "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" ) // assertUniformWidth checks every line of a rendered box has the same visible diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index 925b72b1..75dee449 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -6,10 +6,10 @@ import ( "net/url" "strings" + "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/aiscan/skills" ) diff --git a/pkg/tui/console.go b/pkg/tui/console.go index 0dc558e5..85c764bd 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -16,12 +16,12 @@ import ( "time" "github.com/carapace-sh/carapace" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/probe" cfg "github.com/chainreactors/aiscan/core/config" outputpkg "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/probe" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/tui/console" rlterm "github.com/chainreactors/tui/readline/terminal" @@ -1466,7 +1466,12 @@ func (r *AgentConsole) applyProviderConfig(pc agent.ProviderConfig) (agent.Provi } r.output.SetContextWindow(contextWindow) if r.option != nil { - cfg.ApplyResolvedProviderOptions(r.option, *resolved) + r.option.Provider = resolved.Provider + r.option.BaseURL = resolved.BaseURL + r.option.APIKey = resolved.APIKey + r.option.Model = resolved.Model + r.option.MaxTokens = resolved.MaxTokens + r.option.ContextWindow = resolved.ContextWindow r.option.LLMProxy = resolved.Proxy } r.syncEvalToController() diff --git a/pkg/tui/console_test.go b/pkg/tui/console_test.go index 1689c6c4..52d8cf57 100644 --- a/pkg/tui/console_test.go +++ b/pkg/tui/console_test.go @@ -14,8 +14,8 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/tui/readline/inputrc" rlterm "github.com/chainreactors/tui/readline/terminal" diff --git a/pkg/tui/controller.go b/pkg/tui/controller.go index c6862736..d0d1b998 100644 --- a/pkg/tui/controller.go +++ b/pkg/tui/controller.go @@ -7,9 +7,9 @@ import ( "strings" "sync" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/evaluator" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/evaluator" + "github.com/chainreactors/aiscan/core/telemetry" ) type agentRunFunc func(context.Context) (*agent.Result, error) diff --git a/pkg/tui/controller_test.go b/pkg/tui/controller_test.go index 92cef548..8717d7b9 100644 --- a/pkg/tui/controller_test.go +++ b/pkg/tui/controller_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/agent" ) // gateProvider blocks the first call until released; later calls answer diff --git a/pkg/tui/format.go b/pkg/tui/format.go index 5e66cdd3..a60a0f4b 100644 --- a/pkg/tui/format.go +++ b/pkg/tui/format.go @@ -11,10 +11,10 @@ import ( "unicode" "unicode/utf8" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/truncate" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/util" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/truncate" + "github.com/chainreactors/aiscan/core/util" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" "golang.org/x/term" diff --git a/pkg/tui/live.go b/pkg/tui/live.go index 20735502..b981b08e 100644 --- a/pkg/tui/live.go +++ b/pkg/tui/live.go @@ -5,9 +5,9 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/truncate" - "github.com/chainreactors/aiscan/pkg/util" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/truncate" + "github.com/chainreactors/aiscan/core/util" ) const ( diff --git a/pkg/tui/output.go b/pkg/tui/output.go index 654a826a..f605d79a 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -9,14 +9,14 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" + xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" + xeval "github.com/chainreactors/aiscan/core/aop/x/eval" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/agent/truncate" - "github.com/chainreactors/aiscan/pkg/aop" - xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" - xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" - "github.com/chainreactors/aiscan/pkg/util" + "github.com/chainreactors/aiscan/core/truncate" + "github.com/chainreactors/aiscan/core/util" "golang.org/x/term" ) diff --git a/pkg/tui/output_test.go b/pkg/tui/output_test.go index 5889e3f8..3561c038 100644 --- a/pkg/tui/output_test.go +++ b/pkg/tui/output_test.go @@ -10,11 +10,11 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" + xeval "github.com/chainreactors/aiscan/core/aop/x/eval" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" - xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" ) type syncedBuffer struct { diff --git a/pkg/tui/remote_console.go b/pkg/tui/remote_console.go index dd8193f1..1a03fcbd 100644 --- a/pkg/tui/remote_console.go +++ b/pkg/tui/remote_console.go @@ -7,9 +7,9 @@ import ( "io" "sync" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" rlterm "github.com/chainreactors/tui/readline/terminal" ) diff --git a/pkg/tui/remote_console_test.go b/pkg/tui/remote_console_test.go index 6822e751..d4433705 100644 --- a/pkg/tui/remote_console_test.go +++ b/pkg/tui/remote_console_test.go @@ -4,8 +4,8 @@ import ( "bytes" "testing" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" ) func TestSubscribeAgentOutputRestoresSessionEvents(t *testing.T) { diff --git a/pkg/tui/render.go b/pkg/tui/render.go index b7c1ed40..46d19dcb 100644 --- a/pkg/tui/render.go +++ b/pkg/tui/render.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/util" + "github.com/chainreactors/aiscan/core/util" bspinner "github.com/charmbracelet/bubbles/spinner" ) diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 38ac936d..95d14491 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -9,8 +9,8 @@ import ( "sync/atomic" "time" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" diff --git a/pkg/web/agents_session_end_test.go b/pkg/web/agents_session_end_test.go index 30d9e6ea..e4ae3887 100644 --- a/pkg/web/agents_session_end_test.go +++ b/pkg/web/agents_session_end_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" ) func sessionEvent(t *testing.T, typ, sessionID string, data any) aop.Event { diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index 4d81151e..5beb8aed 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -15,8 +15,8 @@ import ( webstatic "github.com/chainreactors/aiscan/web" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" diff --git a/pkg/web/config_transaction_test.go b/pkg/web/config_transaction_test.go index 9a5dd9e3..c2f3a9c4 100644 --- a/pkg/web/config_transaction_test.go +++ b/pkg/web/config_transaction_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/core/runner" + "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/web/conn_probe_test.go b/pkg/web/conn_probe_test.go index f7c91d5d..7b956aa0 100644 --- a/pkg/web/conn_probe_test.go +++ b/pkg/web/conn_probe_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/agent/probe" + "github.com/chainreactors/aiscan/pkg/probe" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go index 59f97f74..033cbdb5 100644 --- a/pkg/web/eval_forward_test.go +++ b/pkg/web/eval_forward_test.go @@ -4,9 +4,9 @@ import ( "encoding/json" "testing" - "github.com/chainreactors/aiscan/pkg/aop" - xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" - xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" + "github.com/chainreactors/aiscan/core/aop" + xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" + xeval "github.com/chainreactors/aiscan/core/aop/x/eval" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 344a692d..602342e3 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/chainreactors/aiscan/pkg/agent/probe" + "github.com/chainreactors/aiscan/agent/probe" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go index 5f1c012d..ead02193 100644 --- a/pkg/web/llm_probe_test.go +++ b/pkg/web/llm_probe_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/agent/probe" + "github.com/chainreactors/aiscan/agent/probe" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/web/probe.go b/pkg/web/probe.go index e8712689..d125c7b2 100644 --- a/pkg/web/probe.go +++ b/pkg/web/probe.go @@ -4,7 +4,8 @@ import ( "context" "strings" - "github.com/chainreactors/aiscan/pkg/agent/probe" + agentprobe "github.com/chainreactors/aiscan/agent/probe" + "github.com/chainreactors/aiscan/pkg/probe" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -30,15 +31,15 @@ func toProbeConfig(dc webproto.DistributeConfig) probe.ProbeConfig { // TestLLM probes the supplied LLM settings, falling back to the stored API key // when the request leaves it blank, then delegates to pkg/probe. -func (s *Service) TestLLM(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMTestResult, error) { - return probe.TestLLM(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID)) +func (s *Service) TestLLM(ctx context.Context, req agentprobe.LLMProbeRequest) (agentprobe.LLMTestResult, error) { + return agentprobe.TestLLM(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID)) } // ListLLMModels enumerates the models the supplied LLM endpoint advertises, // falling back to the stored API key when the request leaves it blank, then // delegates to pkg/probe. -func (s *Service) ListLLMModels(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMModelsResult, error) { - return probe.ListLLMModels(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID)) +func (s *Service) ListLLMModels(ctx context.Context, req agentprobe.LLMProbeRequest) (agentprobe.LLMModelsResult, error) { + return agentprobe.ListLLMModels(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID)) } // storedLLMAPIKey returns the requested profile's persisted API key. A blank diff --git a/pkg/web/replay_test.go b/pkg/web/replay_test.go index 6ea81db5..cbed9506 100644 --- a/pkg/web/replay_test.go +++ b/pkg/web/replay_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" ) type lockedResponseRecorder struct { diff --git a/pkg/web/report.go b/pkg/web/report.go new file mode 100644 index 00000000..b2b8d74c --- /dev/null +++ b/pkg/web/report.go @@ -0,0 +1,18 @@ +package web + +import "github.com/chainreactors/aiscan/core/output" + +// defaultReportLang is the language the report is frozen in at scan time; the +// stored copy is only a fallback because GetReport re-renders per request. +const defaultReportLang = "zh" + +func buildMarkdownReport(target, mode string, result *output.Result, lang string) string { + return output.RenderReport(result, output.ReportOptions{ + Style: output.StyleMarkdown, + Lang: lang, + Title: target, + Mode: mode, + Sitemap: true, + CollapseBare: true, + }) +} diff --git a/pkg/web/service.go b/pkg/web/service.go index 9b49b04e..e3b5e58a 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -17,13 +17,13 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/core/aop" + xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" + xeval "github.com/chainreactors/aiscan/core/aop/x/eval" "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/runner" - "github.com/chainreactors/aiscan/pkg/aop" - xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" - xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" scantool "github.com/chainreactors/aiscan/tools/scan" @@ -808,376 +808,6 @@ func (w *sseStreamWriter) Write(p []byte) (int, error) { return len(p), nil } -// defaultReportLang is the language the report is frozen in at scan time; the -// stored copy is only a fallback — GetReport re-renders per request language. -const defaultReportLang = "zh" - -// reportWriter holds the target language so every helper can call w.tr() -// without threading `lang` through every signature. -type reportWriter struct { - strings.Builder - lang string -} - -func newReportWriter(lang string) *reportWriter { - if strings.HasPrefix(strings.ToLower(lang), "zh") { - return &reportWriter{lang: "zh"} - } - return &reportWriter{lang: "en"} -} - -func (w *reportWriter) tr(zh, en string) string { - if w.lang == "zh" { - return zh - } - return en -} - -func (w *reportWriter) modeName(mode string) string { - if strings.EqualFold(mode, "full") { - return w.tr("全面侦察", "Full recon") - } - return w.tr("快速侦察", "Quick recon") -} - -func (w *reportWriter) sep() string { return w.tr(":", ": ") } - -// buildMarkdownReport renders a scan result as an operator-facing recon report. -// It reads like something a human wrote — a prose overview instead of a raw -// metric dump, no internal scanner names (gogo_portscan / check) leaking into -// the prose, and bare live hosts (an icmp echo, say) folded into a trailing -// list rather than each claiming a full section. -func buildMarkdownReport(target, mode string, result *output.Result, lang string) string { - w := newReportWriter(lang) - - heading := output.FirstNonEmpty(target, w.tr("目标", "target")) - fmt.Fprintf(w, "# %s%s\n\n", w.tr("侦察报告 · ", "Recon report · "), heading) - fmt.Fprintf(w, "%s `%s` · %s · %s\n\n", - w.tr("目标", "Target"), target, - w.modeName(mode), - time.Now().Format("2006-01-02 15:04:05")) - w.WriteString("---\n\n") - - if result == nil { - w.WriteString(w.tr("本次扫描未返回结构化结果。\n", "No structured result was returned.\n")) - return w.String() - } - - w.WriteString("## " + w.tr("概述", "Overview") + "\n\n") - w.writeOverview(result) - w.WriteString("\n\n") - - rich, bare := splitReportAssets(result.Assets) - if len(rich) > 0 { - w.WriteString("## " + w.tr("资产明细", "Assets") + "\n\n") - for _, asset := range rich { - w.writeAsset(asset) - } - } - if len(bare) > 0 { - w.WriteString("## " + w.tr("其他存活主机", "Other live hosts") + "\n\n") - for _, asset := range bare { - w.writeBareAsset(asset) - } - w.WriteString("\n") - } - - return w.String() -} - -// writeOverview appends the executive summary — one flowing paragraph that -// names only the numbers that are actually present, so a clean scan reads like -// a sentence rather than a table full of zeros. -func (w *reportWriter) writeOverview(result *output.Result) { - s := result.Summary - hosts := reportHostCount(result.Assets) - fingers := resultFingerprintCount(result) - - if w.lang == "zh" { - fmt.Fprintf(w, "本次侦察共识别 %d 台主机、%d 个开放服务", hosts, s.Services) - if s.Webs > 0 { - fmt.Fprintf(w, "(含 %d 个 Web 站点)", s.Webs) - } - w.WriteString("。") - if s.Probes > 0 { - fmt.Fprintf(w, "累计探测 %d 条路径", s.Probes) - if fingers > 0 { - fmt.Fprintf(w, "、命中 %d 项 Web 指纹", fingers) - } - w.WriteString("。") - } else if fingers > 0 { - fmt.Fprintf(w, "命中 %d 项 Web 指纹。", fingers) - } - if s.Loots > 0 { - fmt.Fprintf(w, "**发现 %d 项需优先复核的安全发现(凭证 / 弱口令 / 漏洞)。**", s.Loots) - } - if s.Errors > 0 { - fmt.Fprintf(w, "另有 %d 处探测报错。", s.Errors) - } - if s.Duration != "" { - fmt.Fprintf(w, "全程耗时 %s。", s.Duration) - } - return - } - - fmt.Fprintf(w, "The scan identified %s across %s", plural(hosts, "host", "hosts"), plural(s.Services, "open service", "open services")) - if s.Webs > 0 { - fmt.Fprintf(w, " (%s)", plural(s.Webs, "web site", "web sites")) - } - w.WriteString(". ") - if s.Probes > 0 { - fmt.Fprintf(w, "It probed %s", plural(s.Probes, "path", "paths")) - if fingers > 0 { - fmt.Fprintf(w, " and matched %s", plural(fingers, "fingerprint", "fingerprints")) - } - w.WriteString(". ") - } else if fingers > 0 { - fmt.Fprintf(w, "It matched %s. ", plural(fingers, "fingerprint", "fingerprints")) - } - if s.Loots > 0 { - fmt.Fprintf(w, "**%s surfaced (credentials / weak passwords / vulnerabilities) — review these first.** ", plural(s.Loots, "security finding", "security findings")) - } - if s.Errors > 0 { - fmt.Fprintf(w, "%s occurred during probing. ", plural(s.Errors, "error", "errors")) - } - if s.Duration != "" { - fmt.Fprintf(w, "The scan took %s.", s.Duration) - } -} - -func plural(n int, one, many string) string { - if n == 1 { - return fmt.Sprintf("%d %s", n, one) - } - return fmt.Sprintf("%d %s", n, many) -} - -// reportHostCount collapses assets down to distinct hosts, so an IP that has -// both an icmp echo and a web service counts once, not twice. -func reportHostCount(assets []output.Asset) int { - seen := make(map[string]struct{}) - for _, a := range assets { - if h := assetHost(a); h != "" { - seen[h] = struct{}{} - } - } - if len(seen) == 0 { - return len(assets) - } - return len(seen) -} - -func assetHost(a output.Asset) string { - v := output.FirstNonEmpty(a.Target, a.Key, a.Title) - if i := strings.Index(v, "://"); i >= 0 { - v = v[i+3:] - } - if i := strings.IndexAny(v, "/?#"); i >= 0 { - v = v[:i] - } - if strings.Count(v, ":") == 1 { // host:port — drop the port, leave IPv6 alone - v = v[:strings.LastIndex(v, ":")] - } - return v -} - -func splitReportAssets(assets []output.Asset) (rich, bare []output.Asset) { - for _, a := range assets { - if assetIsBare(a) { - bare = append(bare, a) - } else { - rich = append(rich, a) - } - } - return rich, bare -} - -// assetIsBare is true for a live host that only answered with non-web services -// (an icmp echo, a bare tcp port) — nothing worth its own section. -func assetIsBare(a output.Asset) bool { - hasService := false - for _, item := range a.Items { - if item.Kind != output.AssetItemService { - return false - } - hasService = true - svc := strings.ToLower(output.AssetDataString(item.Data, "service") + " " + output.AssetDataString(item.Data, "protocol")) - if strings.Contains(svc, "http") { - return false - } - } - return hasService -} - -func (w *reportWriter) writeAsset(asset output.Asset) { - title := output.FirstNonEmpty(asset.Title, asset.Target, asset.Key, w.tr("资产", "Asset")) - if asset.Target != "" && asset.Target != title { - fmt.Fprintf(w, "### %s — `%s`\n\n", title, asset.Target) - } else { - fmt.Fprintf(w, "### %s\n\n", title) - } - - w.writeFact(w.tr("开放服务", "Services"), assetServiceFacts(asset.Items)) - w.writeFact(w.tr("HTTP 响应", "HTTP"), assetHTTPStatuses(asset.Items)) - w.writeFact(w.tr("Web 指纹", "Fingerprints"), assetFingers(asset.Items)) - if paths := assetPathCount(asset.Items); paths > 0 { - fmt.Fprintf(w, "- %s%s%s\n", w.tr("已探测路径", "Paths"), w.sep(), w.tr(fmt.Sprintf("%d 条", paths), fmt.Sprintf("%d", paths))) - } - if asset.Status != "" { - fmt.Fprintf(w, "- %s%s%s\n", w.tr("状态", "State"), w.sep(), markdownCode(asset.Status)) - } - w.WriteString("\n") - - w.writeLootMarkdown(asset.Items) -} - -func (w *reportWriter) writeBareAsset(asset output.Asset) { - host := output.FirstNonEmpty(asset.Target, asset.Title, asset.Key) - if services := assetServiceFacts(asset.Items); len(services) > 0 { - fmt.Fprintf(w, "- `%s` · %s\n", host, strings.Join(services, ", ")) - } else { - fmt.Fprintf(w, "- `%s`\n", host) - } -} - -func (w *reportWriter) writeFact(label string, values []string) { - if len(values) == 0 { - return - } - coded := make([]string, 0, len(values)) - for _, value := range values { - coded = append(coded, markdownCode(value)) - } - fmt.Fprintf(w, "- %s%s%s\n", label, w.sep(), strings.Join(coded, w.tr("、", ", "))) -} - -func (w *reportWriter) writeLootMarkdown(items []output.AssetItem) { - wrote := false - for _, item := range items { - switch item.Kind { - case output.AssetItemLoot, output.AssetItemNote, output.AssetItemResponse, output.AssetItemError: - summary := output.FirstNonEmpty(item.Summary, item.Title) - detail := output.AssetItemDetail(item) - if summary == "" && detail == "" { - continue - } - if !wrote { - w.WriteString("#### " + w.tr("分析研判", "Analysis") + "\n\n") - wrote = true - } - if summary == "" { - summary = firstMarkdownLine(detail) - } - fmt.Fprintf(w, "##### %s\n\n", markdownHeading(summary)) - if detail != "" && !sameMarkdownText(summary, detail) { - writeMarkdownBlock(&w.Builder, detail) - } else if detail == "" && summary != "" { - w.WriteString(summary) - w.WriteString("\n\n") - } - } - } -} - -func firstMarkdownLine(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - if idx := strings.IndexByte(value, '\n'); idx >= 0 { - return strings.TrimSpace(value[:idx]) - } - return value -} - -func sameMarkdownText(left, right string) bool { - return strings.TrimSpace(left) == strings.TrimSpace(right) -} - -func writeMarkdownBlock(sb *strings.Builder, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - sb.WriteString(value) - sb.WriteString("\n\n") -} - -func assetServiceFacts(items []output.AssetItem) []string { - var values []string - for _, item := range items { - if item.Kind != output.AssetItemService { - continue - } - values = append(values, strings.Join(output.CompactStrings( - output.AssetDataString(item.Data, "protocol"), - output.AssetDataString(item.Data, "service"), - output.AssetDataString(item.Data, "port"), - ), " ")) - } - return output.CompactStrings(values...) -} - -func assetHTTPStatuses(items []output.AssetItem) []string { - var values []string - for _, item := range items { - if item.Kind == output.AssetItemPath && item.Status != "" { - values = append(values, item.Status) - } - } - return output.CompactStrings(values...) -} - -func assetFingers(items []output.AssetItem) []string { - var values []string - for _, item := range items { - switch item.Kind { - case output.AssetItemFingerprint: - values = append(values, output.FirstNonEmpty(item.Title, output.AssetDataString(item.Data, "name"))) - case output.AssetItemPath: - values = append(values, output.AssetDataStrings(item.Data, "fingers")...) - } - } - return output.CompactStrings(values...) -} - -func assetPathCount(items []output.AssetItem) int { - count := 0 - for _, item := range items { - if item.Kind == output.AssetItemPath { - count++ - } - } - return count -} - -func resultFingerprintCount(result *output.Result) int { - if result == nil { - return 0 - } - seen := make(map[string]struct{}) - for _, asset := range result.Assets { - for _, finger := range assetFingers(asset.Items) { - seen[strings.ToLower(finger)] = struct{}{} - } - } - return len(seen) -} - -func markdownCode(value string) string { - value = strings.ReplaceAll(value, "`", "'") - return "`" + value + "`" -} - -func markdownHeading(value string) string { - value = strings.TrimSpace(value) - value = strings.ReplaceAll(value, "\n", " ") - if value == "" { - return "Analysis" - } - return strings.TrimLeft(value, "# ") -} - func generateID() string { b := make([]byte, 16) _, _ = rand.Read(b) diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index 8ff158e8..4c8724b1 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/aop" + xeval "github.com/chainreactors/aiscan/core/aop/x/eval" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" - xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" ) // A saturated subscriber buffer must never swallow a reliable terminal event. diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index f9e55441..bdb429f9 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -8,8 +8,8 @@ import ( "strings" "time" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/webproto" _ "modernc.org/sqlite" ) diff --git a/pkg/web/store_sqlite_test.go b/pkg/web/store_sqlite_test.go index 714d6427..fae1760a 100644 --- a/pkg/web/store_sqlite_test.go +++ b/pkg/web/store_sqlite_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" ) func createStoredSession(t *testing.T, store *SQLiteStore, id string) { diff --git a/pkg/web/types.go b/pkg/web/types.go index b6206301..58618590 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -5,8 +5,8 @@ import ( "errors" "time" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index 142c920d..9244c832 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -9,11 +9,11 @@ import ( "path/filepath" "strings" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/runner" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" @@ -37,7 +37,7 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg return err } - appConfig := cfg.AppConfig(option, cfg.RuntimeFeatures{ + appConfig := runner.AppConfig(option, runner.RuntimeFeatures{ ProviderEnabled: true, ProviderOptional: true, ToolsEnabled: true, AIEnabled: true, }, logger) appConfig.IOA = remoteIOAConfig(option, identityRef) @@ -46,7 +46,7 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg return err } defer application.Close() - cfg.ApplyResolvedProviderOptions(option, application.ProviderConfig) + runner.ApplyResolvedProviderOptions(option, application.ProviderConfig) rt, err := runner.NewAgentRuntime(ctx, option, logger, &runner.RuntimeConfig{ ExistingApp: application, NoOutput: true, REPLMode: runner.REPLPersistent, }) @@ -172,7 +172,7 @@ func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, app *runner.Ap logger.Warnf("config reload: fetch remote config: %s", err) return nil, "", err } - providerConfig := cfg.ProviderConfig(remoteOpt) + providerConfig := runner.ProviderConfig(remoteOpt) resolved, err := agent.ResolveProvider(&providerConfig) if err != nil { logger.Warnf("config reload: resolve provider: %s", err) @@ -321,11 +321,11 @@ func webNodeRef(option *cfg.Option) (protocols.NodeRef, error) { return protocols.NodeRef{ID: name, Authority: authority}, nil } -func remoteIOAConfig(option *cfg.Option, ref protocols.NodeRef) *cfg.IOAConfig { +func remoteIOAConfig(option *cfg.Option, ref protocols.NodeRef) *runner.IOAConfig { if option == nil || option.IOAURL == "" { return nil } - return &cfg.IOAConfig{ + return &runner.IOAConfig{ URL: option.IOAURL, NodeID: option.IOANodeID, NodeName: option.IOANodeName, diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go index 630acc9b..1c550f6c 100644 --- a/pkg/webagent/agent_test.go +++ b/pkg/webagent/agent_test.go @@ -13,10 +13,11 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/aop" + "github.com/chainreactors/aiscan/core/capability" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" @@ -346,7 +347,7 @@ func TestRunConnectionPTYRoundTrip(t *testing.T) { defer cancel() reg := commands.NewRegistry() - commands.BuildGroup("core", &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5}, reg) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5}, reg) done := make(chan error, 1) go func() { @@ -427,7 +428,7 @@ func TestRunConnectionPushesPTYSessionsOnManagerEvents(t *testing.T) { defer cancel() reg := commands.NewRegistry() - commands.BuildGroup("core", &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5}, reg) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5}, reg) mgr := RegistryPTYManager(reg) if mgr == nil { t.Fatal("bash command did not expose tmux manager") diff --git a/pkg/webagent/aop_tool.go b/pkg/webagent/aop_tool.go index 75511a33..9a6ff8ad 100644 --- a/pkg/webagent/aop_tool.go +++ b/pkg/webagent/aop_tool.go @@ -8,10 +8,10 @@ import ( "strings" "time" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/webagent/aop_tool_test.go b/pkg/webagent/aop_tool_test.go index dee00af7..7c79ab25 100644 --- a/pkg/webagent/aop_tool_test.go +++ b/pkg/webagent/aop_tool_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go index 071d96e1..88888bca 100644 --- a/pkg/webagent/connection.go +++ b/pkg/webagent/connection.go @@ -8,12 +8,12 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" diff --git a/pkg/webagent/connection_lifecycle_test.go b/pkg/webagent/connection_lifecycle_test.go index 2d908215..a5233f4c 100644 --- a/pkg/webagent/connection_lifecycle_test.go +++ b/pkg/webagent/connection_lifecycle_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/gorilla/websocket" diff --git a/pkg/webagent/pty.go b/pkg/webagent/pty.go index b13c678b..a7b557e2 100644 --- a/pkg/webagent/pty.go +++ b/pkg/webagent/pty.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/agent/tmux" + "github.com/chainreactors/aiscan/agent/tmux" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/utils/pty" diff --git a/pkg/webagent/stream.go b/pkg/webagent/stream.go index 5d553f3f..1e091fc1 100644 --- a/pkg/webagent/stream.go +++ b/pkg/webagent/stream.go @@ -3,7 +3,7 @@ package webagent import ( "sync" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/webagent/toolnode.go b/pkg/webagent/toolnode.go index a2fc7e18..23a6a7de 100644 --- a/pkg/webagent/toolnode.go +++ b/pkg/webagent/toolnode.go @@ -9,8 +9,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" ) diff --git a/pkg/webagent/toolnode_test.go b/pkg/webagent/toolnode_test.go index 7a7cb120..9fbca5ca 100644 --- a/pkg/webagent/toolnode_test.go +++ b/pkg/webagent/toolnode_test.go @@ -14,9 +14,9 @@ import ( "github.com/gorilla/websocket" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/webproto" ) diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go index e1225fb2..71a60261 100644 --- a/pkg/webproto/message.go +++ b/pkg/webproto/message.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" ) diff --git a/pkg/webproto/message_test.go b/pkg/webproto/message_test.go index 799759e5..320fa7f8 100644 --- a/pkg/webproto/message_test.go +++ b/pkg/webproto/message_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/utils/pty" ) diff --git a/skills/availability.go b/skills/availability.go index 68aaa763..e9711891 100644 --- a/skills/availability.go +++ b/skills/availability.go @@ -1,15 +1,7 @@ package skills -var blocked = map[string]bool{ - "katana": true, - "passive": true, -} - -//nolint:unused // called from build-tagged files -func enableSkill(name string) { - delete(blocked, name) -} +import "github.com/chainreactors/aiscan/core/capability" func skillAvailable(name string) bool { - return !blocked[name] + return capability.SkillEnabled(name) } diff --git a/skills/availability_full.go b/skills/availability_full.go deleted file mode 100644 index d8096464..00000000 --- a/skills/availability_full.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build full - -package skills - -func init() { - enableSkill("katana") - enableSkill("passive") -} diff --git a/test-skips.json b/test-skips.json new file mode 100644 index 00000000..aaa353fd --- /dev/null +++ b/test-skips.json @@ -0,0 +1,198 @@ +[ + { + "path": "agent/agent_test.go", + "format": "unix-only test", + "count": 5, + "category": "platform", + "reason": "Exercises POSIX process, signal, or shell semantics and is covered by Linux CI." + }, + { + "path": "agent/agent_test.go", + "format": "python3 not found", + "count": 1, + "category": "external_runtime", + "reason": "The subagent process fixture requires a locally installed Python 3 interpreter." + }, + { + "path": "agent/agent_test.go", + "format": "no LIVE_TEST_API_KEY set; skipping live LLM test", + "count": 1, + "category": "live_llm", + "reason": "Calls a live model and therefore requires an explicitly supplied credential." + }, + { + "path": "agent/helpers_test.go", + "format": "set TEST_API_KEY, TEST_BASE_URL, TEST_MODEL to run live tests", + "count": 1, + "category": "live_llm", + "reason": "Provider conformance against a live endpoint requires explicit credentials and model selection." + }, + { + "path": "agent/provider/cache_test.go", + "format": "set TEST_API_KEY, TEST_BASE_URL, TEST_MODEL to run live cache test", + "count": 1, + "category": "live_llm", + "reason": "Prompt-cache behavior can only be verified against a credentialed live provider." + }, + { + "path": "agent/provider/cache_test.go", + "format": "%s: provider does not support streaming", + "count": 1, + "category": "capability", + "reason": "The shared provider matrix includes implementations that intentionally do not expose streaming." + }, + { + "path": "agent/provider/cache_test.go", + "format": "set TEST_API_KEY, TEST_BASE_URL, TEST_MODEL", + "count": 1, + "category": "live_llm", + "reason": "Live multi-provider cache coverage requires explicit endpoint credentials." + }, + { + "path": "agent/tmux/manager_test.go", + "format": "unix-only test", + "count": 19, + "category": "platform", + "reason": "Validates PTY, process-group, and signal behavior provided only by the Unix tmux implementation." + }, + { + "path": "agent/tmux/manager_test.go", + "format": "unix-only", + "count": 2, + "category": "platform", + "reason": "Validates Unix-only PTY and process lifecycle behavior." + }, + { + "path": "pkg/commands/bash_test.go", + "format": "unix-only test", + "count": 2, + "category": "platform", + "reason": "Exercises POSIX shell execution details covered by Linux CI." + }, + { + "path": "pkg/commands/bash_test.go", + "format": "unix-only", + "count": 8, + "category": "platform", + "reason": "Exercises POSIX shell, process, or permission semantics." + }, + { + "path": "pkg/commands/bash_test.go", + "format": "shell assertions are unix-only", + "count": 5, + "category": "platform", + "reason": "Assertions depend on POSIX shell syntax and command behavior." + }, + { + "path": "pkg/commands/tmux_test.go", + "format": "unix-only", + "count": 14, + "category": "platform", + "reason": "The tmux command integration requires Unix PTY and signal semantics." + }, + { + "path": "pkg/headless/engine_test.go", + "format": "skip: requires runtime DSL variables", + "count": 1, + "category": "capability", + "reason": "Two mixed HTTP/headless templates reference variables unavailable to the headless-only compile fixture." + }, + { + "path": "pkg/tui/console_test.go", + "format": "shell assertion is unix-only", + "count": 1, + "category": "platform", + "reason": "The assertion targets POSIX shell rendering." + }, + { + "path": "pkg/web/agents_test.go", + "format": "chromium not found, skipping browser e2e test", + "count": 1, + "category": "external_runtime", + "reason": "Browser E2E requires a Chromium-family executable; CI installs and exercises it." + }, + { + "path": "tools/arsenal/arsenal_tool_test.go", + "format": "skip network test in short mode", + "count": 1, + "category": "external_api", + "reason": "The case performs a real network fetch and is intentionally excluded by go test -short." + }, + { + "path": "tools/arsenal/arsenal_tool_test.go", + "format": "e2e only on linux/amd64", + "count": 1, + "category": "platform", + "reason": "The external arsenal binary fixture is published only for linux/amd64." + }, + { + "path": "tools/functional_integration_full_test.go", + "format": "set AISCAN_INTEGRATION=1 to run public network regression tests", + "count": 1, + "category": "external_api", + "reason": "The scheduled full scanner regression contacts public network targets." + }, + { + "path": "tools/functional_integration_test.go", + "format": "set AISCAN_INTEGRATION=1 to run public network regression tests", + "count": 1, + "category": "external_api", + "reason": "The scheduled scanner regression contacts public network targets." + }, + { + "path": "tools/ioa/commands_test.go", + "format": "set LIVE_TEST_API_KEY or DEEPSEEK_API_KEY to run live LLM IOA test", + "count": 1, + "category": "live_llm", + "reason": "The IOA integration calls a live model endpoint and requires an explicit credential." + }, + { + "path": "tools/playwright/browser_test.go", + "format": "no Chromium/Chrome found, skipping browser integration test", + "count": 1, + "category": "external_runtime", + "reason": "The browser integration requires a locally installed Chromium-family executable." + }, + { + "path": "tools/playwright/recorder_test.go", + "format": "no Chromium/Chrome found, skipping browser integration test", + "count": 1, + "category": "external_runtime", + "reason": "The recorder integration requires a locally installed Chromium-family executable." + }, + { + "path": "tools/proton/command_test.go", + "format": "unix-only", + "count": 1, + "category": "platform", + "reason": "The command fixture asserts POSIX file permission semantics." + }, + { + "path": "tools/register_command_integration_test.go", + "format": "set AISCAN_INTEGRATION=1 to run", + "count": 2, + "category": "external_api", + "reason": "The passive search integrations are opt-in because they call external services." + }, + { + "path": "tools/register_command_integration_test.go", + "format": "FOFA_EMAIL / FOFA_KEY required", + "count": 1, + "category": "external_api", + "reason": "The FOFA integration requires user-supplied service credentials." + }, + { + "path": "tools/register_command_integration_test.go", + "format": "HUNTER_TOKEN or HUNTER_API_KEY required", + "count": 1, + "category": "external_api", + "reason": "The Hunter integration requires a user-supplied service credential." + }, + { + "path": "web/frontend/e2e/aiscan-web.spec.ts", + "format": "LLM_API_KEY env var required", + "count": 2, + "category": "live_llm", + "reason": "Provider connectivity and model discovery checks are retained as opt-in live endpoint coverage." + } +] diff --git a/tools/arsenal/register.go b/tools/arsenal/register.go index e0b8a550..59c0613e 100644 --- a/tools/arsenal/register.go +++ b/tools/arsenal/register.go @@ -1,10 +1,14 @@ package arsenal -import "github.com/chainreactors/aiscan/pkg/commands" +import ( + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/pkg/commands" +) func init() { + capability.Register(capability.Descriptor{ID: "arsenal", Kind: capability.KindTool, Group: "arsenal"}) commands.RegisterFactory(commands.Factory{ - Group: "arsenal", + Capability: "arsenal", Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { logger := deps.GetLogger() diff --git a/tools/capability_test.go b/tools/capability_test.go new file mode 100644 index 00000000..4db178aa --- /dev/null +++ b/tools/capability_test.go @@ -0,0 +1,10 @@ +package tools + +import ( + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/pkg/commands" +) + +func buildTestGroups(groups []string, deps *commands.Deps, reg *commands.CommandRegistry) { + commands.BuildPlan(capability.Select(capability.Options{Groups: groups}), deps, reg) +} diff --git a/tools/functional_integration_full_test.go b/tools/functional_integration_full_test.go index e84ab212..9dfa19d9 100644 --- a/tools/functional_integration_full_test.go +++ b/tools/functional_integration_full_test.go @@ -9,10 +9,11 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" _ "github.com/chainreactors/aiscan/tools/katana" "github.com/chainreactors/aiscan/tools/scan/engine" ) @@ -25,9 +26,9 @@ func TestFullScannerPublicIntegration(t *testing.T) { bus := eventbus.New[output.ToolDataEvent]() recorder := newFunctionalRecorder(bus) registry := commands.NewRegistry() - commands.BuildGroup("scanner", &commands.Deps{ - WorkDir: t.TempDir(), EngineSet: &engine.Set{}, DataBus: bus, Logger: telemetry.NopLogger(), - }, registry) + deps := &commands.Deps{WorkDir: t.TempDir(), DataBus: bus, Logger: telemetry.NopLogger()} + commands.Provide(deps, engine.SetKey, &engine.Set{}) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"scanner"}}), deps, registry) runFunctionalCases(t, registry, recorder, []functionalCase{{ Name: "katana/redhaze-depth-one", Tool: "katana", diff --git a/tools/functional_integration_test.go b/tools/functional_integration_test.go index 3a415a16..3dbe9689 100644 --- a/tools/functional_integration_test.go +++ b/tools/functional_integration_test.go @@ -10,11 +10,12 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/resources" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" _ "github.com/chainreactors/aiscan/tools/gogo" _ "github.com/chainreactors/aiscan/tools/neutron" "github.com/chainreactors/aiscan/tools/scan/engine" @@ -38,10 +39,13 @@ func TestScannerPublicIntegration(t *testing.T) { bus := eventbus.New[output.ToolDataEvent]() recorder := newFunctionalRecorder(bus) registry := commands.NewRegistry() - commands.BuildGroup("scanner", &commands.Deps{ - WorkDir: t.TempDir(), EngineSet: engineSet, Resources: engineSet.Resources, + deps := &commands.Deps{ + WorkDir: t.TempDir(), DataBus: bus, Logger: telemetry.NopLogger(), - }, registry) + } + commands.Provide(deps, engine.SetKey, engineSet) + commands.Provide(deps, resources.SetKey, engineSet.Resources) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"scanner"}}), deps, registry) templateFile := filepath.Join(t.TempDir(), "redhaze-marker.yaml") writeTestFile(t, templateFile, `id: redhaze-public-marker info: diff --git a/tools/functional_norace_test.go b/tools/functional_norace_test.go deleted file mode 100644 index 7deb8dd4..00000000 --- a/tools/functional_norace_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !race - -package tools - -const functionalRaceEnabled = false diff --git a/tools/functional_race_test.go b/tools/functional_race_test.go deleted file mode 100644 index 424133b0..00000000 --- a/tools/functional_race_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build race - -package tools - -const functionalRaceEnabled = true diff --git a/tools/functional_regression_full_test.go b/tools/functional_regression_full_test.go index d1457a96..fa05cc1b 100644 --- a/tools/functional_regression_full_test.go +++ b/tools/functional_regression_full_test.go @@ -9,10 +9,11 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" _ "github.com/chainreactors/aiscan/tools/katana" passivecmd "github.com/chainreactors/aiscan/tools/passive" "github.com/chainreactors/aiscan/tools/scan/engine" @@ -25,12 +26,13 @@ func TestFullScannerFunctionalRegression(t *testing.T) { recorder := newFunctionalRecorder(bus) registry := commands.NewRegistry() engineSet := &engine.Set{} - commands.BuildGroup("scanner", &commands.Deps{ - WorkDir: t.TempDir(), - EngineSet: engineSet, - DataBus: bus, - Logger: telemetry.NopLogger(), - }, registry) + deps := &commands.Deps{ + WorkDir: t.TempDir(), + DataBus: bus, + Logger: telemetry.NopLogger(), + } + commands.Provide(deps, engine.SetKey, engineSet) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"scanner"}}), deps, registry) passiveEngine := &functionalPassiveEngine{} passive := passivecmd.New(passiveEngine).WithLogger(telemetry.NopLogger()) diff --git a/tools/functional_regression_test.go b/tools/functional_regression_test.go index 3547584c..5d62fe1d 100644 --- a/tools/functional_regression_test.go +++ b/tools/functional_regression_test.go @@ -16,11 +16,12 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/resources" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" _ "github.com/chainreactors/aiscan/tools/gogo" _ "github.com/chainreactors/aiscan/tools/neutron" _ "github.com/chainreactors/aiscan/tools/proton" @@ -59,13 +60,14 @@ func TestScannerFunctionalRegression(t *testing.T) { bus := eventbus.New[output.ToolDataEvent]() recorder := newFunctionalRecorder(bus) registry := commands.NewRegistry() - commands.BuildGroup("scanner", &commands.Deps{ - WorkDir: workDir, - EngineSet: engineSet, - Resources: engineSet.Resources, - DataBus: bus, - Logger: telemetry.NopLogger(), - }, registry) + deps := &commands.Deps{ + WorkDir: workDir, + DataBus: bus, + Logger: telemetry.NopLogger(), + } + commands.Provide(deps, engine.SetKey, engineSet) + commands.Provide(deps, resources.SetKey, engineSet.Resources) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"scanner"}}), deps, registry) required := []string{"scan", "gogo", "spray", "zombie", "neutron", "proton"} for _, name := range required { @@ -97,8 +99,7 @@ http: cases := []functionalCase{ { Name: "gogo/http-fingerprint-jsonl", Tool: "gogo", - Args: []string{"-i", host, "-p", port, "-v", "-o", "jl", "-t", "20"}, - SkipUnderRace: true, + Args: []string{"-i", host, "-p", port, "-v", "-o", "jl", "-t", "20"}, Check: func(t *testing.T, result functionalResult) { requireOutputContains(t, result, `"port":"`+port+`"`, "nginx") requireEvent(t, result, "gogo", output.ToolDataService, func(data any) bool { @@ -113,8 +114,7 @@ http: }, { Name: "gogo/target-file", Tool: "gogo", - Args: []string{"-l", targetsFile, "-p", port, "-o", "jl", "-t", "20"}, - SkipUnderRace: true, + Args: []string{"-l", targetsFile, "-p", port, "-o", "jl", "-t", "20"}, Check: func(t *testing.T, result functionalResult) { requireOutputContains(t, result, `"port":"`+port+`"`) }, @@ -196,9 +196,8 @@ http: }, { Name: "scan/quick-pipeline", Tool: "scan", - Args: []string{"-i", host, "--ports", port, "--mode", "quick", "--verify=off", "--timeout", "2", "--no-color"}, - Timeout: 30 * time.Second, - SkipUnderRace: true, + Args: []string{"-i", host, "--ports", port, "--mode", "quick", "--verify=off", "--timeout", "2", "--no-color"}, + Timeout: 30 * time.Second, Check: func(t *testing.T, result functionalResult) { requireOutputContains(t, result, "[summary] completed", port) requireEvent(t, result, "gogo", output.ToolDataService, nil) diff --git a/tools/functional_testkit_test.go b/tools/functional_testkit_test.go index d32e0348..e36121a4 100644 --- a/tools/functional_testkit_test.go +++ b/tools/functional_testkit_test.go @@ -22,13 +22,12 @@ type functionalResult struct { } type functionalCase struct { - Name string - Tool string - Args []string - Stdin string - Timeout time.Duration - SkipUnderRace bool - Check func(*testing.T, functionalResult) + Name string + Tool string + Args []string + Stdin string + Timeout time.Duration + Check func(*testing.T, functionalResult) } type functionalRecorder struct { @@ -62,9 +61,6 @@ func runFunctionalCases(t *testing.T, registry *commands.CommandRegistry, record t.Helper() for _, testCase := range cases { t.Run(testCase.Name, func(t *testing.T) { - if functionalRaceEnabled && testCase.SkipUnderRace { - t.Skip("upstream scanner has a known internal race") - } if !registry.Has(testCase.Tool) { t.Fatalf("tool %q is not registered", testCase.Tool) } diff --git a/tools/gogo/gogo.go b/tools/gogo/gogo.go index 71b42381..bfa07f4a 100644 --- a/tools/gogo/gogo.go +++ b/tools/gogo/gogo.go @@ -9,8 +9,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/toolargs" gogocore "github.com/chainreactors/gogo/v2/core" "github.com/chainreactors/sdk/gogo" diff --git a/tools/gogo/gogo_test.go b/tools/gogo/gogo_test.go index 93dcef73..04e7cef6 100644 --- a/tools/gogo/gogo_test.go +++ b/tools/gogo/gogo_test.go @@ -9,8 +9,8 @@ import ( "sync/atomic" "testing" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" gogopkg "github.com/chainreactors/gogo/v2/pkg" sdkgogo "github.com/chainreactors/sdk/gogo" ) diff --git a/tools/gogo/register.go b/tools/gogo/register.go index 65eb87a7..5869e3a1 100644 --- a/tools/gogo/register.go +++ b/tools/gogo/register.go @@ -1,22 +1,28 @@ package gogo import ( - cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { - cfg.ExtraScannerUsage["gogo"] = func() string { return New(nil).Usage() } + capability.Register(capability.Descriptor{ + ID: "gogo", Kind: capability.KindScanner, Group: "scanner", + CLIName: "gogo", Summary: "gogo", UsageLine: " gogo Run gogo directly", + Usage: func() string { return New(nil).Usage() }, Requires: []string{"scan.engine.Set.Gogo"}, + }) commands.RegisterFactory(commands.Factory{ - Group: "scanner", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - es, _ := deps.EngineSet.(*engine.Set) - if es == nil || es.Gogo == nil { + Capability: "gogo", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + es, ok := deps.Get(d.Bag, engine.SetKey) + if !ok || es == nil || es.Gogo == nil { + d.Skip("gogo", deps.Name(engine.SetKey)+".Gogo") return } - impl := New(es.Gogo).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus) - reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), QuickReference: impl.QuickReference(), Run: impl.Run, SetProxy: impl.SetProxy, GetProxy: func() string { return impl.Proxy }}, "scanner") + impl := New(es.Gogo).WithLogger(d.GetLogger()).WithProxy(d.ScannerProxy).WithDataBus(d.DataBus) + reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), QuickReference: impl.QuickReference(), Run: impl.Run}, "scanner") }, }) } diff --git a/tools/ioa/commands.go b/tools/ioa/commands.go index 9d939458..e6034a2e 100644 --- a/tools/ioa/commands.go +++ b/tools/ioa/commands.go @@ -9,8 +9,8 @@ import ( "strings" "sync" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/ioa/protocols" ) diff --git a/tools/ioa/commands_test.go b/tools/ioa/commands_test.go index b027edb0..f01b33d6 100644 --- a/tools/ioa/commands_test.go +++ b/tools/ioa/commands_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/agent" + "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/ioa/protocols" ) diff --git a/tools/ioa/keys.go b/tools/ioa/keys.go new file mode 100644 index 00000000..1fac42d7 --- /dev/null +++ b/tools/ioa/keys.go @@ -0,0 +1,9 @@ +package ioa + +import ( + "github.com/chainreactors/aiscan/core/deps" + "github.com/chainreactors/ioa/protocols" +) + +// ClientKey carries the bound IOA client to the ioa command factory. +var ClientKey = deps.NewKey[protocols.ClientAPI]("ioa.Client") diff --git a/tools/ioa/register.go b/tools/ioa/register.go index f5097b5e..be04f1fc 100644 --- a/tools/ioa/register.go +++ b/tools/ioa/register.go @@ -1,8 +1,9 @@ package ioa import ( + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/ioa/protocols" _ "github.com/chainreactors/ioa/protocols/checkpoint" _ "github.com/chainreactors/ioa/protocols/handoff" @@ -10,14 +11,19 @@ import ( ) func init() { + capability.Register(capability.Descriptor{ + ID: "ioa", Kind: capability.KindService, Group: "ioa", + Requires: []string{"ioa.ClientAPI"}, + }) commands.RegisterFactory(commands.Factory{ - Group: "ioa", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - client, _ := deps.IOAClient.(protocols.ClientAPI) - if client == nil { + Capability: "ioa", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + client, ok := deps.Get(d.Bag, ClientKey) + if !ok || client == nil { + d.Skip("ioa", deps.Name(ClientKey)) return } - for _, cmd := range NewCommands(client, deps.NodeName, deps.NodeMeta) { + for _, cmd := range NewCommands(client, d.NodeName, d.NodeMeta) { reg.Register(cmd, "ioa") } }, diff --git a/tools/katana/katana.go b/tools/katana/katana.go index e52d2e06..a6cd5ce6 100644 --- a/tools/katana/katana.go +++ b/tools/katana/katana.go @@ -13,8 +13,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/toolargs" "github.com/projectdiscovery/goflags" "github.com/projectdiscovery/gologger" diff --git a/tools/katana/register.go b/tools/katana/register.go index 3d4a08da..404f5a09 100644 --- a/tools/katana/register.go +++ b/tools/katana/register.go @@ -3,12 +3,14 @@ package katana import ( + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/pkg/commands" ) func init() { + capability.Register(capability.Descriptor{ID: "katana", Kind: capability.KindScanner, Group: "scanner", CLIName: "katana", Summary: "katana", UsageLine: " katana Run katana web crawler", Usage: func() string { return New().Usage() }, Skills: []string{"katana"}}) commands.RegisterFactory(commands.Factory{ - Group: "scanner", + Capability: "katana", Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { logger := deps.GetLogger() impl := New().WithLogger(logger).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus) diff --git a/tools/neutron/neutron.go b/tools/neutron/neutron.go index ca0ac53e..c0078884 100644 --- a/tools/neutron/neutron.go +++ b/tools/neutron/neutron.go @@ -14,8 +14,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" scanengine "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/neutron/templates" diff --git a/tools/neutron/register.go b/tools/neutron/register.go index 9a83bc4a..f2807fc2 100644 --- a/tools/neutron/register.go +++ b/tools/neutron/register.go @@ -1,22 +1,28 @@ package neutron import ( - cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { - cfg.ExtraScannerUsage["neutron"] = func() string { return New(nil, nil).Usage() } + capability.Register(capability.Descriptor{ + ID: "neutron", Kind: capability.KindScanner, Group: "scanner", + CLIName: "neutron", Summary: "neutron", UsageLine: " neutron Run neutron directly", + Usage: func() string { return New(nil, nil).Usage() }, Requires: []string{"scan.engine.Set.Neutron"}, + }) commands.RegisterFactory(commands.Factory{ - Group: "scanner", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - es, _ := deps.EngineSet.(*engine.Set) - if es == nil || es.Neutron == nil { + Capability: "neutron", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + es, ok := deps.Get(d.Bag, engine.SetKey) + if !ok || es == nil || es.Neutron == nil { + d.Skip("neutron", deps.Name(engine.SetKey)+".Neutron") return } - impl := New(es.Neutron, es.Index).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus) - reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), QuickReference: impl.QuickReference(), Run: impl.Run, SetProxy: impl.SetProxy, GetProxy: func() string { return impl.Proxy }}, "scanner") + impl := New(es.Neutron, es.Index).WithLogger(d.GetLogger()).WithProxy(d.ScannerProxy).WithDataBus(d.DataBus) + reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), QuickReference: impl.QuickReference(), Run: impl.Run}, "scanner") }, }) } diff --git a/tools/neutron/sdk_stage.go b/tools/neutron/sdk_stage.go index e9832d44..7188d41d 100644 --- a/tools/neutron/sdk_stage.go +++ b/tools/neutron/sdk_stage.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" scanengine "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/neutron/common" "github.com/chainreactors/neutron/templates" diff --git a/tools/passive/passive.go b/tools/passive/passive.go index 6a1f5f73..eda2e4e9 100644 --- a/tools/passive/passive.go +++ b/tools/passive/passive.go @@ -14,8 +14,8 @@ import ( "strings" "time" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/projectdiscovery/uncover/sources" ) diff --git a/tools/passive/register.go b/tools/passive/register.go index e4b6f275..b966821d 100644 --- a/tools/passive/register.go +++ b/tools/passive/register.go @@ -1,28 +1,37 @@ //go:build full +// Passive uses the full-only uncover engine API; the standard stub does not +// expose QueryRaw, RawFofa or RawHunter, so this is a real implementation gate. + package passive import ( - "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { - config.ExtraCommands["passive"] = true - config.ExtraUsageEntries = append(config.ExtraUsageEntries, " passive Run passive cyberspace recon") - config.ExtraSummaryEntries = append(config.ExtraSummaryEntries, "passive") - config.ExtraScannerUsage["passive"] = func() string { return New(nil).Usage() } + capability.Register(capability.Descriptor{ + ID: "passive", Kind: capability.KindScanner, Group: "scanner", + CLIName: "passive", Summary: "passive", UsageLine: " passive Run passive cyberspace recon", + Usage: func() string { return New(nil).Usage() }, Skills: []string{"passive"}, + Requires: []string{"scan.engine.Set.Uncover"}, + }) commands.RegisterFactory(commands.Factory{ - Group: "scanner", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { + Capability: "passive", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + // passive registers with a nil backend so its usage stays visible; + // every query then reports that no recon source is configured. var backend QueryEngine - if es, ok := deps.EngineSet.(*engine.Set); ok && es != nil && es.Uncover != nil { + if es, ok := deps.Get(d.Bag, engine.SetKey); ok && es != nil && es.Uncover != nil { backend = es.Uncover + } else { + d.Skip("passive.recon", deps.Name(engine.SetKey)+".Uncover") } - logger := deps.GetLogger() - impl := New(backend).WithLogger(logger) + impl := New(backend).WithLogger(d.GetLogger()) reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run}, "scanner") }, }) diff --git a/tools/playwright/advanced.go b/tools/playwright/advanced.go index 47004a09..bbbe0e09 100644 --- a/tools/playwright/advanced.go +++ b/tools/playwright/advanced.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/proto" "github.com/ysmood/gson" diff --git a/tools/playwright/browser.go b/tools/playwright/browser.go index 48ba4448..44fd2074 100644 --- a/tools/playwright/browser.go +++ b/tools/playwright/browser.go @@ -14,9 +14,9 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" @@ -576,6 +576,12 @@ func (c *Command) getOrLaunchBrowser() (*rod.Browser, error) { Set("disable-dev-shm-usage"). Set("ignore-certificate-errors"). Set("allow-insecure-localhost") + // Prefer an installed browser when one is available. Rod otherwise + // enters its auto-download path, which is inappropriate for offline CI + // and can block even though launcher.LookPath already found Chromium. + if browserPath, ok := launcher.LookPath(); ok { + l = l.Bin(browserPath) + } c.proxyMu.RLock() proxy := c.proxyURL diff --git a/tools/playwright/interact.go b/tools/playwright/interact.go index c807780d..342e9d9b 100644 --- a/tools/playwright/interact.go +++ b/tools/playwright/interact.go @@ -548,7 +548,7 @@ var keyNameMap = map[string]input.Key{ "home": input.Home, "end": input.End, "pageup": input.PageUp, "pagedown": input.PageDown, "insert": input.Insert, - "f1": input.F1, "f2": input.F2, "f3": input.F3, "f4": input.F4, + "f1": input.F1, "f2": input.F2, "f3": input.F3, "f4": input.F4, "f5": input.F5, "f6": input.F6, "f7": input.F7, "f8": input.F8, "f9": input.F9, "f10": input.F10, "f11": input.F11, "f12": input.F12, "shift": input.ShiftLeft, "control": input.ControlLeft, diff --git a/tools/playwright/register.go b/tools/playwright/register.go index 1751e1cf..a1d58326 100644 --- a/tools/playwright/register.go +++ b/tools/playwright/register.go @@ -2,11 +2,15 @@ package playwright -import "github.com/chainreactors/aiscan/pkg/commands" +import ( + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/pkg/commands" +) func init() { + capability.Register(capability.Descriptor{ID: "browser", Kind: capability.KindTool, Group: "browser", Optional: true, Default: true}) commands.RegisterFactory(commands.Factory{ - Group: "browser", + Capability: "browser", Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { impl := New(deps.WorkDir) reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run, Close: impl.Close}, "browser") diff --git a/tools/playwright/session.go b/tools/playwright/session.go index edb25766..fa51ab41 100644 --- a/tools/playwright/session.go +++ b/tools/playwright/session.go @@ -17,8 +17,8 @@ import ( "github.com/go-rod/rod" "github.com/go-rod/rod/lib/proto" "github.com/go-rod/stealth" - "github.com/ysmood/gson" katanajs "github.com/projectdiscovery/katana/pkg/engine/headless/js" + "github.com/ysmood/gson" ) const ( @@ -742,26 +742,26 @@ func (c *Command) execDetach(ctx context.Context, args []string) (string, error) type openOpts struct { commonOpts - sessName string - opTimeout time.Duration - noSpeedUp bool - ignoreHTTPSErrs bool - viewportSize string // "WxH" - geolocation string // "lat,lon" - timezone string - colorScheme string // light|dark - lang string - device string - loadStoragePath string - saveStoragePath string // stored on Session, dumped at close - saveHARPath string // stored on Session, dumped at close - saveHARGlob string - proxyServer string - proxyBypass string - blockSW bool - record bool - headed bool - cdpURL string + sessName string + opTimeout time.Duration + noSpeedUp bool + ignoreHTTPSErrs bool + viewportSize string // "WxH" + geolocation string // "lat,lon" + timezone string + colorScheme string // light|dark + lang string + device string + loadStoragePath string + saveStoragePath string // stored on Session, dumped at close + saveHARPath string // stored on Session, dumped at close + saveHARGlob string + proxyServer string + proxyBypass string + blockSW bool + record bool + headed bool + cdpURL string } func parseOpenOpts(args []string, usage string) (openOpts, error) { @@ -958,13 +958,13 @@ func parseGeolocation(s string) (float64, float64, error) { // storageState mirrors the Playwright storage state format. type storageState struct { - Cookies []json.RawMessage `json:"cookies"` - LocalStorage []localStorageEntry `json:"origins"` + Cookies []json.RawMessage `json:"cookies"` + LocalStorage []localStorageEntry `json:"origins"` } type localStorageEntry struct { - Origin string `json:"origin"` - LocalStorage []nameValuePair `json:"localStorage"` + Origin string `json:"origin"` + LocalStorage []nameValuePair `json:"localStorage"` } type nameValuePair struct { diff --git a/tools/proton/command.go b/tools/proton/command.go index af7adf37..210fbc55 100644 --- a/tools/proton/command.go +++ b/tools/proton/command.go @@ -17,8 +17,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/neutron/operators" "github.com/chainreactors/neutron/protocols" diff --git a/tools/proton/register.go b/tools/proton/register.go index bd513802..13783f20 100644 --- a/tools/proton/register.go +++ b/tools/proton/register.go @@ -1,26 +1,30 @@ package proton import ( - cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" ) func init() { - cfg.ExtraCommands["proton"] = true - cfg.ExtraUsageEntries = append(cfg.ExtraUsageEntries, " proton Run proton sensitive info scanner") - cfg.ExtraSummaryEntries = append(cfg.ExtraSummaryEntries, "proton") - cfg.ExtraScannerUsage["proton"] = func() string { return New().Usage() } + capability.Register(capability.Descriptor{ + ID: "proton", Kind: capability.KindScanner, Group: "scanner", + CLIName: "proton", Summary: "proton", UsageLine: " proton Run proton sensitive info scanner", + Usage: func() string { return New().Usage() }, + }) commands.RegisterFactory(commands.Factory{ - Group: "scanner", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - logger := deps.GetLogger() - cmd := New().WithLogger(logger).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus) - if rs, ok := deps.Resources.(*resources.Set); ok && rs != nil { + Capability: "proton", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + cmd := New().WithLogger(d.GetLogger()).WithProxy(d.ScannerProxy).WithDataBus(d.DataBus) + if rs, ok := deps.Get(d.Bag, resources.SetKey); ok && rs != nil { cmd.WithResourceProvider(rs.ProtonConfig) + } else { + // proton still runs, but only with its built-in rules. + d.Skip("proton.rules", deps.Name(resources.SetKey)) } - cmd.SetWorkDir(deps.WorkDir) - reg.Register(commands.Command{Name: cmd.Name(), Usage: cmd.Usage(), Run: cmd.Run, SetProxy: cmd.SetProxy, GetProxy: func() string { return cmd.Proxy }}, "scanner") + cmd.SetWorkDir(d.WorkDir) + reg.Register(commands.Command{Name: cmd.Name(), Usage: cmd.Usage(), Run: cmd.Run}, "scanner") }, }) } diff --git a/tools/proton/register_test.go b/tools/proton/register_test.go index 78e8a98b..b303847e 100644 --- a/tools/proton/register_test.go +++ b/tools/proton/register_test.go @@ -4,12 +4,13 @@ import ( "slices" "testing" + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/pkg/commands" ) func TestFactoryBuildsProtonWithScannerGroup(t *testing.T) { registry := commands.NewRegistry() - commands.BuildGroup("scanner", &commands.Deps{WorkDir: t.TempDir()}, registry) + commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"scanner"}}), &commands.Deps{WorkDir: t.TempDir()}, registry) if !registry.Has("proton") { t.Fatal("scanner group did not register proton") diff --git a/tools/proxy/command.go b/tools/proxy/command.go index fd1634fa..88ddfdb9 100644 --- a/tools/proxy/command.go +++ b/tools/proxy/command.go @@ -6,8 +6,8 @@ import ( "net/url" "strings" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/proxyclient" "github.com/chainreactors/proxyclient/extra/clash" goflags "github.com/jessevdk/go-flags" diff --git a/tools/proxy/mitm.go b/tools/proxy/mitm.go index 1ecbf8cc..6e28703d 100644 --- a/tools/proxy/mitm.go +++ b/tools/proxy/mitm.go @@ -9,8 +9,8 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" mitmproxy "github.com/chainreactors/utils/mitmproxy/proxy" goflags "github.com/jessevdk/go-flags" ) diff --git a/tools/proxy/mitm_test.go b/tools/proxy/mitm_test.go index f737e5e3..f9dd2631 100644 --- a/tools/proxy/mitm_test.go +++ b/tools/proxy/mitm_test.go @@ -177,9 +177,6 @@ func TestMITMCapture_NonHTTP_Fallback(t *testing.T) { } func TestMITMCapture_ServerFirst_Fallback(t *testing.T) { - if raceEnabled { - t.Skip("flaky under -race: mitmproxy internal goroutine scheduling causes i/o timeout on CI") - } // Server-first protocol (like SSH): server sends banner, client waits. // MITM should timeout on Peek and fallback to raw transfer. tcpServer, err := net.Listen("tcp", "127.0.0.1:0") @@ -187,16 +184,19 @@ func TestMITMCapture_ServerFirst_Fallback(t *testing.T) { t.Fatal(err) } defer tcpServer.Close() + serverReady := make(chan error, 1) go func() { - for { - conn, err := tcpServer.Accept() - if err != nil { - return - } - conn.Write([]byte("SSH-2.0-TestServer\r\n")) + conn, err := tcpServer.Accept() + if err != nil { + serverReady <- err + return + } + defer conn.Close() + _, err = conn.Write([]byte("SSH-2.0-TestServer\r\n")) + serverReady <- err + if err == nil { buf := make([]byte, 256) - conn.Read(buf) - conn.Close() + _, _ = conn.Read(buf) } }() @@ -209,31 +209,49 @@ func TestMITMCapture_ServerFirst_Fallback(t *testing.T) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() conn, err := dial(ctx, "tcp", tcpServer.Addr().String()) if err != nil { t.Fatal(err) } defer conn.Close() + select { + case err := <-serverReady: + if err != nil { + t.Fatalf("server banner write: %v", err) + } + case <-ctx.Done(): + t.Fatalf("server did not accept proxy connection: %v", ctx.Err()) + } buf := make([]byte, 64) - // The banner only arrives after the MITM's ~3s peek timeout expires and it - // falls back to raw transfer. Keep this deadline well above that timeout so - // scheduling jitter under -race / CI load can't race it (was 8s → flaky). - conn.SetReadDeadline(time.Now().Add(30 * time.Second)) - n, err := io.ReadAtLeast(conn, buf, 3) - if err != nil { - t.Fatalf("expected SSH banner data, got error: %v", err) + type readResult struct { + n int + err error + } + readDone := make(chan readResult, 1) + go func() { + n, err := io.ReadAtLeast(conn, buf, 3) + readDone <- readResult{n: n, err: err} + }() + var read readResult + select { + case read = <-readDone: + case <-ctx.Done(): + t.Fatalf("proxy did not enter raw fallback after synchronized banner write: %v", ctx.Err()) + } + if read.err != nil { + t.Fatalf("expected SSH banner data, got error: %v", read.err) } - banner := string(buf[:n]) + banner := string(buf[:read.n]) if !strings.Contains(banner, "SSH-") && !strings.Contains(banner, "SH-") { t.Fatalf("expected SSH banner fragment, got %q", banner) } if store.Count() != 0 { t.Fatalf("server-first protocol should not capture flows, got %d", store.Count()) } - t.Logf("server-first fallback OK: received %q, 0 flows captured", string(buf[:n])) + t.Logf("server-first fallback OK: received %q, 0 flows captured", string(buf[:read.n])) } // === Latency Benchmark === @@ -387,7 +405,7 @@ func BenchmarkFlowStore_Query(b *testing.B) { store.Add(Flow{ Method: "GET", URL: fmt.Sprintf("http://host%d.com/path%d", i%10, i), - StatusCode: 200 + (i % 5) * 100, + StatusCode: 200 + (i%5)*100, Host: fmt.Sprintf("host%d.com", i%10), }) } diff --git a/tools/proxy/race_norace_test.go b/tools/proxy/race_norace_test.go deleted file mode 100644 index 84a78f40..00000000 --- a/tools/proxy/race_norace_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !race - -package proxy - -const raceEnabled = false diff --git a/tools/proxy/race_test.go b/tools/proxy/race_test.go deleted file mode 100644 index b624eb94..00000000 --- a/tools/proxy/race_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build race - -package proxy - -const raceEnabled = true diff --git a/tools/proxy/register_command.go b/tools/proxy/register_command.go index 1a238201..43e9ebf4 100644 --- a/tools/proxy/register_command.go +++ b/tools/proxy/register_command.go @@ -6,6 +6,7 @@ import ( "net/url" "strings" + "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/proxyclient" @@ -18,8 +19,9 @@ import ( ) func init() { + capability.Register(capability.Descriptor{ID: "proxy", Kind: capability.KindService, Group: "proxy"}) commands.RegisterFactory(commands.Factory{ - Group: "proxy", + Capability: "proxy", Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { state := NewState(deps.ScannerProxy) cmd := New(state) diff --git a/tools/proxy/state.go b/tools/proxy/state.go index 6e10c17f..43270819 100644 --- a/tools/proxy/state.go +++ b/tools/proxy/state.go @@ -23,8 +23,8 @@ type State struct { subscribeURL string activeNode *clash.ProxyNode activeURL string - autoURL string // clash:// URL for auto mode - autoDial proxyclient.Dial // pre-built dial for auto mode + autoURL string // clash:// URL for auto mode + autoDial proxyclient.Dial // pre-built dial for auto mode } func NewState(originalProxy string) *State { @@ -151,7 +151,7 @@ func (s *State) TestNode(ctx context.Context, node *clash.ProxyNode) (time.Durat DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dial.DialContext(ctx, network, addr) }, - TLSClientConfig: &tls.Config{}, + TLSClientConfig: &tls.Config{}, DisableKeepAlives: true, } client := &http.Client{ diff --git a/tools/register_command.go b/tools/register_command.go index 33bf7f10..a75859d4 100644 --- a/tools/register_command.go +++ b/tools/register_command.go @@ -1,39 +1,40 @@ package tools import ( - cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/tools/scan" "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { - cfg.ExtraScannerUsage["scan"] = scan.Usage + capability.Register(capability.Descriptor{ + ID: "scan", Kind: capability.KindScanner, Group: "scanner", + CLIName: "scan", Summary: "scan", Usage: scan.Usage, + Requires: []string{"scan.engine.Set.Gogo", "scan.engine.Set.Spray"}, + }) commands.RegisterFactory(commands.Factory{ - Group: "scanner", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - es, _ := deps.EngineSet.(*engine.Set) - if es == nil { + Capability: "scan", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + es, ok := deps.Get(d.Bag, engine.SetKey) + if !ok || es == nil || es.Gogo == nil || es.Spray == nil { + d.Skip("scan", deps.Name(engine.SetKey)+".Gogo+.Spray") return } - var scanOpts []scan.Option - for _, o := range deps.ScanOpts { - if opt, ok := o.(scan.Option); ok { - scanOpts = append(scanOpts, opt) - } - } - if deps.ScannerProxy != "" { - scanOpts = append(scanOpts, scan.WithProxy(deps.ScannerProxy)) + // copy: the bag's slice is shared with every other build + stored, _ := deps.Get(d.Bag, scan.OptsKey) + scanOpts := append([]scan.Option(nil), stored...) + if d.ScannerProxy != "" { + scanOpts = append(scanOpts, scan.WithProxy(d.ScannerProxy)) } - if deps.DataBus != nil { - scanOpts = append(scanOpts, scan.WithDataBus(deps.DataBus)) + if d.DataBus != nil { + scanOpts = append(scanOpts, scan.WithDataBus(d.DataBus)) } - if es.Gogo != nil && es.Spray != nil { - impl := scan.New(es, scanOpts...) - reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run}, "scanner") - } + impl := scan.New(es, scanOpts...) + reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run}, "scanner") }, }) } diff --git a/tools/register_command_integration_test.go b/tools/register_command_integration_test.go index 4f31e33e..0f5478de 100644 --- a/tools/register_command_integration_test.go +++ b/tools/register_command_integration_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" passivecmd "github.com/chainreactors/aiscan/tools/passive" "github.com/chainreactors/aiscan/tools/scan/engine" ) diff --git a/tools/register_command_test.go b/tools/register_command_test.go index 5c6ad7d4..5d7ffd5c 100644 --- a/tools/register_command_test.go +++ b/tools/register_command_test.go @@ -29,11 +29,10 @@ import ( func buildRegistry(engineSet *engine.Set) *commands.CommandRegistry { reg := commands.NewRegistry() - deps := &commands.Deps{ - EngineSet: engineSet, - Resources: engineSet.Resources, - } - commands.BuildAll(deps, reg) + deps := &commands.Deps{} + commands.Provide(deps, engine.SetKey, engineSet) + commands.Provide(deps, resources.SetKey, engineSet.Resources) + buildTestGroups([]string{"scanner", "search"}, deps, reg) return reg } diff --git a/tools/scan/collector.go b/tools/scan/collector.go index f8854e9b..c0a81569 100644 --- a/tools/scan/collector.go +++ b/tools/scan/collector.go @@ -168,7 +168,14 @@ func (c *collector) TerminalString(color bool) string { } func (c *collector) ReportMarkdown() string { - return formatMarkdown(c) + return output.RenderReport(c.StructuredResult(), output.ReportOptions{ + Style: output.StyleMarkdown, + Title: "Scan Report", + Sitemap: true, + CollapseBare: true, + Metrics: true, + Inventory: true, + }) } func (c *collector) JSONLines() (string, error) { diff --git a/tools/scan/command.go b/tools/scan/command.go index c405df38..de4c5800 100644 --- a/tools/scan/command.go +++ b/tools/scan/command.go @@ -7,12 +7,12 @@ import ( "os" "path/filepath" + "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/aop" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/aiscan/tools/scan/pipeline" "github.com/chainreactors/aiscan/tools/toolargs" diff --git a/tools/scan/command_test.go b/tools/scan/command_test.go index 5bf8967a..5b91057c 100644 --- a/tools/scan/command_test.go +++ b/tools/scan/command_test.go @@ -16,8 +16,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/aiscan/tools/scan/pipeline" "github.com/chainreactors/fingers/common" diff --git a/tools/scan/engine/gogo.go b/tools/scan/engine/gogo.go index c5e9460c..9c097a7e 100644 --- a/tools/scan/engine/gogo.go +++ b/tools/scan/engine/gogo.go @@ -5,11 +5,11 @@ import ( "fmt" "os" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" gogopkg "github.com/chainreactors/gogo/v2/pkg" - "github.com/chainreactors/utils/parsers" "github.com/chainreactors/sdk/gogo" sdktypes "github.com/chainreactors/sdk/pkg/types" + "github.com/chainreactors/utils/parsers" ) const GogoTempLogFile = ".sock.lock" diff --git a/tools/scan/engine/keys.go b/tools/scan/engine/keys.go new file mode 100644 index 00000000..6356e063 --- /dev/null +++ b/tools/scan/engine/keys.go @@ -0,0 +1,7 @@ +package engine + +import "github.com/chainreactors/aiscan/core/deps" + +// SetKey carries the initialized scanner engines to the command factories. +// Declared here so pkg/commands never has to link the scanner SDKs. +var SetKey = deps.NewKey[*Set]("scan.engine.Set") diff --git a/tools/scan/engine/neutron.go b/tools/scan/engine/neutron.go index cf9bffe2..ff2a43df 100644 --- a/tools/scan/engine/neutron.go +++ b/tools/scan/engine/neutron.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/neutron/common" "github.com/chainreactors/neutron/templates" "github.com/chainreactors/sdk/neutron" diff --git a/tools/scan/engine/race_norace_test.go b/tools/scan/engine/race_norace_test.go deleted file mode 100644 index 9f576e43..00000000 --- a/tools/scan/engine/race_norace_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !race - -package engine - -const raceEnabled = false diff --git a/tools/scan/engine/race_test.go b/tools/scan/engine/race_test.go deleted file mode 100644 index ee11e288..00000000 --- a/tools/scan/engine/race_test.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build race - -package engine - -const raceEnabled = true diff --git a/tools/scan/engine/set.go b/tools/scan/engine/set.go index 61e744f8..66587bcf 100644 --- a/tools/scan/engine/set.go +++ b/tools/scan/engine/set.go @@ -9,8 +9,8 @@ import ( "sync" "github.com/chainreactors/aiscan/core/resources" - "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/util" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/util" "github.com/chainreactors/fingers/alias" fingersLib "github.com/chainreactors/fingers/fingers" neutronhttp "github.com/chainreactors/neutron/protocols/http" diff --git a/tools/scan/engine/set_test.go b/tools/scan/engine/set_test.go index 2b2f42ed..682d0362 100644 --- a/tools/scan/engine/set_test.go +++ b/tools/scan/engine/set_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" gogopkg "github.com/chainreactors/gogo/v2/pkg" "github.com/chainreactors/neutron/operators" neutronhttp "github.com/chainreactors/neutron/protocols/http" @@ -468,9 +468,6 @@ func TestSprayStatsHandlerSafeAfterCancel(t *testing.T) { } func TestZombieStatsHandlerSafeAfterCancel(t *testing.T) { - if raceEnabled { - t.Skip("zombie engine has known races under -race detector") - } eng, err := sdkzombie.NewEngine(nil) if err != nil { t.Fatalf("NewEngine: %v", err) diff --git a/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go index 778f9072..530f9c89 100644 --- a/tools/scan/engine/set_uncover_recon.go +++ b/tools/scan/engine/set_uncover_recon.go @@ -5,7 +5,7 @@ package engine import ( "strings" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) func (e *Set) SetupUncover(opts ReconOptions, logger telemetry.Logger) { diff --git a/tools/scan/engine/set_uncover_stub.go b/tools/scan/engine/set_uncover_stub.go index 8e187b89..e1a57bfc 100644 --- a/tools/scan/engine/set_uncover_stub.go +++ b/tools/scan/engine/set_uncover_stub.go @@ -2,6 +2,6 @@ package engine -import "github.com/chainreactors/aiscan/pkg/telemetry" +import "github.com/chainreactors/aiscan/core/telemetry" func (e *Set) SetupUncover(_ ReconOptions, _ telemetry.Logger) {} diff --git a/tools/scan/engine/spray.go b/tools/scan/engine/spray.go index 662be303..b340266c 100644 --- a/tools/scan/engine/spray.go +++ b/tools/scan/engine/spray.go @@ -3,14 +3,21 @@ package engine import ( "context" "fmt" + "sync" "time" - "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/aiscan/core/telemetry" sdktypes "github.com/chainreactors/sdk/pkg/types" "github.com/chainreactors/sdk/spray" + "github.com/chainreactors/utils/parsers" ) +// spray's runner construction mutates shared logger/option state inside the +// upstream engine. A scan may schedule check, crawl and plugin capabilities in +// parallel against the same engine, so keep one invocation active at a time +// until its result stream is fully drained. +var sprayExecutionMu sync.Mutex + type SprayCheckOptions struct { URLs []string Host string @@ -40,6 +47,7 @@ func SprayCheckStream(ctx context.Context, eng *spray.Engine, opts SprayCheckOpt if eng == nil { return nil, fmt.Errorf("spray engine is not available") } + sprayExecutionMu.Lock() if opts.Debug { telemetry.EnableLogsDebug() } @@ -58,12 +66,14 @@ func SprayCheckStream(ctx context.Context, eng *spray.Engine, opts SprayCheckOpt } if err != nil { cancel() + sprayExecutionMu.Unlock() return nil, err } out := make(chan *parsers.SprayResult) go func() { defer telemetry.SDKGoRecover("spray") + defer sprayExecutionMu.Unlock() defer cancel() defer close(out) for { diff --git a/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go index 73e6a517..f8b85b63 100644 --- a/tools/scan/engine/uncover.go +++ b/tools/scan/engine/uncover.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/projectdiscovery/uncover/sources" ) diff --git a/tools/scan/engine/zombie.go b/tools/scan/engine/zombie.go index 3c09bb98..f651de67 100644 --- a/tools/scan/engine/zombie.go +++ b/tools/scan/engine/zombie.go @@ -4,10 +4,10 @@ import ( "context" "fmt" - "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/aiscan/core/telemetry" sdktypes "github.com/chainreactors/sdk/pkg/types" sdkzombie "github.com/chainreactors/sdk/zombie" + "github.com/chainreactors/utils/parsers" ) type ZombieWeakpassOptions struct { diff --git a/tools/scan/event.go b/tools/scan/event.go index fc4db17d..b35a1c09 100644 --- a/tools/scan/event.go +++ b/tools/scan/event.go @@ -7,8 +7,8 @@ import ( "sync/atomic" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/utils/parsers" sdktypes "github.com/chainreactors/sdk/pkg/types" + "github.com/chainreactors/utils/parsers" ) type eventKind string diff --git a/tools/scan/http_auth.go b/tools/scan/http_auth.go index 05b062fe..567ca7b3 100644 --- a/tools/scan/http_auth.go +++ b/tools/scan/http_auth.go @@ -62,7 +62,7 @@ func httpAuthClient(timeoutSeconds int) *http.Client { if timeoutSeconds <= 0 { timeoutSeconds = 5 } - transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:errcheck // DefaultTransport is always *http.Transport + transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:errcheck // DefaultTransport is always *http.Transport transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // scanner probes must tolerate self-signed certs return &http.Client{ Timeout: time.Duration(timeoutSeconds) * time.Second, diff --git a/tools/scan/input.go b/tools/scan/input.go index 3bdfe620..16a20220 100644 --- a/tools/scan/input.go +++ b/tools/scan/input.go @@ -8,9 +8,9 @@ import ( "os" "strings" - "github.com/chainreactors/utils/parsers" sdkzombie "github.com/chainreactors/sdk/zombie" "github.com/chainreactors/utils" + "github.com/chainreactors/utils/parsers" zombiepkg "github.com/chainreactors/zombie/pkg" ) diff --git a/tools/scan/jsonl_writer.go b/tools/scan/jsonl_writer.go index b634d555..e56d2d17 100644 --- a/tools/scan/jsonl_writer.go +++ b/tools/scan/jsonl_writer.go @@ -4,9 +4,9 @@ import ( "encoding/json" "strings" + "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/tools/scan/pipeline" ) diff --git a/tools/scan/keys.go b/tools/scan/keys.go new file mode 100644 index 00000000..2ceadd8f --- /dev/null +++ b/tools/scan/keys.go @@ -0,0 +1,7 @@ +package scan + +import "github.com/chainreactors/aiscan/core/deps" + +// OptsKey carries scan options built by the assembly layer (parent agent, deep +// browser, skill reader) that cannot be expressed as plain Deps fields. +var OptsKey = deps.NewKey[[]Option]("scan.Options") diff --git a/tools/scan/options.go b/tools/scan/options.go index 99f7d36d..cc82d83d 100644 --- a/tools/scan/options.go +++ b/tools/scan/options.go @@ -3,10 +3,10 @@ package scan import ( "context" + "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) type Option func(*Command) diff --git a/tools/scan/pipeline/pipeline.go b/tools/scan/pipeline/pipeline.go index 17b63de9..b2999122 100644 --- a/tools/scan/pipeline/pipeline.go +++ b/tools/scan/pipeline/pipeline.go @@ -6,7 +6,7 @@ import ( "sync" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) type Event interface { diff --git a/tools/scan/report.go b/tools/scan/report.go index aeb4e018..035645ed 100644 --- a/tools/scan/report.go +++ b/tools/scan/report.go @@ -1,15 +1,12 @@ package scan import ( - "fmt" - "sort" "strconv" "strings" "time" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/utils/parsers" - sdktypes "github.com/chainreactors/sdk/pkg/types" ) func formatSummary(d *collector, color bool) string { @@ -25,104 +22,10 @@ func formatSummary(d *collector, color bool) string { } } sb.WriteString(formatScanSummaryLine(d, stats, color)) - - if len(d.trace) > 0 { - for _, line := range d.trace { - sb.WriteString(line) - sb.WriteString("\n") - } - } - - return sb.String() -} - -func formatMarkdown(d *collector) string { - d.mu.Lock() - defer d.mu.Unlock() - stats := d.statsSnapshotLocked() - - var sb strings.Builder - sb.WriteString("# Scan Report\n\n") - sb.WriteString(formatScanSummaryLine(d, stats, false)) - sb.WriteString("\n\n") - - sb.WriteString("## Metrics\n\n") - sb.WriteString("| Metric | Value |\n") - sb.WriteString("| --- | ---: |\n") - sb.WriteString(fmt.Sprintf("| Inputs | %d |\n", stats.Inputs)) - sb.WriteString(fmt.Sprintf("| Open services | %d |\n", len(d.gogoResults))) - sb.WriteString(fmt.Sprintf("| Web endpoints | %d |\n", len(d.seenWeb))) - sb.WriteString(fmt.Sprintf("| Web probes | %d |\n", len(d.sprayResults))) - sb.WriteString(fmt.Sprintf("| Fingerprints | %d |\n", len(d.seenFinger))) - sb.WriteString(fmt.Sprintf("| Loots | %d |\n", len(d.loots))) - sb.WriteString(fmt.Sprintf("| Errors | %d |\n", len(d.errors))) - sb.WriteString(fmt.Sprintf("| Tasks | %d |\n", stats.Tasks)) - sb.WriteString(fmt.Sprintf("| Requests | %d |\n", stats.Requests)) - sb.WriteString(fmt.Sprintf("| Duration | %s |\n", stats.Duration().Round(time.Millisecond))) - - if d.debug && len(stats.CapabilityRuns) > 0 { - sb.WriteString("\n## Capability Runs\n\n") - writeCountTable(&sb, "Capability", stats.CapabilityRuns) - } - - if d.debug && len(stats.EngineStats) > 0 { - sb.WriteString("\n## Engine Stats\n\n") - writeEngineStatsTable(&sb, stats.EngineStats) - } - - if len(d.gogoResults) > 0 { - sb.WriteString("\n## Open Services\n\n") - for _, result := range sortedCopy(d.gogoResults, func(a, b *parsers.GOGOResult) bool { - return a.GetTarget() < b.GetTarget() - }) { - writeMarkdownEventLine(&sb, targetEvent(capGogoPortscan, "", newServiceTarget("", result))) - } - } - - if len(d.sprayResults) > 0 { - sb.WriteString("\n## Web Evidence\n\n") - for _, item := range sortedCopy(d.sprayResults, func(a, b sprayObservation) bool { - return sprayResultSortKey(a) < sprayResultSortKey(b) - }) { - if item.Result == nil { - continue - } - writeMarkdownEventLine(&sb, targetEvent(item.Capability, "", newWebProbeTarget("", item.Capability, "", item.Result))) - } - } - - if len(d.loots) > 0 { - sb.WriteString("\n## Loots\n\n") - for _, loot := range sortedCopy(d.loots, func(a, b output.Loot) bool { - if a.Kind != b.Kind { - return a.Kind < b.Kind - } - return a.Description < b.Description - }) { - status, _ := loot.Data["verification_status"].(string) - line := formatEventLine(lootEvent(loot.Kind, loot), false) - if line != "" { - writeMarkdownStatusLine(&sb, line, status) - } - } - } - - if len(d.errors) > 0 { - sb.WriteString("\n## Errors\n\n") - for _, line := range sortedCopy(d.errors, func(a, b string) bool { return a < b }) { - writeMarkdownEventLine(&sb, errorEventOf("scan", line)) - } - } - - if d.debug && len(d.trace) > 0 { - sb.WriteString("\n## Trace\n\n") - for _, line := range d.trace { - sb.WriteString("- ") - sb.WriteString(line) - sb.WriteString("\n") - } + for _, line := range d.trace { + sb.WriteString(line) + sb.WriteString("\n") } - return sb.String() } @@ -139,8 +42,7 @@ func formatScanSummaryLine(d *collector, stats statsSnapshot, color bool) string parts = appendCount64(parts, stats.Requests, "request", "requests") parts = append(parts, stats.Duration().Round(time.Millisecond).String()) c := output.NewColor(color) - body := strings.Join(parts, " ") - return output.FormatLine(output.OutputPrefix("summary", c.Dim), body, c) + "\n" + return output.FormatLine(output.OutputPrefix("summary", c.Dim), strings.Join(parts, " "), c) + "\n" } func appendCount(parts []string, n int, singular, plural string) []string { @@ -159,19 +61,6 @@ func appendCount64(parts []string, n int64, singular, plural string) []string { return append(parts, strconv.FormatInt(n, 10), word) } -func sortedCopy[T any](items []T, less func(a, b T) bool) []T { - out := append([]T(nil), items...) - sort.SliceStable(out, func(i, j int) bool { return less(out[i], out[j]) }) - return out -} - -func sprayResultSortKey(item sprayObservation) string { - if item.Result == nil { - return item.Capability - } - return item.Result.UrlString + "|" + item.Capability + "|" + item.Result.Source.Name() -} - func formatTraceEvent(event pipelineEvent) string { parts := []string{string(event.Action)} if event.Capability != "" { @@ -185,27 +74,21 @@ func formatTraceEvent(event pipelineEvent) string { hostHeader := "" switch target := event.Event.Target.(type) { case scanTarget: - if target.Target != "" { - targetValue = target.Target - } + targetValue = target.Target case serviceTarget: if target.Result != nil { targetValue = target.Result.GetTarget() } case webTarget: - if target.URL != "" { - targetValue = target.URL - } + targetValue = target.URL hostHeader = target.HostHeader case webProbeTarget: - if target.Result != nil && target.Result.UrlString != "" { + if target.Result != nil { targetValue = target.Result.UrlString } hostHeader = target.HostHeader case pocTarget: - if target.Target != "" { - targetValue = target.Target - } + targetValue = target.Target case weakpassTarget: if target.Target.Address() != ":" { targetValue = target.Target.Address() @@ -222,86 +105,3 @@ func formatTraceEvent(event pipelineEvent) string { } return output.FormatLine("[trace]", parsers.JoinOutput(parts...), output.NewColor(false)) } - -func writeMarkdownEventLine(sb *strings.Builder, event event) { - line := formatEventLine(event, false) - if line == "" { - return - } - writeMarkdownStatusLine(sb, line, "") -} - -func writeMarkdownStatusLine(sb *strings.Builder, line, status string) { - if line == "" { - return - } - sb.WriteString("- ") - switch status { - case "not_confirmed": - sb.WriteString("~~") - sb.WriteString(line) - sb.WriteString("~~ *(not confirmed)*") - case "confirmed": - sb.WriteString("**[verified]** ") - sb.WriteString(line) - case "inconclusive": - sb.WriteString("**[inconclusive]** ") - sb.WriteString(line) - case "failed": - sb.WriteString("**[verification failed]** ") - sb.WriteString(line) - default: - sb.WriteString(line) - } - sb.WriteString("\n") -} - - -func sortedMapKeys(values map[string]int) []string { - keys := make([]string, 0, len(values)) - for key := range values { - if key != "" { - keys = append(keys, key) - } - } - sort.Strings(keys) - return keys -} - -func writeCountTable(sb *strings.Builder, label string, values map[string]int) { - sb.WriteString(fmt.Sprintf("| %s | Count |\n", label)) - sb.WriteString("| --- | ---: |\n") - for _, key := range sortedMapKeys(values) { - sb.WriteString(fmt.Sprintf("| %s | %d |\n", key, values[key])) - } -} - -func sortedStatsKeys(values map[string]sdktypes.Stats) []string { - keys := make([]string, 0, len(values)) - for key := range values { - if key != "" { - keys = append(keys, key) - } - } - sort.Strings(keys) - return keys -} - -func writeEngineStatsTable(sb *strings.Builder, values map[string]sdktypes.Stats) { - sb.WriteString("| Source | Engine | Task | Targets | Tasks | Requests | Results | Errors | Duration |\n") - sb.WriteString("| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |\n") - for _, key := range sortedStatsKeys(values) { - stats := values[key] - sb.WriteString(fmt.Sprintf("| %s | %s | %s | %d | %d | %d | %d | %d | %s |\n", - key, - stats.Engine, - stats.Task, - stats.Targets, - stats.Tasks, - stats.Requests, - stats.Results, - stats.Errors, - stats.Duration.Round(time.Millisecond), - )) - } -} diff --git a/tools/scan/target.go b/tools/scan/target.go index fbc510c2..7ac3a1f7 100644 --- a/tools/scan/target.go +++ b/tools/scan/target.go @@ -5,9 +5,9 @@ import ( "sort" "strings" - "github.com/chainreactors/utils/parsers" sdkzombie "github.com/chainreactors/sdk/zombie" "github.com/chainreactors/utils" + "github.com/chainreactors/utils/parsers" ) type target interface { diff --git a/tools/scan/verify.go b/tools/scan/verify.go index 3fa0c096..c36c3519 100644 --- a/tools/scan/verify.go +++ b/tools/scan/verify.go @@ -5,9 +5,9 @@ import ( "fmt" "strings" + "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/agent" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) type indexedLoot struct { diff --git a/tools/search/fetch.go b/tools/search/fetch.go index a36dc857..f5bdd777 100644 --- a/tools/search/fetch.go +++ b/tools/search/fetch.go @@ -12,9 +12,9 @@ import ( "time" "unicode" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ) const ( diff --git a/tools/search/register.go b/tools/search/register.go index 5d9a4817..90804a9d 100644 --- a/tools/search/register.go +++ b/tools/search/register.go @@ -1,42 +1,46 @@ package search import ( + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/core/resources" - "github.com/chainreactors/aiscan/pkg/agent/provider" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/sdk/pkg/association" ) func init() { + capability.Register(capability.Descriptor{ + ID: "search", Kind: capability.KindTool, Group: "search", + Optional: true, Default: true, + }) commands.RegisterFactory(commands.Factory{ - Group: "search", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - var p provider.Provider - if deps.Provider != nil { - p, _ = deps.Provider.(provider.Provider) - } - - tavily := NewTavilySearch(deps.TavilyKeys) - if deps.ScannerProxy != "" { - tavily.SetProxy(deps.ScannerProxy) + Capability: "search", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + tavily := NewTavilySearch(d.TavilyKeys) + if d.ScannerProxy != "" { + tavily.SetProxy(d.ScannerProxy) } - reg.RegisterTool(NewWebSearchTool(p, tavily)) + reg.RegisterTool(NewWebSearchTool(d.Provider, tavily)) fetch := NewFetchCommand() reg.Register(commands.Command{Name: fetch.Name(), Usage: fetch.Usage(), Run: fetch.Run}, "search") var idx *association.Index - if es, ok := deps.EngineSet.(*engine.Set); ok && es != nil { + if es, ok := deps.Get(d.Bag, engine.SetKey); ok && es != nil { idx = es.Index } if idx == nil { - if rs, ok := deps.Resources.(*resources.Set); ok && rs != nil && rs.FingersConfig != nil { + if rs, ok := deps.Get(d.Bag, resources.SetKey); ok && rs != nil && rs.FingersConfig != nil { full := rs.FingersConfig.FullFingers idx = association.NewIndex() idx.BuildWithFingers(full.Fingers(), full.Aliases(), nil) } } + if idx == nil { + // cyberhub still answers, but without local fingerprint association. + d.Skip("cyberhub.index", deps.Name(engine.SetKey)+"/"+deps.Name(resources.SetKey)) + } cyberhub := NewCyberhubSearch(idx) reg.Register(commands.Command{Name: cyberhub.Name(), Usage: cyberhub.Usage(), Run: cyberhub.Run}, "search") }, diff --git a/tools/search/tavily.go b/tools/search/tavily.go index 339b3944..82c7b4b8 100644 --- a/tools/search/tavily.go +++ b/tools/search/tavily.go @@ -14,7 +14,7 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/pkg/agent/truncate" + "github.com/chainreactors/aiscan/core/truncate" ) const ( diff --git a/tools/search/websearch.go b/tools/search/websearch.go index 3dbef358..9f4ff5b8 100644 --- a/tools/search/websearch.go +++ b/tools/search/websearch.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/chainreactors/aiscan/pkg/agent/provider" + "github.com/chainreactors/aiscan/agent/provider" ) func formatWebSearchResponse(resp *provider.WebSearchResponse, query string) string { diff --git a/tools/search/websearch_tool.go b/tools/search/websearch_tool.go index aad76dca..adec6c15 100644 --- a/tools/search/websearch_tool.go +++ b/tools/search/websearch_tool.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" + "github.com/chainreactors/aiscan/agent/provider" "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/agent/provider" ) type WebSearchTool struct { diff --git a/tools/spray/register.go b/tools/spray/register.go index 3489fdd2..fe478eee 100644 --- a/tools/spray/register.go +++ b/tools/spray/register.go @@ -1,22 +1,28 @@ package spray import ( - cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { - cfg.ExtraScannerUsage["spray"] = func() string { return New(nil).Usage() } + capability.Register(capability.Descriptor{ + ID: "spray", Kind: capability.KindScanner, Group: "scanner", + CLIName: "spray", Summary: "spray", UsageLine: " spray Run spray directly", + Usage: func() string { return New(nil).Usage() }, Requires: []string{"scan.engine.Set.Spray"}, + }) commands.RegisterFactory(commands.Factory{ - Group: "scanner", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - es, _ := deps.EngineSet.(*engine.Set) - if es == nil || es.Spray == nil { + Capability: "spray", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + es, ok := deps.Get(d.Bag, engine.SetKey) + if !ok || es == nil || es.Spray == nil { + d.Skip("spray", deps.Name(engine.SetKey)+".Spray") return } - impl := New(es.Spray).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus) - reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), QuickReference: impl.QuickReference(), Run: impl.Run, SetProxy: impl.SetProxy, GetProxy: func() string { return impl.Proxy }}, "scanner") + impl := New(es.Spray).WithLogger(d.GetLogger()).WithProxy(d.ScannerProxy).WithDataBus(d.DataBus) + reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), QuickReference: impl.QuickReference(), Run: impl.Run}, "scanner") }, }) } diff --git a/tools/spray/spray.go b/tools/spray/spray.go index 69e0035c..142f016e 100644 --- a/tools/spray/spray.go +++ b/tools/spray/spray.go @@ -9,8 +9,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/sdk/spray" spraycore "github.com/chainreactors/spray/core" diff --git a/tools/spray/spray_test.go b/tools/spray/spray_test.go index 92091af2..91a2f89d 100644 --- a/tools/spray/spray_test.go +++ b/tools/spray/spray_test.go @@ -9,8 +9,8 @@ import ( "sync/atomic" "testing" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" sdkspray "github.com/chainreactors/sdk/spray" spraypkg "github.com/chainreactors/spray/pkg" "github.com/chainreactors/utils/parsers" diff --git a/tools/toolargs/base.go b/tools/toolargs/base.go index a83b0552..94a1e5a1 100644 --- a/tools/toolargs/base.go +++ b/tools/toolargs/base.go @@ -6,7 +6,7 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/telemetry" + "github.com/chainreactors/aiscan/core/telemetry" ) type Base struct { diff --git a/tools/zombie/register.go b/tools/zombie/register.go index 8b78f30b..90525aaa 100644 --- a/tools/zombie/register.go +++ b/tools/zombie/register.go @@ -1,22 +1,28 @@ package zombie import ( - cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/capability" + "github.com/chainreactors/aiscan/core/deps" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { - cfg.ExtraScannerUsage["zombie"] = func() string { return New(nil).Usage() } + capability.Register(capability.Descriptor{ + ID: "zombie", Kind: capability.KindScanner, Group: "scanner", + CLIName: "zombie", Summary: "zombie", UsageLine: " zombie Run zombie directly", + Usage: func() string { return New(nil).Usage() }, Requires: []string{"scan.engine.Set.Zombie"}, + }) commands.RegisterFactory(commands.Factory{ - Group: "scanner", - Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - es, _ := deps.EngineSet.(*engine.Set) - if es == nil || es.Zombie == nil { + Capability: "zombie", + Build: func(d *commands.Deps, reg *commands.CommandRegistry) { + es, ok := deps.Get(d.Bag, engine.SetKey) + if !ok || es == nil || es.Zombie == nil { + d.Skip("zombie", deps.Name(engine.SetKey)+".Zombie") return } - impl := New(es.Zombie).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus) - reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run, SetProxy: impl.SetProxy, GetProxy: func() string { return impl.Proxy }}, "scanner") + impl := New(es.Zombie).WithLogger(d.GetLogger()).WithProxy(d.ScannerProxy).WithDataBus(d.DataBus) + reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run}, "scanner") }, }) } diff --git a/tools/zombie/zombie.go b/tools/zombie/zombie.go index 6d3e0966..00119323 100644 --- a/tools/zombie/zombie.go +++ b/tools/zombie/zombie.go @@ -8,8 +8,8 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/tools/toolargs" sdkzombie "github.com/chainreactors/sdk/zombie" zombiecore "github.com/chainreactors/zombie/core" diff --git a/tools/zombie/zombie_test.go b/tools/zombie/zombie_test.go index 568d23c2..658ef0fc 100644 --- a/tools/zombie/zombie_test.go +++ b/tools/zombie/zombie_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" + "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/telemetry" ) func TestExecuteDebugActivatesTelemetryLogger(t *testing.T) { From 644cefcb5e10e2f54d7c01e24ebae665e27d8fe6 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 13:51:20 +0800 Subject: [PATCH 139/348] test(web): require real agent and verify SSE resume --- .gitignore | 2 + web/frontend/cyber-ui | 2 +- web/frontend/e2e/aiscan-web.spec.ts | 77 +++++++++++++----- web/frontend/e2e/start-server.mjs | 119 ++++++++++++++++++++++++++++ web/frontend/playwright.config.ts | 11 ++- 5 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 web/frontend/e2e/start-server.mjs diff --git a/.gitignore b/.gitignore index cb364dd4..1314135c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ out/ scan_results.jsonl pw_driver_bin node_modules/ +web/frontend/playwright-report/ +web/frontend/test-results/ community.yaml # Local runtime state / operator artifacts diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui index 1d1a29e1..70fe4fec 160000 --- a/web/frontend/cyber-ui +++ b/web/frontend/cyber-ui @@ -1 +1 @@ -Subproject commit 1d1a29e17ecd3838880b193660df98c01cb1fc26 +Subproject commit 70fe4fecb43bc461ea505d1ba8cc90ef5aae7048 diff --git a/web/frontend/e2e/aiscan-web.spec.ts b/web/frontend/e2e/aiscan-web.spec.ts index 58c71cf0..7b297178 100644 --- a/web/frontend/e2e/aiscan-web.spec.ts +++ b/web/frontend/e2e/aiscan-web.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; const API_TOKEN = process.env.ACCESS_KEY || 'test-token'; const LLM_PROVIDER = process.env.LLM_PROVIDER || 'openai'; @@ -19,6 +19,20 @@ async function openAuthenticatedApp(page: Page) { await expect(page.locator('button[aria-label="Open settings"]')).toBeVisible(); } +async function requireRegisteredAgents(request: APIRequestContext) { + let agents: any[] = []; + await expect.poll(async () => { + const response = await request.get('/api/agents', { headers: apiHeaders() }); + expect(response.ok()).toBeTruthy(); + agents = await response.json(); + return agents.length; + }, { + message: 'the E2E server must start and register its local mock-backed agent', + timeout: 15_000, + }).toBeGreaterThan(0); + return agents; +} + // --------------------------------------------------------------------------- // 1. Health & Status // --------------------------------------------------------------------------- @@ -302,15 +316,8 @@ test.describe('Agents API', () => { test.describe('Chat Session CRUD', () => { test('create, list, and delete a session', async ({ request }) => { // First, get available agents - const agentsRes = await request.get('/api/agents', { headers: apiHeaders() }); - const agents = await agentsRes.json(); - const agentID = agents.length > 0 ? agents[0].id : ''; - - // Skip if no agent is available - if (!agentID) { - test.skip(); - return; - } + const agents = await requireRegisteredAgents(request); + const agentID = agents[0].id; // Create const createRes = await request.post('/api/chat/sessions', { @@ -344,12 +351,7 @@ test.describe('Chat Session CRUD', () => { test.describe('Chat LLM round-trip', () => { test('send a message and receive an assistant response', async ({ request }) => { // Get agent - const agentsRes = await request.get('/api/agents', { headers: apiHeaders() }); - const agents = await agentsRes.json(); - if (agents.length === 0) { - test.skip(); - return; - } + const agents = await requireRegisteredAgents(request); const agentID = agents[0].id; // Create session @@ -375,7 +377,9 @@ test.describe('Chat LLM round-trip', () => { const msgRes = await request.get(`/api/chat/sessions/${sessionID}/messages`, { headers: apiHeaders(), }); - const messages = await msgRes.json(); + const page = await msgRes.json(); + expect(Array.isArray(page.items)).toBeTruthy(); + const messages = page.items; const assistantMsgs = messages.filter((m: any) => m.role === 'assistant'); if (assistantMsgs.length > 0) { assistantMsg = assistantMsgs[assistantMsgs.length - 1]; @@ -396,7 +400,44 @@ test.describe('Chat LLM round-trip', () => { }); // --------------------------------------------------------------------------- -// 10. SCO / Asset Pool API +// 10. SSE reconnect and durable event cursor +// --------------------------------------------------------------------------- + +test.describe('SSE reconnect', () => { + test('replays missing durable events after the browser reconnects', async ({ page, request, context }) => { + const agents = await requireRegisteredAgents(request); + + const createRes = await request.post('/api/chat/sessions', { + headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, + data: { agent_id: agents[0].id }, + }); + expect(createRes.ok()).toBeTruthy(); + const session = await createRes.json(); + + await openAuthenticatedApp(page); + await page.goto(`/sessions/${session.id}`); + const prompt = 'Reply with exactly one word: PONG'; + const sendRes = await request.post(`/api/chat/sessions/${session.id}/messages`, { + headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, + data: { content: prompt }, + }); + expect(sendRes.ok()).toBeTruthy(); + + await expect(page.locator('p').filter({ hasText: prompt })).toBeVisible({ timeout: 10_000 }); + await context.setOffline(true); + await page.waitForTimeout(3500); + await context.setOffline(false); + + const resumed = page.getByText('PONG', { exact: true }); + await expect(resumed).toBeVisible({ timeout: 15_000 }); + await expect(resumed).toHaveCount(1); + + await request.delete(`/api/chat/sessions/${session.id}`, { headers: apiHeaders() }); + }); +}); + +// --------------------------------------------------------------------------- +// 11. SCO / Asset Pool API // --------------------------------------------------------------------------- test.describe('Asset Pool API', () => { diff --git a/web/frontend/e2e/start-server.mjs b/web/frontend/e2e/start-server.mjs new file mode 100644 index 00000000..2d4fcf57 --- /dev/null +++ b/web/frontend/e2e/start-server.mjs @@ -0,0 +1,119 @@ +import { createServer } from 'node:http' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn, spawnSync } from 'node:child_process' + +const host = '127.0.0.1' +const webPort = Number(process.env.AISCAN_E2E_PORT || 38080) +const root = resolve(fileURLToPath(new URL('../../..', import.meta.url))) +const workDir = await mkdtemp(join(tmpdir(), 'aiscan-web-e2e-')) +const binary = join(workDir, process.platform === 'win32' ? 'aiscan-e2e.exe' : 'aiscan-e2e') + +const mockLLM = createServer(async (req, res) => { + if (req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ data: [{ id: 'deepseek-chat', object: 'model' }] })) + return + } + if (req.url !== '/v1/chat/completions' || req.method !== 'POST') { + res.writeHead(404) + res.end('not found') + return + } + + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') + const delayedReply = JSON.stringify(payload.messages || []).includes('Reply with exactly one word: PONG') + if (payload.stream) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + if (delayedReply) { + res.write('data: {"choices":[{"delta":{"role":"assistant","content":"P"},"index":0}]}\n\n') + await new Promise((resolveDelay) => setTimeout(resolveDelay, 3000)) + res.write('data: {"choices":[{"delta":{"content":"ONG"},"index":0}]}\n\n') + } else { + res.write('data: {"choices":[{"delta":{"role":"assistant","content":"PONG"},"index":0}]}\n\n') + } + res.write('data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11}}\n\n') + res.end('data: [DONE]\n\n') + return + } + + if (delayedReply) await new Promise((resolveDelay) => setTimeout(resolveDelay, 3000)) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + id: 'chatcmpl-e2e', + choices: [{ message: { role: 'assistant', content: 'PONG' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, + })) +}) + +await new Promise((resolveListen, reject) => { + mockLLM.once('error', reject) + mockLLM.listen(0, host, resolveListen) +}) +const llmAddress = mockLLM.address() +if (!llmAddress || typeof llmAddress === 'string') throw new Error('mock LLM did not expose a TCP address') + +const configPath = join(workDir, 'aiscan.yaml') +await writeFile(configPath, `llm: + active_profile: e2e + providers: + - id: e2e + name: E2E DeepSeek + provider: deepseek + base_url: http://${host}:${llmAddress.port}/v1 + api_key: test-key + model: deepseek-chat +`, { mode: 0o600 }) + +const build = spawnSync('go', ['build', '-tags', 'full', '-o', binary, './cmd/aiscan'], { + cwd: root, + stdio: 'inherit', +}) +if (build.status !== 0) { + mockLLM.close() + await rm(workDir, { recursive: true, force: true }) + process.exit(build.status ?? 1) +} + +const child = spawn(binary, [ + '--config', configPath, + '--data-dir', join(workDir, 'data'), + 'web', + '--addr', `${host}:${webPort}`, + '--db', join(workDir, 'aiscan-web.db'), + '--token', 'test-token', +], { + cwd: root, + stdio: 'inherit', +}) + +let shuttingDown = false +async function shutdown(code) { + if (shuttingDown) return + shuttingDown = true + if (child.exitCode === null) child.kill() + await new Promise((resolveClose) => mockLLM.close(resolveClose)) + await rm(workDir, { recursive: true, force: true }) + process.exit(code) +} + +child.once('error', (error) => { + console.error(error) + void shutdown(1) +}) +child.once('exit', (code, signal) => { + if (!shuttingDown) { + console.error(`AIScan E2E server exited early (code=${code}, signal=${signal})`) + void shutdown(code ?? 1) + } +}) +process.once('SIGINT', () => void shutdown(130)) +process.once('SIGTERM', () => void shutdown(143)) diff --git a/web/frontend/playwright.config.ts b/web/frontend/playwright.config.ts index 1ca79740..293de43c 100644 --- a/web/frontend/playwright.config.ts +++ b/web/frontend/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@playwright/test'; -const baseURL = process.env.BASE_URL || 'http://127.0.0.1:18080'; +const baseURL = process.env.BASE_URL || `http://127.0.0.1:${process.env.AISCAN_E2E_PORT || '38080'}`; +const manageServer = !process.env.BASE_URL; export default defineConfig({ testDir: './e2e', @@ -9,6 +10,14 @@ export default defineConfig({ fullyParallel: false, retries: 0, reporter: [['list'], ['html', { open: 'never' }]], + webServer: manageServer ? { + command: 'node ./e2e/start-server.mjs', + url: `${baseURL}/health`, + timeout: 180_000, + reuseExistingServer: false, + stdout: 'pipe', + stderr: 'pipe', + } : undefined, use: { baseURL, headless: true, From f1e0b49905017d9ca0d17c23e0a733ff10943e37 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 13:52:11 +0800 Subject: [PATCH 140/348] ci: enforce architecture and zero-debt gates --- .github/workflows/ci.yml | 105 ++++++++++++++++++++++- .github/workflows/scanner-regression.yml | 2 +- Makefile | 2 +- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16baa277..730974d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,11 +70,38 @@ jobs: exit 1 fi + quality: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Check dependency layers, registered skips, and repository debt + run: go test -count=1 ./core/deps + + - name: Run go vet + run: go vet ./... + + - name: Check whitespace and submodule pins + run: | + git diff --check HEAD + git diff --exit-code --submodule=diff + git submodule foreach --recursive 'test -z "$(git status --porcelain)"' + # ── Unit tests (depends on tidy) ────────────────────────────── test: runs-on: ubuntu-22.04 - needs: tidy + needs: [tidy, quality] steps: - name: Checkout uses: actions/checkout@v6 @@ -152,13 +179,66 @@ jobs: run: | go test -tags "re2_cgo re2_static" -race -count=1 -timeout 5m -v \ -run 'MultiRound|SendCtrlC' \ - ./pkg/agent/tmux/ + ./agent/tmux/ - name: Run agent tmux integration tests run: | go test -tags "re2_cgo re2_static" -race -count=1 -timeout 5m -v \ -run 'AgentTmux' \ - ./pkg/agent/ + ./agent/ + + windows-test: + runs-on: windows-2022 + needs: [tidy, quality] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run native Windows package tests + run: go test -count=1 ./agent/... ./pkg/runner/... ./pkg/web/... + + - name: Compile and test the full CLI on Windows + run: go test -count=1 -tags full ./cmd/aiscan + + race-stress: + runs-on: ubuntu-22.04 + needs: [tidy, quality] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Repeat agent and runner concurrency tests + run: | + go test -race -count=20 -timeout 15m \ + -run 'Test(ConcurrentEmitWhileRegistering|SetProviderRaceWithRun|ResetDoesNotAllowConcurrentPrompt|StreamingProviderEmitsMessageUpdates)$' \ + ./agent/... + go test -race -count=20 -timeout 15m \ + -run 'Test(StdioSameSessionFIFOOrder|StdioSessionsRunConcurrently|StdioDrainWaitsForInFlightAndQueued|RuntimeSessionDirectLoopUsesSessionScheduler|RuntimeSessionRejectsRequestsPastPendingLimit|SessionContextCancellationStopsActiveRun|ActiveRunSteersAsyncInputWithoutSecondLifecycle)$' \ + ./pkg/runner/... + + - name: Repeat web SSE, cancellation, and reload concurrency tests + run: | + go test -race -count=20 -timeout 20m \ + -run 'Test(BroadcastAOPEventPersistsRawEnvelope|ServeSSEWithSnapshotSubscribesBeforeReadingSnapshot|ServeSSEWithSnapshotDropsQueuedSnapshotDuplicates|SessionEventsReplayHasNoSideEffects|SessionEventsResumesAfterLastEventID|CancelRemoteScanStopsAgentAndPreservesCanceledStatus|CancelQueuedScanDoesNotWaitForConcurrencySlot|CancelTaskUsesControlChannelWhenTaskQueueIsFull|CancelTaskWaitsForSaturatedControlChannel|CompleteJobCannotOverwriteCanceledScan|BroadcastConfigReload|BroadcastConfigReloadWaitsBehindCancellationFrames|HandleConfigReloadResultUpdatesAgentStatus|SaveConfigBuildFailureKeepsCommittedConfigAndCurrentApp|SaveConfigCommitFailureClosesCandidateAndKeepsCurrentApp|SaveConfigSerializesConcurrentCandidates)$' \ + ./pkg/web scanner-functional: runs-on: ubuntu-22.04 @@ -209,6 +289,9 @@ jobs: go test -tags "re2_cgo re2_static" -race -count=1 -timeout 5m \ ./core/resources/... + - name: Check generated resources are committed + run: git diff --exit-code + # ── E2E tests (depends on test) ─────────────────────────────── e2e: @@ -240,6 +323,20 @@ jobs: npm --prefix web/frontend run build test -s web/static/index.html + - name: Install Playwright Chromium + working-directory: web/frontend + run: npx playwright install --with-deps chromium + + - name: Run frontend Playwright E2E + working-directory: web/frontend + run: npm run test:e2e + + - name: Run cyber-ui viewer tests + working-directory: web/frontend/cyber-ui + run: | + corepack pnpm install --frozen-lockfile + corepack pnpm --filter @cyber/viewer test + - name: Run e2e tests run: | go test -race -count=1 -timeout 10m \ @@ -303,7 +400,7 @@ jobs: - name: Build all platforms (${{ matrix.id }}) run: | - for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do IFS='/' read -r goos goarch <<< "$target" echo " compile ${goos}/${goarch}" CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ diff --git a/.github/workflows/scanner-regression.yml b/.github/workflows/scanner-regression.yml index 883d80f2..b7c255b0 100644 --- a/.github/workflows/scanner-regression.yml +++ b/.github/workflows/scanner-regression.yml @@ -35,4 +35,4 @@ jobs: run: | go test -tags "full integration re2_cgo re2_static" -count=1 -timeout 8m -v \ -run 'Test(ScannerPublicIntegration|FullScannerPublicIntegration)$' \ - ./pkg/tools + ./tools diff --git a/Makefile b/Makefile index 3691d9f3..b22aead0 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,7 @@ prepare: mkdir -p "$(BIN_DIR)" aop-gen: - $(GO) generate ./pkg/aop/... + $(GO) generate ./core/aop/... frontend: $(NPM) --prefix "$(WEB_DIR)" run build From d9bf03ea794eec6793d2a2a2fd613045df4bae91 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 14:20:24 +0800 Subject: [PATCH 141/348] test(output): keep golden fixtures platform-stable --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 9885429f..eaeb469b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ *.sh text eol=lf *.go text eol=lf +*.golden text eol=lf go.mod text eol=lf go.sum text eol=lf From e599073f33ec78d3b88bb498156d612f8ff2e660 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 16:06:59 +0800 Subject: [PATCH 142/348] fix(web): converge scans across timeout races --- pkg/web/scan_lifecycle_test.go | 108 +++++++++++++++++++++++++++++---- pkg/web/service.go | 3 +- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/pkg/web/scan_lifecycle_test.go b/pkg/web/scan_lifecycle_test.go index 6e00db86..4c00ca3c 100644 --- a/pkg/web/scan_lifecycle_test.go +++ b/pkg/web/scan_lifecycle_test.go @@ -137,6 +137,28 @@ func TestCancelQueuedScanDoesNotWaitForConcurrencySlot(t *testing.T) { waitScanStatus(t, store, running.ID, StatusCanceled) } +type controlledDeadlineContext struct { + context.Context + done chan struct{} +} + +func newControlledDeadlineContext() *controlledDeadlineContext { + return &controlledDeadlineContext{Context: context.Background(), done: make(chan struct{})} +} + +func (c *controlledDeadlineContext) Done() <-chan struct{} { return c.done } + +func (c *controlledDeadlineContext) Err() error { + select { + case <-c.done: + return context.DeadlineExceeded + default: + return nil + } +} + +func (c *controlledDeadlineContext) expire() { close(c.done) } + func TestRemoteScanTimeoutCancelsAgentAndFailsScan(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) if err != nil { @@ -144,34 +166,94 @@ func TestRemoteScanTimeoutCancelsAgentAndFailsScan(t *testing.T) { } t.Cleanup(func() { _ = store.Close() }) - svc := NewService(ServiceConfig{Store: store, MaxConcurrent: 1, ScanTimeout: 50 * time.Millisecond}) + svc := NewService(ServiceConfig{Store: store, MaxConcurrent: 1}) pool := NewAgentPool(svc.Hub()) svc.SetAgentPool(pool) - srv, _ := setupTestServerWithPool(t, pool) - conn := dialAgent(t, srv, "timeout-agent", []string{"scan"}) - t.Cleanup(func() { _ = conn.Close() }) - waitAgents(t, pool, 1) + agent := newFakeAgent("timeout-agent", 1) + pool.register(agent) - job, err := svc.SubmitScan(context.Background(), "127.0.0.1", "quick", false, false, false) - if err != nil { + now := time.Now() + job := &ScanJob{ + ID: "timeout-scan", Target: "127.0.0.1", Mode: "quick", + Status: StatusRunning, CreatedAt: now, UpdatedAt: now, + } + if err := store.Create(context.Background(), job); err != nil { t.Fatal(err) } + jobID := job.ID + + ctx := newControlledDeadlineContext() + done := make(chan struct{}) + go func() { + svc.runScanViaAgent(ctx, job) + close(done) + }() + var call webproto.Message - if err := conn.ReadJSON(&call); err != nil { - t.Fatal(err) + select { + case call = <-agent.sendCh: + case <-time.After(time.Second): + t.Fatal("agent did not receive scan dispatch") } - _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + if call.Type != webproto.TypeAOP || call.TaskID != jobID { + t.Fatalf("scan dispatch = %+v", call) + } + + ctx.expire() var cancel webproto.Message - if err := conn.ReadJSON(&cancel); err != nil { - t.Fatalf("agent did not receive timeout cancellation: %v", err) + select { + case cancel = <-agent.controlCh: + case <-time.After(time.Second): + t.Fatal("agent did not receive timeout cancellation") } - if cancel.Type != "cancel" || cancel.TaskID != job.ID { + if cancel.Type != "cancel" || cancel.TaskID != jobID { t.Fatalf("timeout cancel frame = %+v", cancel) } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed-out remote scan did not return") + } + failed := waitScanStatus(t, store, jobID, StatusFailed) + if failed.Error != "scan timed out" { + t.Fatalf("timeout error = %q", failed.Error) + } +} + +func TestRemoteScanExpiredBeforeDispatchFailsScan(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + svc := NewService(ServiceConfig{Store: store, MaxConcurrent: 1}) + pool := NewAgentPool(svc.Hub()) + svc.SetAgentPool(pool) + agent := newFakeAgent("timeout-agent", 1) + pool.register(agent) + + now := time.Now() + job := &ScanJob{ + ID: "expired-scan", Target: "127.0.0.1", Mode: "quick", + Status: StatusRunning, CreatedAt: now, UpdatedAt: now, + } + if err := store.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + ctx := newControlledDeadlineContext() + ctx.expire() + svc.runScanViaAgent(ctx, job) + failed := waitScanStatus(t, store, job.ID, StatusFailed) if failed.Error != "scan timed out" { t.Fatalf("timeout error = %q", failed.Error) } + select { + case msg := <-agent.sendCh: + t.Fatalf("expired scan was dispatched: %+v", msg) + default: + } } func setupTestServerWithPool(t *testing.T, pool *AgentPool) (*httptest.Server, *AgentPool) { diff --git a/pkg/web/service.go b/pkg/web/service.go index e3b5e58a..aee07d76 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -464,7 +464,8 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { s.mu.Lock() s.scanAgents[job.ID] = agent.id s.mu.Unlock() - if ctx.Err() != nil { + if err := ctx.Err(); err != nil { + s.finishScanContext(job, err) return } From 04e7adecd5463ed2ba5ae16d72bca91d3fb18b33 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 16:08:44 +0800 Subject: [PATCH 143/348] fix(deps): consume race-free scanner and viewer --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ web/frontend/cyber-ui | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 23da8b1b..52bb429b 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,14 @@ require ( github.com/alecthomas/chroma/v2 v2.14.0 github.com/carapace-sh/carapace v1.11.6 github.com/chainreactors/crtm v0.0.3-0.20260618163257-073207497076 - github.com/chainreactors/fingers v1.2.2-0.20260704073236-3e22b6a528b9 - github.com/chainreactors/gogo/v2 v2.14.2-0.20260710171447-b1776cb06226 + github.com/chainreactors/fingers v1.2.2-0.20260714063144-070758342f45 + github.com/chainreactors/gogo/v2 v2.15.1-0.20260728051744-a278b33d8744 github.com/chainreactors/ioa v0.1.2-0.20260720012101-ee17a402fc18 github.com/chainreactors/libcstx/go v0.0.0-20260716111447-af6771384af7 github.com/chainreactors/logs v0.0.0-20260624034259-9aaea4aa52cc - github.com/chainreactors/neutron v0.1.1-0.20260710171341-456d36779ab2 + github.com/chainreactors/neutron v0.1.1-0.20260714062907-716c6b167cb6 github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131 - github.com/chainreactors/proxyclient v1.1.1-0.20260529172347-2a80e08d5593 + github.com/chainreactors/proxyclient v1.1.1-0.20260714062913-bce898a8f790 github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3 github.com/chainreactors/sdk v0.3.4-0.20260708104745-dcad8620f5e9 github.com/chainreactors/sdk/gogo v0.0.0-20260708104745-dcad8620f5e9 @@ -22,9 +22,9 @@ require ( github.com/chainreactors/spray v1.3.3-0.20260704194611-7ce7b850d447 github.com/chainreactors/tui/console v0.0.0-20260712082522-2ba36ad7841f github.com/chainreactors/tui/readline v0.0.0-20260723062039-ed89e758c21b - github.com/chainreactors/utils v0.0.0-20260707181750-8aa6ca296863 + github.com/chainreactors/utils v0.0.0-20260711153742-f3d210a5fa9d github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 - github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863 + github.com/chainreactors/utils/parsers v0.0.3 github.com/chainreactors/utils/pty v0.0.0-20260722180147-5b1816060721 github.com/chainreactors/zombie v1.3.0 github.com/charmbracelet/bubbles v1.0.0 diff --git a/go.sum b/go.sum index c8534106..32f93324 100644 --- a/go.sum +++ b/go.sum @@ -171,12 +171,12 @@ github.com/chainreactors/crtm v0.0.3-0.20260618163257-073207497076 h1:vIEqkeRYDy github.com/chainreactors/crtm v0.0.3-0.20260618163257-073207497076/go.mod h1:+T3JvsT0teBxi4+ValZTYWCDIwM8inbx57+nQfMFkbA= github.com/chainreactors/files v0.0.0-20240716182835-7884ee1e77f0 h1:cU3sGEODXZsUZGBXfnz0nyxF6+37vA+ZGDx6L/FKN4o= github.com/chainreactors/files v0.0.0-20240716182835-7884ee1e77f0/go.mod h1:NSxGNMRWryAyrDzZpVwmujI22wbGw6c52bQOd5zEvyU= -github.com/chainreactors/fingers v1.2.2-0.20260704073236-3e22b6a528b9 h1:6TntNzkBkKaZ2rN/w+pJUmmb0VOoZkcOZUkrIYi05RQ= -github.com/chainreactors/fingers v1.2.2-0.20260704073236-3e22b6a528b9/go.mod h1:rTZEazmD80vXmSpgwDcMo7bbZU8dop3D57XIsuy1W3M= +github.com/chainreactors/fingers v1.2.2-0.20260714063144-070758342f45 h1:wIKAvAPjQUXqAKDYG0UnpLTwn/+fwT7ipwFgNPxz88M= +github.com/chainreactors/fingers v1.2.2-0.20260714063144-070758342f45/go.mod h1:ba7u/7/I9yV7TvuWj+VV9QYz9NmlLdh6UK9kxRRft+E= github.com/chainreactors/go-re2 v1.11.1-0.20260718064805-1d8511959320 h1:gkcY9PramU2ZQZ9NWisdog4oMKaeNsd30er6Nr1pKvE= github.com/chainreactors/go-re2 v1.11.1-0.20260718064805-1d8511959320/go.mod h1:4qC68vqWSuPTct3spuTrWBqCpm00mQ707JKLS1izVjI= -github.com/chainreactors/gogo/v2 v2.14.2-0.20260710171447-b1776cb06226 h1:f/9CjNNXnSn718EsSysQUyj4AchRTw5xWqig+myOPt4= -github.com/chainreactors/gogo/v2 v2.14.2-0.20260710171447-b1776cb06226/go.mod h1:pCbDa+HwfjKCGOD4PJ0puFa/FJkE/kiy29tkX0OIw70= +github.com/chainreactors/gogo/v2 v2.15.1-0.20260728051744-a278b33d8744 h1:5Bj73ddSftvWEgjo3dEOUis1CuwPaX8JsSAIZCujfxE= +github.com/chainreactors/gogo/v2 v2.15.1-0.20260728051744-a278b33d8744/go.mod h1:Em8DiV1Rh59FCd9zN4RQBi3RnUt9yPWD/oxPEPTyXIk= github.com/chainreactors/ioa v0.1.2-0.20260720012101-ee17a402fc18 h1:X0jMNLJGBsR0prosH0sKh+ry+iyvsPifA4UlQ+iOh5M= github.com/chainreactors/ioa v0.1.2-0.20260720012101-ee17a402fc18/go.mod h1:IqHyULc67RKEmr9qsyPpJzgSGJRK8JeRXXXVthQu5Z8= github.com/chainreactors/katana v1.6.2-0.20260716115809-46dd3ac126d2 h1:pc7Vw1H4CyFnzOCxRmM1kdDwX0vJBTotIxOQjA78J/Q= @@ -185,16 +185,16 @@ github.com/chainreactors/libcstx/go v0.0.0-20260716111447-af6771384af7 h1:DJxafZ github.com/chainreactors/libcstx/go v0.0.0-20260716111447-af6771384af7/go.mod h1:YQNpUU90e8tw9k1VNEUF7smIY8kSI0J3T0/zp8ri85Y= github.com/chainreactors/logs v0.0.0-20260624034259-9aaea4aa52cc h1:e6rjnU8dmfhAnkFzLp/R5gta0LbFM13L27djxbC/i4I= github.com/chainreactors/logs v0.0.0-20260624034259-9aaea4aa52cc/go.mod h1:VrXmYPbNN5AVoo1sc5aeyPVBYqubMdb3KO/tn5rRZpo= -github.com/chainreactors/neutron v0.1.1-0.20260710171341-456d36779ab2 h1:u0gNplhf4avPGO6fowTIAl8VrXE+hKccbbrFt8ETjm4= -github.com/chainreactors/neutron v0.1.1-0.20260710171341-456d36779ab2/go.mod h1:BAWFIherRWHI1kjZkncx54tuhaKLoS6OM6T7zQBPynU= +github.com/chainreactors/neutron v0.1.1-0.20260714062907-716c6b167cb6 h1:apEDnJeZ5fe2AaEJHV6TRDldoy2t/d7E1maxmUz2tfU= +github.com/chainreactors/neutron v0.1.1-0.20260714062907-716c6b167cb6/go.mod h1:zok/CDxut71iw8NQTHKPvy0f+1G5639zr9GgDsXGnOs= github.com/chainreactors/neutron/operators/full v0.1.1-0.20260704194031-f57d0a560e32 h1:q14uQiXYcizQqkHraBghJEPzcE5StLQLIWF0HvNFKhY= github.com/chainreactors/neutron/operators/full v0.1.1-0.20260704194031-f57d0a560e32/go.mod h1:8Yg/msDYB3syXD2ryGObqSn8GG+xaBoDG/1S67A4ByQ= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe h1:n1pFLHHYXMiX5rCVWeciOTJUFggWXOrLtCu9jhq5Mbs= github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe/go.mod h1:ygxMqZQ/hGY2uegUvC0LbR538hbgNH7HP4dTuv/jfSM= github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131 h1:gTrBbrASTvndSBr2XL75Kdw8fAM3xw/dikTqMNzoQBE= github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131/go.mod h1:c4kezBtDrE4sBIH6qF0+OShJ3/fijZENyxcUbcfZ/qQ= -github.com/chainreactors/proxyclient v1.1.1-0.20260529172347-2a80e08d5593 h1:tnXa9DobeX30xGIkiAcH+BOKKwvscvxy6GfI0Q0qu8Q= -github.com/chainreactors/proxyclient v1.1.1-0.20260529172347-2a80e08d5593/go.mod h1:xSNRChMYF8en5O5ZQVmCOmNTTyQhvsrk0D0vluh/JKk= +github.com/chainreactors/proxyclient v1.1.1-0.20260714062913-bce898a8f790 h1:We2R3rJUQBI6GGmA2kM3IOK+yvPn5NsXHhpbKiVsPE0= +github.com/chainreactors/proxyclient v1.1.1-0.20260714062913-bce898a8f790/go.mod h1:m0kmv5rgzac2q3HMYBlQULc+mubGILrPx1IiPQWcQHc= github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3 h1:WVEb3Bjq3AC67oa9pNjHJfIH+mX6PozJO3S5ccVZJYQ= github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3/go.mod h1:4BG8xIxTebn0VoOTEwQ2pmYIB3K2qwGINxnRHALBNZg= github.com/chainreactors/sdk v0.3.4-0.20260708104745-dcad8620f5e9 h1:zHGP9WFSpTyZYWeKDHPDoEysmNYjck25Wv8/eVtpYBE= @@ -212,14 +212,14 @@ github.com/chainreactors/tui/console v0.0.0-20260712082522-2ba36ad7841f/go.mod h github.com/chainreactors/tui/readline v0.0.0-20260723062039-ed89e758c21b h1:OeflBONN55oQ++CFJDE47pW5GfyXJpiQClFzD1aYK+o= github.com/chainreactors/tui/readline v0.0.0-20260723062039-ed89e758c21b/go.mod h1:nEHRbLD/s2GWdAGbNVjz/KDF0ac7WZ3tPMgWmW8sZWA= github.com/chainreactors/utils v0.0.0-20240716182459-e85f2b01ee16/go.mod h1:LajXuvESQwP+qCMAvlcoSXppQCjuLlBrnQpu9XQ1HtU= -github.com/chainreactors/utils v0.0.0-20260707181750-8aa6ca296863 h1:jfuZD+vg3/K/+l8au9RXnZCQf0J6S3vG6VRqmeBmxo0= -github.com/chainreactors/utils v0.0.0-20260707181750-8aa6ca296863/go.mod h1:xwbUlFoSSxLHujyb8D48o1s2DqmEAxUNfxIy0DVUmcg= +github.com/chainreactors/utils v0.0.0-20260711153742-f3d210a5fa9d h1:wlJ6oMbVLKrpxHmaXGSxmJt1F8l3kvqily0N58FGfLM= +github.com/chainreactors/utils v0.0.0-20260711153742-f3d210a5fa9d/go.mod h1:xwbUlFoSSxLHujyb8D48o1s2DqmEAxUNfxIy0DVUmcg= github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863 h1:41tvJzi9t1NUlM/CzVdl8OG+W6PMFChsDOChohI2VeU= github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863/go.mod h1:xvvWMcU9Fcht6GR1cc9ceAZ3/Hl2HrkoRzpeyOzx1rQ= github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 h1:r6UUUUQt4r/0SL6vgrwoq6ynidAkN3auSZsvzZ5BBRE= github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M= -github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863 h1:u9cXebLoVtKwN0KkpGFfrjANUYo+93MijB//X9qONeY= -github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863/go.mod h1:S9lkpQ1I4wcBq0YEBde/UPmR061IPok3bLl7aPz6Vkk= +github.com/chainreactors/utils/parsers v0.0.3 h1:3ld7xG5TSvzikVOCkQHjqjHO3otjODwSwHQDkMKbu5o= +github.com/chainreactors/utils/parsers v0.0.3/go.mod h1:bE/znJWt08n9QOORWsWu0ggB8GWfOg3+dfUMMITmwV4= github.com/chainreactors/utils/pty v0.0.0-20260722180147-5b1816060721 h1:gxkedbTvFEFTtel7XJEPMVh1iznfD+91woPkGBXZMNk= github.com/chainreactors/utils/pty v0.0.0-20260722180147-5b1816060721/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4= github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 h1:lvnDYEkatmZFHP5i321qQXK9L4vKRfso/uUfr5tOeC8= diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui index 70fe4fec..2ab3d12a 160000 --- a/web/frontend/cyber-ui +++ b/web/frontend/cyber-ui @@ -1 +1 @@ -Subproject commit 70fe4fecb43bc461ea505d1ba8cc90ef5aae7048 +Subproject commit 2ab3d12a1f312aacfd9319081fae6fd35a6733d8 From 27c244cc44567e313282353fb34c80bf4607dfc6 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 16:47:49 +0800 Subject: [PATCH 144/348] refactor(build): demote agent command to example --- .github/workflows/ci.yml | 6 +----- Makefile | 17 +++++------------ README.md | 10 ++++------ README_CN.md | 8 +++----- build.sh | 19 +++++++++---------- cmd/agent/capability_test.go | 24 ------------------------ core/deps/architecture_test.go | 26 ++++++++++++++++++++++++++ {cmd => examples}/agent/imports.go | 0 {cmd => examples}/agent/main.go | 14 +++++++------- 9 files changed, 55 insertions(+), 69 deletions(-) delete mode 100644 cmd/agent/capability_test.go rename {cmd => examples}/agent/imports.go (100%) rename {cmd => examples}/agent/main.go (81%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 730974d9..d996dc7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -344,7 +344,7 @@ jobs: -v \ ./pkg/web - # ── Build (depends on test, 3 parallel profiles) ────────────── + # ── Build (depends on test, 2 parallel profiles) ────────────── build: runs-on: ubuntu-22.04 @@ -361,10 +361,6 @@ jobs: main: ./cmd/aiscan tags: "forceposix emptytemplates noembed osusergo netgo full sqlite" generate: true - - id: agent - main: ./cmd/agent - tags: "forceposix emptytemplates noembed osusergo netgo" - generate: false steps: - name: Checkout uses: actions/checkout@v6 diff --git a/Makefile b/Makefile index b22aead0..b7be7925 100644 --- a/Makefile +++ b/Makefile @@ -28,25 +28,22 @@ NPM ?= npm endif STANDARD_BIN ?= $(BIN_DIR)/aiscan$(EXE) -AGENT_BIN ?= $(BIN_DIR)/aiscan-agent$(EXE) FULL_BIN ?= $(BIN_DIR)/aiscan-full$(EXE) -# Standard/full match release artifacts; agent remains a developer build target. +# Standard/full match release artifacts. STANDARD_TAGS := forceposix emptytemplates noembed osusergo netgo cstx_native $(RE2_TAGS) -AGENT_TAGS := forceposix emptytemplates noembed osusergo netgo FULL_TAGS := forceposix emptytemplates noembed osusergo netgo full cstx_native katana_slim $(RE2_TAGS) BUILD_FLAGS := -trimpath -buildvcs=false -.PHONY: help prepare frontend aop-gen standard agent full web-build web-run web all clean +.PHONY: help prepare frontend aop-gen standard full web-build web-run web all clean help: @echo "AIScan build targets:" @echo " make / make standard Build the standard AIScan edition" - @echo " make agent Build the developer-only lightweight agent runtime" @echo " make full Build frontend, then build the full edition" @echo " make web Build the full edition and start the Web UI" @echo " make frontend Build only web/frontend into web/static" - @echo " make all Build all three editions" + @echo " make all Build the standard and full editions" @echo "" @echo "Variables:" @echo " BIN_DIR=path Binary output directory (default: $(BIN_DIR))" @@ -66,10 +63,6 @@ standard: prepare CGO_ENABLED=1 $(GO) build $(BUILD_FLAGS) -ldflags "$(CGO_LDFLAGS)" -tags "$(STANDARD_TAGS)" -o "$(STANDARD_BIN)" ./cmd/aiscan @echo "Built standard edition: $(STANDARD_BIN)" -agent: prepare - CGO_ENABLED=0 $(GO) build $(BUILD_FLAGS) -ldflags "-s -w" -tags "$(AGENT_TAGS)" -o "$(AGENT_BIN)" ./cmd/agent - @echo "Built agent edition: $(AGENT_BIN)" - # The full binary embeds web/static, so frontend must finish first. full: frontend prepare CGO_ENABLED=1 $(GO) build $(BUILD_FLAGS) -ldflags "$(CGO_LDFLAGS)" -tags "$(FULL_TAGS)" -o "$(FULL_BIN)" ./cmd/aiscan @@ -83,7 +76,7 @@ web-run: web: full "$(FULL_BIN)" web --addr "$(WEB_ADDR)" $(if $(strip $(WEB_TOKEN)),--token "$(WEB_TOKEN)",) -all: standard agent full +all: standard full clean: - rm -f "$(STANDARD_BIN)" "$(AGENT_BIN)" "$(FULL_BIN)" + rm -f "$(STANDARD_BIN)" "$(FULL_BIN)" diff --git a/README.md b/README.md index af3e91b6..b3424b4b 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,15 @@ git clone https://github.com/chainreactors/aiscan.git && cd aiscan go build -o aiscan ./cmd/aiscan # standard go build -tags full -o aiscan-full ./cmd/aiscan # full (playwright/katana/passive) -go build -o aiscan-agent ./cmd/agent # developer-only lightweight agent runtime ``` -GitHub Releases publish only the standard and full editions. The lightweight -agent runtime remains available for developers to build from source. The full -target builds the frontend first so the latest `web/static` assets are embedded -into the binary: +The standalone agent executable is no longer a maintained build or release +target. Reference wiring remains in `examples/agent` and can be run manually +with `go run ./examples/agent --help`. The full target builds the frontend first +so the latest `web/static` assets are embedded into the binary: ```bash make # standard edition -make agent # developer-only lightweight agent runtime make full # frontend + full edition make web WEB_ADDR=127.0.0.1:18081 WEB_TOKEN=local-dev # full build + Web UI ``` diff --git a/README_CN.md b/README_CN.md index 034be639..41627a94 100644 --- a/README_CN.md +++ b/README_CN.md @@ -71,16 +71,14 @@ git clone https://github.com/chainreactors/aiscan.git && cd aiscan go build -o aiscan ./cmd/aiscan # 标准版 go build -tags full -o aiscan-full ./cmd/aiscan # 完整版(含 playwright/katana/passive) -go build -o aiscan-agent ./cmd/agent # 仅供开发者使用的轻量 agent 运行时 ``` -GitHub Releases 只发布标准版和完整版。轻量 agent 运行时仍保留给开发者从 -源码自行编译。`make full` 会先构建前端,再将最新的 `web/static` 嵌入 full -二进制: +独立 agent 可执行文件不再作为维护或发布目标。参考 wiring 已迁移到 +`examples/agent`,需要时可手动运行 `go run ./examples/agent --help`。 +`make full` 会先构建前端,再将最新的 `web/static` 嵌入 full 二进制: ```bash make # Standard 默认版 -make agent # 仅供开发者使用的轻量 agent 运行时 make full # 前端 + Full 完整版 make web WEB_ADDR=127.0.0.1:18081 WEB_TOKEN=local-dev # Full 构建并启动 Web UI ``` diff --git a/build.sh b/build.sh index ac6e5b66..9864dbac 100755 --- a/build.sh +++ b/build.sh @@ -106,7 +106,7 @@ aiscan 构建脚本 --output DIR 输出目录 (默认: dist) --embed 嵌入扫描资源(不加 emptytemplates/noembed tag) --ioa (已废弃, ioa serve 已集成到 aiscan 主二进制) - --profile PROFILE 构建配置: agent (~28MB), mini (默认, ~77MB), full (~123MB) + --profile PROFILE 构建配置: mini (默认, ~77MB), full (~123MB) LLM 覆盖(优先级高于 aiscan.yaml): --llm-provider NAME @@ -140,7 +140,6 @@ Web Search: ./build.sh --llm-provider deepseek --llm-model deepseek-chat ./build.sh --embed # 嵌入资源的完整构建 ./build.sh -g # 打印 ldflags(用于自定义构建命令) - ./build.sh --profile agent -o linux/amd64 # agent 构建 (仅 agent REPL + Arsenal, 无内置扫描器) ./build.sh --profile full -o linux/amd64 # full 构建 (全部扫描器 + browser + recon + ioa) HELP exit 0 @@ -153,6 +152,14 @@ HELP esac done +case "$PROFILE" in + mini|full) ;; + *) + echo "未知 profile: $PROFILE (可选: mini, full)" >&2 + exit 1 + ;; +esac + # ─── 读取配置 ──────────────────────────────────────────────────── resolve() { @@ -242,19 +249,11 @@ AISCAN_MAIN="./cmd/aiscan" case "$PROFILE" in mini) ;; - agent) - AISCAN_BIN="aiscan-agent" - AISCAN_MAIN="./cmd/agent" - ;; full) EXTRA_TAGS="full${EXTRA_TAGS:+,$EXTRA_TAGS}" BUILD_IOA=true AISCAN_BIN="aiscan-full" ;; - *) - echo "未知 profile: $PROFILE (可选: agent, mini, full)" >&2 - exit 1 - ;; esac # ─── Build tags ────────────────────────────────────────────────── diff --git a/cmd/agent/capability_test.go b/cmd/agent/capability_test.go deleted file mode 100644 index 97cf0968..00000000 --- a/cmd/agent/capability_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "slices" - "testing" - - "github.com/chainreactors/aiscan/core/capability" - cfg "github.com/chainreactors/aiscan/core/config" -) - -func TestAgentCapabilitySetHasNoScanner(t *testing.T) { - want := []string{"arsenal", "core", "ioa"} - if got := capability.IDsSorted(); !slices.Equal(got, want) { - t.Fatalf("agent capabilities = %#v, want %#v", got, want) - } - for _, descriptor := range capability.All() { - if descriptor.Kind == capability.KindScanner { - t.Fatalf("agent linked scanner capability %q", descriptor.ID) - } - } - if got := cfg.CLICommandSummary(); got != "agent, web, serve" { - t.Fatalf("agent command summary = %q, want %q", got, "agent, web, serve") - } -} diff --git a/core/deps/architecture_test.go b/core/deps/architecture_test.go index f582257c..a9a7c115 100644 --- a/core/deps/architecture_test.go +++ b/core/deps/architecture_test.go @@ -40,6 +40,7 @@ func TestLegacyPackagesCannotReturn(t *testing.T) { {dir: filepath.Join("pkg", "util"), importPath: modulePath + "/pkg/" + "util"}, {dir: filepath.Join("core", "runner"), importPath: modulePath + "/core/" + "runner"}, {dir: filepath.Join("core", "transport"), importPath: modulePath + "/core/" + "transport"}, + {dir: filepath.Join("cmd", "agent"), importPath: modulePath + "/cmd/" + "agent"}, } for _, item := range legacy { legacyDir := filepath.Join(root, item.dir) @@ -81,6 +82,31 @@ func TestLegacyPackagesCannotReturn(t *testing.T) { } } +func TestAgentExampleIsNotMaintainedBuildTarget(t *testing.T) { + root := repositoryRoot(t) + example := filepath.Join(root, "examples", "agent", "main.go") + if _, err := os.Stat(example); err != nil { + t.Fatalf("agent example is missing: %v", err) + } + + for _, rel := range []string{ + filepath.Join(".github", "workflows", "ci.yml"), + "Makefile", + "build.sh", + } { + path := filepath.Join(root, rel) + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", relative(root, path), err) + } + for _, forbidden := range []string{"cmd/agent", "examples/agent", "aiscan-agent"} { + if strings.Contains(string(content), forbidden) { + t.Errorf("maintained build file %s references agent binary token %q", relative(root, path), forbidden) + } + } + } +} + func assertNoFirstPartyImports(t *testing.T, tree string, forbidden map[string]bool) { t.Helper() root := repositoryRoot(t) diff --git a/cmd/agent/imports.go b/examples/agent/imports.go similarity index 100% rename from cmd/agent/imports.go rename to examples/agent/imports.go diff --git a/cmd/agent/main.go b/examples/agent/main.go similarity index 81% rename from cmd/agent/main.go rename to examples/agent/main.go index 7a2517d6..4269f271 100644 --- a/cmd/agent/main.go +++ b/examples/agent/main.go @@ -21,12 +21,12 @@ func main() { parser := goflags.NewParser(&option, goflags.Default&^goflags.PrintErrors) parser.Usage = `[OPTIONS] -aiscan-agent - Minimal AI agent with Arsenal toolkit +AIScan agent example - reference wiring for the root agent packages -Examples: - aiscan-agent -p "list available tools using arsenal" - aiscan-agent -p "install nuclei and scan target" -i http://target.com - aiscan-agent --base-url https://api.deepseek.com --model deepseek-v4-pro` +Run manually: + go run ./examples/agent -p "list available tools using arsenal" + go run ./examples/agent -p "install nuclei and scan target" -i http://target.com + go run ./examples/agent --base-url https://api.deepseek.com --model deepseek-v4-pro` if _, err := parser.Parse(); err != nil { if flagsErr, ok := err.(*goflags.Error); ok && flagsErr.Type == goflags.ErrHelp { @@ -38,7 +38,7 @@ Examples: } if option.Version { - fmt.Printf("aiscan-agent v%s\n", cfg.Version) + fmt.Printf("AIScan agent example v%s\n", cfg.Version) return } @@ -85,7 +85,7 @@ Examples: interruptMu.Unlock() }) if err != nil { - logger.Errorf("agent failed: %s", err) + logger.Errorf("agent example failed: %s", err) os.Exit(1) } } From 38f14418ec11f6591f6939102c5fa6b2a6162a15 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 16:47:50 +0800 Subject: [PATCH 145/348] test(output): normalize golden fixture line endings --- core/output/report_golden_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/output/report_golden_test.go b/core/output/report_golden_test.go index 014cc660..238ec2ed 100644 --- a/core/output/report_golden_test.go +++ b/core/output/report_golden_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" ) @@ -47,8 +48,9 @@ func checkReportGolden(t *testing.T, name, got string) { if err != nil { t.Fatalf("read golden (run go test -run %s -update-report-golden): %v", t.Name(), err) } - if got != string(want) { - t.Errorf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, got, string(want)) + wantText := strings.ReplaceAll(string(want), "\r\n", "\n") + if got != wantText { + t.Errorf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, got, wantText) } } From 3530077494b063b22776828f03ce945ab19c879c Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 18:02:28 +0800 Subject: [PATCH 146/348] refactor(runner): transfer command ownership to cairn --- cmd/runner/imports.go | 11 --- cmd/runner/main.go | 122 --------------------------------- cmd/runner/main_test.go | 25 ------- core/deps/architecture_test.go | 1 + docs/cairn-runner-design.md | 64 ----------------- 5 files changed, 1 insertion(+), 222 deletions(-) delete mode 100644 cmd/runner/imports.go delete mode 100644 cmd/runner/main.go delete mode 100644 cmd/runner/main_test.go delete mode 100644 docs/cairn-runner-design.md diff --git a/cmd/runner/imports.go b/cmd/runner/imports.go deleted file mode 100644 index 6a000c8d..00000000 --- a/cmd/runner/imports.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - _ "github.com/chainreactors/aiscan/tools" - _ "github.com/chainreactors/aiscan/tools/arsenal" - _ "github.com/chainreactors/aiscan/tools/gogo" - _ "github.com/chainreactors/aiscan/tools/neutron" - _ "github.com/chainreactors/aiscan/tools/proton" - _ "github.com/chainreactors/aiscan/tools/spray" - _ "github.com/chainreactors/aiscan/tools/zombie" -) diff --git a/cmd/runner/main.go b/cmd/runner/main.go deleted file mode 100644 index defca306..00000000 --- a/cmd/runner/main.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "os" - "os/signal" - "strings" - "syscall" - - "github.com/chainreactors/aiscan/core/capability" - cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/resources" - "github.com/chainreactors/aiscan/core/telemetry" - "github.com/chainreactors/aiscan/pkg/commands" - apprunner "github.com/chainreactors/aiscan/pkg/runner" - "github.com/chainreactors/aiscan/pkg/webagent" - "github.com/chainreactors/aiscan/tools/scan/engine" -) - -func main() { - var ( - serverURL string - token string - runnerID string - wsPath string - configFile string - ) - flag.StringVar(&serverURL, "server", "", "Cairn server URL, e.g. http://host:8080") - flag.StringVar(&token, "token", "", "runner token") - flag.StringVar(&runnerID, "id", "", "stable runner ID (default: hostname)") - flag.StringVar(&wsPath, "ws-path", "/ws/runner", "runner WebSocket path") - flag.StringVar(&configFile, "config", "", "path to aiscan.yaml") - flag.Parse() - if serverURL == "" || token == "" { - fmt.Fprintln(os.Stderr, "usage: aiscan-runner --server --token [--id ]") - os.Exit(2) - } - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - logger := telemetry.GlobalLogger(telemetry.LogConfig{Output: os.Stderr}) - - option := &cfg.Option{} - option.ConfigFile = configFile - if _, err := apprunner.ResolveRuntimeConfig(option); err != nil { - logger.Errorf("load config: %v", err) - os.Exit(1) - } - dataBus := eventbus.New[output.ToolDataEvent]() - registry, err := initTools(ctx, option, logger, dataBus) - if err != nil { - logger.Errorf("initialize tools: %v", err) - os.Exit(1) - } - defer closeTools(registry) - - sco := output.NewSCOSidecar(dataBus, output.CSTXTransform) - defer sco.Close() - logger.Infof("tools ready: %s", strings.Join(registry.Names(), ", ")) - if err := webagent.RunToolNode(ctx, webagent.ToolNodeConfig{ - ServerURL: serverURL, - WSPath: wsPath, - ID: runnerID, - Token: token, - Registry: registry, - DataBus: dataBus, - SCO: sco, - Logger: logger, - Version: cfg.Version, - }); err != nil { - logger.Errorf("runner: %v", err) - os.Exit(1) - } -} - -func initTools(ctx context.Context, option *cfg.Option, logger telemetry.Logger, dataBus *eventbus.Bus[output.ToolDataEvent]) (*commands.CommandRegistry, error) { - engineSet, err := engine.InitWithOptions(ctx, resources.Options{ - CyberhubURL: option.CyberhubURL, - APIKey: option.CyberhubKey, - Mode: option.CyberhubMode, - Proxy: option.Proxy, - }, logger) - if err != nil { - logger.Warnf("engine init: %v (continuing with available engines)", err) - } - - workDir, _ := os.Getwd() - registry := commands.NewRegistry() - deps := &commands.Deps{ - WorkDir: workDir, - RunnerMode: true, - Logger: logger, - DataBus: dataBus, - ScannerProxy: option.Proxy, - } - if engineSet != nil { - commands.Provide(deps, engine.SetKey, engineSet) - commands.Provide(deps, resources.SetKey, engineSet.Resources) - } - commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core", "scanner", "arsenal"}}), deps, registry) - registry.SetLogger(logger) - return registry, nil -} - -func closeTools(registry *commands.CommandRegistry) { - if registry == nil { - return - } - for _, tool := range registry.Tools() { - if closer, ok := tool.(interface{ Close() }); ok { - closer.Close() - } - } - for _, command := range registry.All() { - if command.Close != nil { - command.Close() - } - } -} diff --git a/cmd/runner/main_test.go b/cmd/runner/main_test.go deleted file mode 100644 index d942021c..00000000 --- a/cmd/runner/main_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "context" - "testing" - - cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/telemetry" -) - -func TestInitToolsRegistersBash(t *testing.T) { - registry, err := initTools(context.Background(), &cfg.Option{}, telemetry.NopLogger(), eventbus.New[output.ToolDataEvent]()) - if err != nil { - t.Fatal(err) - } - defer closeTools(registry) - if _, ok := registry.GetTool("bash"); !ok { - t.Fatal("bash tool is not registered") - } - if _, ok := registry.GetTool("ls"); !ok { - t.Fatal("native ls tool is not registered") - } -} diff --git a/core/deps/architecture_test.go b/core/deps/architecture_test.go index a9a7c115..3faca71d 100644 --- a/core/deps/architecture_test.go +++ b/core/deps/architecture_test.go @@ -41,6 +41,7 @@ func TestLegacyPackagesCannotReturn(t *testing.T) { {dir: filepath.Join("core", "runner"), importPath: modulePath + "/core/" + "runner"}, {dir: filepath.Join("core", "transport"), importPath: modulePath + "/core/" + "transport"}, {dir: filepath.Join("cmd", "agent"), importPath: modulePath + "/cmd/" + "agent"}, + {dir: filepath.Join("cmd", "runner"), importPath: modulePath + "/cmd/" + "runner"}, } for _, item := range legacy { legacyDir := filepath.Join(root, item.dir) diff --git a/docs/cairn-runner-design.md b/docs/cairn-runner-design.md deleted file mode 100644 index 1ffa0d1a..00000000 --- a/docs/cairn-runner-design.md +++ /dev/null @@ -1,64 +0,0 @@ -# aiscan as cairn runner - -## 核心思路 - -aiscan `cmd/runner/` 编译精简二进制(工具链,不带 agent/web/TUI),作为 **tool-only 节点**复用 aiscan 中立的 `pkg/webagent`/`pkg/webproto` 协议接入 cairn。cairn 服务端(`server/internal/runner/`)做信封适配。 - -aiscan 侧不再维护任何 cairn 专属协议包(原 `pkg/cairnrunner` 已删除)。所有 aiscan 工具(scan/gogo/spray/neutron/zombie/proton)通过 webproto 的 `exec` 消息暴露,runner 在 exec handler 中拦截已注册的命令名走进程内执行(BashTool 统一策略边界)。 - -## 协议:复用 webproto - -runner 与 cairn 之间使用 aiscan 的 webproto 信封 `{type, task_id, data, data_b64, payload}`: - -| 消息 | 方向 | 用途 | -|---|---|---| -| `register` → `connected` | R→S / S→R | 握手(payload 携带 name/node/runtime/commands) | -| `exec` → `complete`/`error` | S→R / R→S | 命令执行(payload: ExecPayload / ExecResult) | -| `output` | R→S | 流式 stdout/stderr(payload.stream 区分流) | -| `file.read` / `file.write` → `complete` | S→R / R→S | 文件读写(base64 `data_b64`,JSON-only,无 binary frame) | -| `pty` | 双向 | PTY 帧(payload 结构不变) | -| `cancel` | S→R | 按 task_id 取消 | -| `tool.data` / `tool.sco` | R→S | 扫描器遥测 / 归一化 SCO 节点(task_id = call_id) | -| WS ping/pong | 双向 | 心跳(原生帧) | - -与早期 bespoke 设计(hello/welcome、数字 id req/res、binary frame 文件块)的差异全部由 **cairn 服务端适配层**吸收:数字 id 映射为字符串 task_id(`exec-N`),文件传输改为单发 base64,握手改为 register/connected。 - -## aiscan runner 侧 - -### cmd/runner/main.go - -入口只做三件事: - -1. 解析 flags(`--server` / `--token` / `--name` / `--ws-path`,ws-path 默认 `/ws/runner`) -2. `initTools()` 构建 `*commands.CommandRegistry`(core/scanner/arsenal 组)+ dataBus + SCO sidecar -3. 调 `webagent.RunToolNode(ctx, webagent.ToolNodeConfig{...})` - -`RunToolNode`(`pkg/webagent/toolnode.go`)复用 webagent 的连接循环(register 握手、断线重连、exec/file/pty/cancel 分发、tool.data/tool.sco 事件转发),但不挂 LLM provider、agent loop 与 IOA 依赖——NodeRef 由 ServerURL 直接合成。 - -### exec handler — 统一进入 BashTool - -`pkg/webagent/exec.go` 的 `ExecCommand`:所有 exec 经注册的 BashTool `RunForeground` 执行,流式输出走 `output` 消息(`payload:{"stream":"stdout"}`),终态走 `complete` 消息(payload 为 `webproto.ExecResult{exit_code, state, kill_cause, duration, details?}`)。 - -## cairn 服务端适配 - -适配全部集中在 `server/internal/runner/`,TS 侧零改动(只走 Go 内部 HTTP API): - -- `protocol.go` — 信封类型换成 webproto(`{type, task_id, ...}`) -- `bridge.go` — 握手 register→connected;读循环按 `type` 分发;tool.data/tool.sco 的 call_id 取 `task_id` -- `rpc.go` — pending map 键为字符串 task_id;exec 结果从 `complete.payload` 组装;文件读写单发 base64 - -## PTY 转发 + cyber-ui 复用 - -cairn 的浏览器终端经 `/ws/runners/:id/exec` 把 `pty` 消息透传到 runner 连接;runner 侧由 webagent 连接循环里的 `PTYRouter` 处理(frame 结构与 aiscan WebAgent 完全一致,仅信封字段名为 `type` 而非 `t`)。 - -## Explore agent 使用 - -全部通过 exec,和普通 shell 命令一样: - -```bash -scan -i 192.168.1.0/24 --mode quick -gogo -i 10.0.0.1 -p top1000 -neutron -t cve-2024-xxxx.yaml 192.168.1.10 -spray -u http://target.com --crawl -zombie -i 192.168.1.10:3306 --top 100 -``` From d940d30e81f5ffc2d3d2be1e692264516a8b053a Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 28 Jul 2026 19:20:47 +0800 Subject: [PATCH 147/348] fix(deps): preserve CONNECT tunnel bytes --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 52bb429b..5ea1892c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/chainreactors/logs v0.0.0-20260624034259-9aaea4aa52cc github.com/chainreactors/neutron v0.1.1-0.20260714062907-716c6b167cb6 github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131 - github.com/chainreactors/proxyclient v1.1.1-0.20260714062913-bce898a8f790 + github.com/chainreactors/proxyclient v1.1.1-0.20260728110701-74504679dc47 github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3 github.com/chainreactors/sdk v0.3.4-0.20260708104745-dcad8620f5e9 github.com/chainreactors/sdk/gogo v0.0.0-20260708104745-dcad8620f5e9 diff --git a/go.sum b/go.sum index 32f93324..4a9111d4 100644 --- a/go.sum +++ b/go.sum @@ -193,8 +193,8 @@ github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe h1:n1pFLHHYX github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe/go.mod h1:ygxMqZQ/hGY2uegUvC0LbR538hbgNH7HP4dTuv/jfSM= github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131 h1:gTrBbrASTvndSBr2XL75Kdw8fAM3xw/dikTqMNzoQBE= github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131/go.mod h1:c4kezBtDrE4sBIH6qF0+OShJ3/fijZENyxcUbcfZ/qQ= -github.com/chainreactors/proxyclient v1.1.1-0.20260714062913-bce898a8f790 h1:We2R3rJUQBI6GGmA2kM3IOK+yvPn5NsXHhpbKiVsPE0= -github.com/chainreactors/proxyclient v1.1.1-0.20260714062913-bce898a8f790/go.mod h1:m0kmv5rgzac2q3HMYBlQULc+mubGILrPx1IiPQWcQHc= +github.com/chainreactors/proxyclient v1.1.1-0.20260728110701-74504679dc47 h1:2Dmj2xnsUb0cy7yY37l3Qt8GQEWjos7vC/kJar6qc9A= +github.com/chainreactors/proxyclient v1.1.1-0.20260728110701-74504679dc47/go.mod h1:DPIRtV3QMlIvdoHAn55XFxSZCn5XW1uuLx2PaKeKcwE= github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3 h1:WVEb3Bjq3AC67oa9pNjHJfIH+mX6PozJO3S5ccVZJYQ= github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3/go.mod h1:4BG8xIxTebn0VoOTEwQ2pmYIB3K2qwGINxnRHALBNZg= github.com/chainreactors/sdk v0.3.4-0.20260708104745-dcad8620f5e9 h1:zHGP9WFSpTyZYWeKDHPDoEysmNYjck25Wv8/eVtpYBE= From 8ced520e8da740fa659f6c43ce3d41098945e611 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 12:36:15 +0800 Subject: [PATCH 148/348] refactor(config): centralize env and provider protocols --- README.md | 2 +- README_CN.md | 2 +- build.sh | 6 +- cmd/aiscan/cli_test.go | 8 +- cmd/aiscan/setup.go | 16 +-- cmd/aiscan/web_full.go | 3 +- core/config/config_gen.go | 10 +- core/config/datadir.go | 7 +- core/config/env.go | 113 +++++++++++++++++--- core/config/loader_test.go | 101 ++++++++++++++++- core/config/options.go | 9 +- docs/agent.md | 10 +- docs/mechanisms.md | 2 + docs/reference.md | 69 ++++++------ pkg/commands/factory.go | 17 +-- pkg/runner/app.go | 15 +-- pkg/runner/application_builder.go | 60 ++++++----- pkg/runner/application_config.go | 34 +++--- pkg/runner/provider_config.go | 16 ++- pkg/runner/provider_config_test.go | 4 +- pkg/runner/remote_repl_test.go | 7 +- pkg/runner/runner.go | 3 +- pkg/runner/scanner.go | 14 ++- pkg/tui/console.go | 8 +- pkg/tui/output.go | 11 +- pkg/tui/render.go | 5 +- pkg/web/agents.go | 3 +- pkg/web/config_profiles_test.go | 4 +- pkg/web/config_reload_test.go | 2 +- pkg/web/types.go | 1 + pkg/webagent/remote.go | 4 + pkg/webagent/remote_test.go | 2 +- pkg/webproto/config.go | 23 +++- pkg/webproto/config_test.go | 18 ++++ tools/ioa/commands_test.go | 4 +- tools/playwright/browser.go | 15 ++- tools/playwright/browser_test.go | 7 ++ tools/playwright/register.go | 2 +- tools/scan/engine/set.go | 1 + tools/scan/engine/set_uncover_recon.go | 8 ++ tools/scan/engine/uncover.go | 38 ++++++- tools/scan/engine/uncover_test.go | 27 +++++ tools/search/tavily.go | 3 - tools/search/tavily_test.go | 8 ++ web/frontend/e2e/start-server.mjs | 2 +- web/frontend/src/components/ConfigPanel.tsx | 17 ++- web/frontend/src/i18n/locales/en/config.ts | 2 +- web/frontend/src/i18n/locales/zh/config.ts | 2 +- 48 files changed, 553 insertions(+), 192 deletions(-) create mode 100644 pkg/webproto/config_test.go diff --git a/README.md b/README.md index af3e91b6..d2bcf6e1 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \ export OPENAI_API_KEY="sk-..." # CLI arguments -aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat +aiscan agent --provider openai --base-url https://api.deepseek.com/v1 --api-key sk-... --model deepseek-chat ``` Config file `aiscan.yaml`: diff --git a/README_CN.md b/README_CN.md index 034be639..8a60eb42 100644 --- a/README_CN.md +++ b/README_CN.md @@ -186,7 +186,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \ export OPENAI_API_KEY="sk-..." # CLI 参数 -aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat +aiscan agent --provider openai --base-url https://api.deepseek.com/v1 --api-key sk-... --model deepseek-chat ``` 配置文件 `aiscan.yaml`: diff --git a/build.sh b/build.sh index ac6e5b66..c07c2f89 100755 --- a/build.sh +++ b/build.sh @@ -7,7 +7,7 @@ # ./build.sh -o linux/amd64 # 快速编译单一平台 # ./build.sh -o "linux/amd64 darwin/arm64" # 编译指定平台 # ./build.sh --config prod.yaml # 使用指定配置文件 -# ./build.sh --llm-model deepseek-chat # CLI 覆盖配置文件中的值 +# ./build.sh --llm-provider openai --llm-model deepseek-chat # OpenAI-compatible # ./build.sh --embed # 嵌入扫描资源(不加 emptytemplates/noembed tag) # ./build.sh --ioa # 同时编译 ioa server 二进制 @@ -109,7 +109,7 @@ aiscan 构建脚本 --profile PROFILE 构建配置: agent (~28MB), mini (默认, ~77MB), full (~123MB) LLM 覆盖(优先级高于 aiscan.yaml): - --llm-provider NAME + --llm-provider TYPE openai (OpenAI-compatible) or anthropic --llm-base-url URL --llm-api-key KEY --llm-model NAME @@ -137,7 +137,7 @@ Web Search: ./build.sh -o linux/amd64 # 快速编译单平台 ./build.sh --config prod.yaml -o linux/amd64 # 使用生产配置编译 ./build.sh --cyberhub-url http://10.0.0.1:9000 --cyberhub-key mykey - ./build.sh --llm-provider deepseek --llm-model deepseek-chat + ./build.sh --llm-provider openai --llm-base-url https://api.deepseek.com/v1 --llm-model deepseek-chat ./build.sh --embed # 嵌入资源的完整构建 ./build.sh -g # 打印 ldflags(用于自定义构建命令) ./build.sh --profile agent -o linux/amd64 # agent 构建 (仅 agent REPL + Arsenal, 无内置扫描器) diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go index 00c8b653..acecada8 100644 --- a/cmd/aiscan/cli_test.go +++ b/cmd/aiscan/cli_test.go @@ -194,8 +194,8 @@ func TestParseCLIAgentAcceptsLLMFlags(t *testing.T) { t.Fatalf("llm options = %#v", opt.LLMOptions) } pcfg := runner.ProviderConfig(&opt) - if pcfg.Provider != "" { - t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider) + if pcfg.Provider != "openai" { + t.Fatalf("provider = %q, want openai protocol", pcfg.Provider) } resolved, err := agent.ResolveProvider(&pcfg) if err != nil { @@ -304,8 +304,8 @@ func TestParseCLIScanExtractsLLMFlags(t *testing.T) { t.Fatalf("llm options = %#v", opt.LLMOptions) } pcfg := runner.ProviderConfig(&opt) - if pcfg.Provider != "" { - t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider) + if pcfg.Provider != "openai" { + t.Fatalf("provider = %q, want openai protocol", pcfg.Provider) } resolved, err := agent.ResolveProvider(&pcfg) if err != nil { diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 9485a8b0..97983f4b 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -63,6 +63,7 @@ func initEngines(ctx context.Context, sc runner.ScannerConfig, logger telemetry. HunterAPIKey: sc.HunterAPIKey, IngressProxy: sc.ReconProxy, Limit: sc.ReconLimit, + ProviderKeys: sc.ReconProviderKeys, } engineSet.SetupUncover(recon, logger) return engineSet @@ -97,13 +98,14 @@ func registerScannerCommands(cmdReg *commands.CommandRegistry, engineSet *engine workDir, _ := os.Getwd() deps := &commands.Deps{ - WorkDir: workDir, - BashTimeout: toolCfg.BashTimeout, - SkillStore: skillStore, - ScannerProxy: scanCfg.Proxy, - Logger: logger, - TavilyKeys: toolCfg.TavilyKeys, - DataBus: dataBus, + WorkDir: workDir, + BashTimeout: toolCfg.BashTimeout, + SkillStore: skillStore, + ScannerProxy: scanCfg.Proxy, + Logger: logger, + TavilyKeys: toolCfg.TavilyKeys, + PlaywrightSession: toolCfg.PlaywrightSession, + DataBus: dataBus, } commands.Provide(deps, scan.OptsKey, scanOpts) if engineSet != nil { diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index bb300a0b..4ab0556a 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -38,7 +38,7 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom } defer store.Close() - application, err := initWebApp(ctx, option, logger) + application, err := initWebApp(ctx, explicitOption, logger) if err != nil { return fmt.Errorf("init aiscan: %s", err) } @@ -269,6 +269,7 @@ func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming w } current = parseDistributeConfig(data) } + webproto.MigrateLLMConfig(&incoming.LLM, webproto.LLMProviderConfig{}) // Preserve existing secrets when incoming value is empty. preserveLLMProfileSecrets(&incoming.LLM, current.LLM) diff --git a/core/config/config_gen.go b/core/config/config_gen.go index 045646b7..e68a8382 100644 --- a/core/config/config_gen.go +++ b/core/config/config_gen.go @@ -9,7 +9,7 @@ import ( const configFileHeader = `# aiscan 配置文件 # # 运行时: aiscan 自动加载 ./aiscan.yaml 或 <二进制所在目录>/aiscan.yaml -# 优先级: CLI 参数 > 环境变量 > 配置文件 > 默认值 +# 优先级: CLI > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 默认值 # 生成: aiscan --init # # 仅填写需要的字段,留空或删除的字段不会覆盖其他来源的值 @@ -17,16 +17,18 @@ const configFileHeader = `# aiscan 配置文件 # LLM 配置支持两种格式: # 格式一 — 单 provider 简写(兼容旧配置): # llm: -# provider: deepseek +# provider: openai +# base_url: https://api.deepseek.com/v1 # api_key: sk-... # model: deepseek-chat # -# 格式二 — providers 配置列表(通过 active_profile 显式选择): +# 格式二 — LLM profile 列表(字段名 providers,通过 active_profile 选择): # llm: # active_profile: deepseek # providers: # - id: deepseek -# provider: deepseek +# provider: openai +# base_url: https://api.deepseek.com/v1 # api_key: sk-... # model: deepseek-chat # - id: openai diff --git a/core/config/datadir.go b/core/config/datadir.go index 2da39bd1..641e779d 100644 --- a/core/config/datadir.go +++ b/core/config/datadir.go @@ -3,7 +3,6 @@ package config import ( "os" "path/filepath" - "strings" "sync" ) @@ -24,14 +23,12 @@ func SetDataDir(dir string) { } // DataDir returns the resolved .aiscan data directory. -// Priority: AISCAN_DATA_DIR env > config/CLI --data-dir > /.aiscan +// Priority is resolved centrally before this function is called: +// CLI > AISCAN_DATA_DIR > config > /.aiscan. func DataDir() string { dataDirOnce.Do(func() { dataDirMu.Lock() defer dataDirMu.Unlock() - if v := strings.TrimSpace(os.Getenv("AISCAN_DATA_DIR")); v != "" { - resolvedDataDir = v - } if resolvedDataDir == "" { if exe, err := os.Executable(); err == nil { resolvedDataDir = filepath.Join(filepath.Dir(exe), dataDirName) diff --git a/core/config/env.go b/core/config/env.go index 9517cab9..04d756f7 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -17,6 +17,7 @@ func ResolveRuntimeConfig(option *Option, applyProcessState bool, inferProvider return configPath, err } applyEnvironment(option, explicit, os.LookupEnv, inferProvider) + normalizeProviderOptions(option, inferProvider) ApplyDefaults(option) if _, err := ResolveOutputPolicy(option); err != nil { return configPath, err @@ -31,6 +32,7 @@ func applyEnvironment(option *Option, explicit Option, lookup envLookup, inferPr applyLLMEnvironment(option, explicit, lookup, inferProvider) applyScannerEnvironment(option, explicit, lookup) applyReconEnvironment(option, explicit, lookup) + applyRuntimeEnvironment(option, explicit, lookup) } func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { @@ -109,17 +111,17 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe func applyScannerEnvironment(option *Option, explicit Option, lookup envLookup) { if strings.TrimSpace(explicit.CyberhubURL) == "" { - if v := firstEnv(lookup, "CYBERHUB_URL", "AISCAN_CYBERHUB_URL"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_URL", "CYBERHUB_URL"); v != "" { option.CyberhubURL = v } } if strings.TrimSpace(explicit.CyberhubKey) == "" { - if v := firstEnv(lookup, "CYBERHUB_KEY", "AISCAN_CYBERHUB_KEY"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_KEY", "CYBERHUB_KEY"); v != "" { option.CyberhubKey = v } } if strings.TrimSpace(explicit.CyberhubMode) == "" { - if v := firstEnv(lookup, "CYBERHUB_MODE", "AISCAN_CYBERHUB_MODE"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_MODE", "CYBERHUB_MODE"); v != "" { option.CyberhubMode = v } } @@ -161,26 +163,75 @@ func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) { option.ReconProxy = v } } + applyReconProviderEnvironment(option, lookup) +} + +func applyRuntimeEnvironment(option *Option, explicit Option, lookup envLookup) { + if strings.TrimSpace(explicit.DataDir) == "" { + if v := firstEnv(lookup, "AISCAN_DATA_DIR"); v != "" { + option.DataDir = v + } + } + if strings.TrimSpace(explicit.RenderMode) == "" { + option.RenderMode = firstEnv(lookup, "AISCAN_RENDER") + } + if strings.TrimSpace(explicit.REPLMode) == "" { + option.REPLMode = firstEnv(lookup, "AISCAN_REPL") + } + if strings.TrimSpace(explicit.PlaywrightSession) == "" { + option.PlaywrightSession = firstEnv(lookup, "PLAYWRIGHT_CLI_SESSION") + } +} + +var reconProviderEnvNames = []string{ + "SHODAN_API_KEY", + "QUAKE_TOKEN", + "NETLAS_API_KEY", + "CRIMINALIP_API_KEY", + "PUBLICWWW_API_KEY", + "HUNTERHOW_API_KEY", + "ZOOMEYE_API_KEY", + "DRIFTNET_API_KEY", + "DAYDAYMAP_API_KEY", + "CENSYS_API_TOKEN", + "CENSYS_ORGANIZATION_ID", + "GOOGLE_API_KEY", + "GOOGLE_API_CX", + "ODIN_API_KEY", + "BINARYEDGE_API_KEY", + "ONYPHE_API_KEY", + "GREYNOISE_API_KEY", + "NERDYDATA_API_KEY", +} + +func applyReconProviderEnvironment(option *Option, lookup envLookup) { + for _, name := range reconProviderEnvNames { + if value := firstEnv(lookup, name); value != "" { + if option.ReconProviderKeys == nil { + option.ReconProviderKeys = make(map[string]string) + } + option.ReconProviderKeys[name] = value + } + } } func selectedEnvProvider(option *Option, lookup envLookup, inferProvider func(string) string) string { if v := strings.ToLower(strings.TrimSpace(option.Provider)); v != "" { - return v + return normalizeProviderName(v) } if option.BaseURL != "" && inferProvider != nil { return inferProvider(option.BaseURL) } - if firstEnv(lookup, "ANTHROPIC_API_KEY") != "" { - return "anthropic" - } - if firstEnv(lookup, "OPENAI_API_KEY") != "" { - return "openai" + for _, providerName := range []string{"anthropic", "openai"} { + if providerAPIKeyEnv(providerName, lookup) != "" { + return providerName + } } return "" } func providerBaseURLEnv(providerName string, lookup envLookup) string { - providerName = strings.ToLower(strings.TrimSpace(providerName)) + providerName = canonicalEnvProvider(providerName) if providerName == "" { return "" } @@ -193,7 +244,7 @@ func providerBaseURLEnv(providerName string, lookup envLookup) string { } func providerModelEnv(providerName string, lookup envLookup) string { - providerName = strings.ToLower(strings.TrimSpace(providerName)) + providerName = canonicalEnvProvider(providerName) if providerName == "" { return "" } @@ -201,13 +252,41 @@ func providerModelEnv(providerName string, lookup envLookup) string { } func providerAPIKeyEnv(providerName string, lookup envLookup) string { - providerName = strings.ToLower(strings.TrimSpace(providerName)) - switch providerName { - case "anthropic": - return firstEnv(lookup, "ANTHROPIC_API_KEY") - default: - return firstEnv(lookup, "OPENAI_API_KEY") + providerName = canonicalEnvProvider(providerName) + if providerName == "" { + return "" + } + return firstEnv(lookup, providerEnvName(providerName, "API_KEY")) +} + +func canonicalEnvProvider(providerName string) string { + if strings.TrimSpace(providerName) == "" { + return "" + } + return normalizeProviderName(providerName) +} + +func normalizeProviderOptions(option *Option, inferProvider func(string) string) { + if strings.TrimSpace(option.Provider) != "" { + option.Provider = normalizeProviderName(option.Provider) + } else if strings.TrimSpace(option.BaseURL) != "" && inferProvider != nil { + option.Provider = normalizeProviderName(inferProvider(option.BaseURL)) + } + for i := range option.Providers { + providerName := strings.TrimSpace(option.Providers[i].Provider) + if providerName != "" { + option.Providers[i].Provider = normalizeProviderName(providerName) + } else if inferProvider != nil { + option.Providers[i].Provider = normalizeProviderName(inferProvider(option.Providers[i].BaseURL)) + } + } +} + +func normalizeProviderName(name string) string { + if strings.EqualFold(strings.TrimSpace(name), "anthropic") { + return "anthropic" } + return "openai" } func providerEnvName(providerName, suffix string) string { diff --git a/core/config/loader_test.go b/core/config/loader_test.go index b3b4af69..f4a996b6 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -519,7 +519,7 @@ cyberhub: } checks := []struct{ field, got, want string }{ - {"Provider", option.Provider, "deepseek"}, + {"Provider", option.Provider, "openai"}, {"BaseURL", option.BaseURL, "https://env.example/v1"}, {"APIKey", option.APIKey, "env-key"}, {"Model", option.Model, "env-model"}, @@ -612,6 +612,103 @@ llm: }) } +func TestResolveRuntimeConfigNormalizesLegacyProviderToOpenAI(t *testing.T) { + t.Setenv("AISCAN_PROVIDER", "") + t.Setenv("AISCAN_LLM_PROVIDER", "") + t.Setenv("AISCAN_API_KEY", "") + t.Setenv("AISCAN_LLM_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "openai-compatible-key") + t.Setenv("DEEPSEEK_API_KEY", "ignored-vendor-key") + + withDefaults(t, func() { + dir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + t.Fatal(err) + } + if option.Provider != "openai" || option.APIKey != "openai-compatible-key" { + t.Fatalf("legacy provider was not normalized: %#v", option.LLMOptions) + } + }) +} + +func TestApplyEnvironmentIgnoresVendorSpecificLLMVariables(t *testing.T) { + values := map[string]string{ + "DEEPSEEK_API_KEY": "vendor-key", + "DEEPSEEK_BASE_URL": "https://vendor.example/v1", + "DEEPSEEK_MODEL": "vendor-model", + } + lookup := func(name string) (string, bool) { + value, ok := values[name] + return value, ok + } + + option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} + applyEnvironment(&option, option, lookup, testProviderInference) + normalizeProviderOptions(&option, testProviderInference) + if option.Provider != "openai" || option.APIKey != "" || option.BaseURL != "" || option.Model != "" { + t.Fatalf("vendor-specific LLM environment should be ignored: %#v", option.LLMOptions) + } +} + +func TestApplyEnvironmentCentralizesRuntimeAndReconValues(t *testing.T) { + values := map[string]string{ + "AISCAN_DATA_DIR": "env-data", + "AISCAN_RENDER": "static", + "AISCAN_REPL": "fast", + "PLAYWRIGHT_CLI_SESSION": "browser-1", + "SHODAN_API_KEY": "shodan-key", + } + lookup := func(name string) (string, bool) { + value, ok := values[name] + return value, ok + } + + option := Option{MiscOptions: MiscOptions{DataDir: "config-data"}} + applyEnvironment(&option, Option{}, lookup, testProviderInference) + if option.DataDir != "env-data" || option.RenderMode != "static" || option.REPLMode != "fast" || option.PlaywrightSession != "browser-1" { + t.Fatalf("runtime environment not resolved: %#v", option) + } + if option.ReconProviderKeys["SHODAN_API_KEY"] != "shodan-key" { + t.Fatalf("recon provider environment not resolved: %#v", option.ReconProviderKeys) + } + + cli := Option{MiscOptions: MiscOptions{DataDir: "cli-data"}} + applyEnvironment(&cli, cli, lookup, testProviderInference) + if cli.DataDir != "cli-data" { + t.Fatalf("CLI data dir should win over env: got %q", cli.DataDir) + } +} + +func TestResolveRuntimeConfigTavilyPriority(t *testing.T) { + dir := t.TempDir() + writeTestConfig(t, dir, "search:\n tavily_keys: config-key\n") + t.Setenv("TAVILY_API_KEY", "env-key") + + withDefaults(t, func() { + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + option := Option{ReconOptions: ReconOptions{TavilyKey: "cli-key"}} + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + t.Fatal(err) + } + if option.TavilyKey != "cli-key" || option.SearchConfig.TavilyKeys != "config-key" { + t.Fatalf("Tavily sources were not centralized: cli=%q config=%q", option.TavilyKey, option.SearchConfig.TavilyKeys) + } + }) +} + // A provider-scoped model env (ANTHROPIC_MODEL) is often injected by the // surrounding environment for another tool (a Claude-Code style gateway). It must // NOT override a model the user configured for aiscan itself — otherwise editing @@ -757,7 +854,7 @@ llm: if _, err := ResolveRuntimeConfig(&explicit, false, testProviderInference); err != nil { t.Fatal(err) } - if explicit.Provider != "deepseek" || explicit.Model != "cli-model" || explicit.APIKey != "cli-key" { + if explicit.Provider != "openai" || explicit.Model != "cli-model" || explicit.APIKey != "cli-key" { t.Fatalf("explicit CLI LLM values did not override staged config: %+v", explicit.LLMOptions) } } diff --git a/core/config/options.go b/core/config/options.go index 0cb93a71..7a109a89 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -21,6 +21,13 @@ type Option struct { MiscOptions `group:"Miscellaneous Options" config:"misc"` ScanConfig ScanConfigOptions `no-flag:"true" config:"scan"` SearchConfig SearchConfigOptions `no-flag:"true" config:"search"` + + // Runtime-only environment settings. Business packages receive these values + // after ResolveRuntimeConfig instead of reading the process environment. + RenderMode string `no-flag:"true"` + REPLMode string `no-flag:"true"` + PlaywrightSession string `no-flag:"true"` + ReconProviderKeys map[string]string `no-flag:"true"` } type ScanConfigOptions struct { @@ -32,7 +39,7 @@ type SearchConfigOptions struct { } type LLMOptions struct { - Provider string `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot, zhipu"` + Provider string `long:"provider" config:"provider" description:"LLM protocol: openai (OpenAI-compatible, default) or anthropic"` BaseURL string `long:"base-url" config:"base_url" description:"LLM API base URL (leave empty to use provider default)"` APIKey string `long:"api-key" config:"api_key" description:"LLM API key (or env: OPENAI_API_KEY, ANTHROPIC_API_KEY, AISCAN_API_KEY)"` Model string `long:"model" config:"model" description:"LLM model name"` diff --git a/docs/agent.md b/docs/agent.md index 577c7c32..98afde55 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -530,13 +530,15 @@ llm: model: gpt-4o api_key: "sk-..." - id: deepseek - provider: deepseek + provider: openai + base_url: "https://api.deepseek.com/v1" model: deepseek-chat api_key: "..." - id: ollama - provider: ollama + provider: openai model: llama3 base_url: "http://localhost:11434/v1" + api_key: "local" ``` `active_profile` 按 `id` 选择当前项;未设置时使用列表第一项。完整格式参见 [参考手册](reference.md)。 @@ -549,8 +551,8 @@ REPL 中使用 `/provider` 命令查看当前和其他可用配置: aiscan> /provider Provider profiles: 1. openai / gpt-4o # active - 2. deepseek / deepseek-chat # configured - 3. ollama / llama3 # configured + 2. openai / deepseek-chat # deepseek profile + 3. openai / llama3 # ollama profile ``` 切换通过 Web 设置页,或 REPL 的 `/provider set --provider ... --model ...` 显式完成。 diff --git a/docs/mechanisms.md b/docs/mechanisms.md index 24b0503f..879703cf 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -275,3 +275,5 @@ scan、agent joined、session cleared 等产品事件保留独立的 `DomainEven 对 `BaseURL`、`APIKey` 同理。 **文件**: `core/config/env.go` + +所有 AIScan 运行时业务环境变量都由该入口读取一次。DataDir、TUI、Playwright、Tavily 和 Uncover 只消费解析后的配置,不再自行调用 `os.Getenv`。系统级 `PATH`、Go 标准代理环境变量和 Vite 构建期变量仍按各自平台语义处理。 diff --git a/docs/reference.md b/docs/reference.md index 0e834342..9f076ea9 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -35,9 +35,11 @@ aiscan [全局参数] [子命令参数] ### 配置优先级 ``` -CLI 参数 > 环境变量 > 配置文件 > 编译时默认值 +CLI 参数 > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 编译时默认值 ``` +`AISCAN_*`、Cyberhub、FOFA、Hunter、Tavily 等明确属于 AIScan 的环境变量会覆盖配置文件。`OPENAI_*`、`ANTHROPIC_*` 等可能由其他工具注入的 Provider 兼容变量只用于填补配置文件中的空值。 + ### 配置文件 ```bash @@ -52,7 +54,7 @@ aiscan -c /path/to/aiscan.yaml scan -i 192.168.1.0/24 # 指定配置文件 ```yaml # LLM Provider llm: - provider: "" # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic, zhipu + provider: "" # 协议类型:openai(默认,兼容所有 OpenAI API)或 anthropic base_url: "" # API base URL(留空使用 provider 默认值) api_key: "" # API key(建议使用环境变量) model: "" # 模型名称 @@ -60,13 +62,13 @@ llm: max_tokens: 0 # 单次最大输出;0 使用默认值 16384 proxy: "" # 访问 LLM API 的 HTTP proxy - # 多 provider 配置(可选;只手动切换,不自动 fallback) + # 多 LLM profile 配置(可选;只手动切换,不自动 fallback) active_profile: deepseek providers: - id: deepseek name: DeepSeek - provider: deepseek - base_url: https://api.deepseek.com + provider: openai + base_url: https://api.deepseek.com/v1 api_key: "sk-..." model: deepseek-chat context_window: 128000 @@ -140,7 +142,7 @@ misc: | 参数 | 说明 | | --- | --- | -| `--provider` | LLM provider 名称(openai、deepseek、openrouter、ollama 等) | +| `--provider` | LLM 协议类型:`openai`(OpenAI-compatible)或 `anthropic` | | `--base-url` | LLM API base URL | | `--api-key` | LLM API key(也可用环境变量) | | `--model` | 模型名称(默认 `gpt-4o`) | @@ -198,24 +200,18 @@ misc: --- -## LLM Provider +## LLM 协议与 Profile -### 支持的 Provider +### 支持的协议 -| Provider | 默认 Base URL | 默认模型 | API Key 环境变量 | +| 协议 | 用途 | 默认 Base URL | 环境变量 | | --- | --- | --- | --- | -| `openai` | `https://api.openai.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `deepseek` | `https://api.deepseek.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `anthropic` | `https://api.anthropic.com/v1` | — | `AISCAN_API_KEY` / `ANTHROPIC_API_KEY` | -| `openrouter` | `https://openrouter.ai/api/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `groq` | `https://api.groq.com/openai/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `moonshot` | `https://api.moonshot.cn/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `ollama` | `http://localhost:11434/v1` | — | 不需要 | -| `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | +| `openai` | OpenAI 及 DeepSeek、OpenRouter、Groq、Moonshot、Ollama 等 OpenAI-compatible API | `https://api.openai.com/v1` | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL` | +| `anthropic` | Anthropic Messages API 及兼容网关 | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL` | -`glm` 和 `bigmodel` 是 `zhipu` 的别名。已知 Provider 在 `base_url` 留空时使用上表地址;显式填写的地址始终优先。只提供 `base_url` 而不提供 Provider 时,Anthropic 官方域名会选择 Anthropic 协议,其他地址默认按 OpenAI 兼容协议处理。 +除 Anthropic 协议外,其余模型服务统一使用 `openai`,通过 `base_url`、`model` 和 `api_key` 指定实际服务。旧配置中的 `deepseek`、`openrouter`、`ollama` 等 provider 名称会自动归一化为 `openai`。 -### 多 Provider 配置 +### 多 LLM Profile 配置 配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。`model` 必填,保存配置或激活 Profile 时都会拒绝空模型。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。 @@ -230,11 +226,11 @@ Agent 只会重试当前 provider。重试耗尽后直接返回错误,不会 export OPENAI_API_KEY="sk-..." aiscan agent -p "检查目标" -i http://target.example -# 指定 provider -aiscan agent --provider deepseek --api-key "sk-..." --model deepseek-chat +# DeepSeek(OpenAI-compatible) +aiscan agent --provider openai --base-url https://api.deepseek.com/v1 --api-key "sk-..." --model deepseek-chat -# Ollama 本地模型 -aiscan agent --provider ollama --model llama3 --base-url http://localhost:11434/v1 +# Ollama(OpenAI-compatible;部分部署可使用任意非空 API key) +aiscan agent --provider openai --model llama3 --base-url http://localhost:11434/v1 --api-key local # 任意 OpenAI 兼容 API aiscan agent --base-url https://my-proxy.example/v1 --api-key "$MY_KEY" --model my-model @@ -424,21 +420,34 @@ scan: | `OPENAI_API_KEY` | OpenAI API key | | `OPENAI_BASE_URL` / `OPENAI_BASEURL` | OpenAI/Codex 风格 API base URL | | `OPENAI_MODEL` | OpenAI/Codex 风格模型名 | -| `DEEPSEEK_API_KEY` | DeepSeek API key | | `ANTHROPIC_API_KEY` | Anthropic API key | | `ANTHROPIC_BASE_URL` / `ANTHROPIC_BASEURL` | Claude Code 风格 API base URL | | `ANTHROPIC_MODEL` | Claude Code 风格模型名 | -| `OPENROUTER_API_KEY` | OpenRouter API key | -| `GROQ_API_KEY` | Groq API key | -| `MOONSHOT_API_KEY` | Moonshot API key | | `AISCAN_API_KEY` | 统一 fallback API key(所有 provider 通用) | | `AISCAN_BASE_URL` / `AISCAN_LLM_BASE_URL` | 统一 LLM API base URL | | `AISCAN_MODEL` / `AISCAN_LLM_MODEL` | 统一模型名 | -| `AISCAN_PROVIDER` / `AISCAN_LLM_PROVIDER` | 统一 provider 名称 | +| `AISCAN_PROVIDER` / `AISCAN_LLM_PROVIDER` | 协议类型:`openai` 或 `anthropic` | | `AISCAN_LLM_PROXY` | LLM API 请求代理 | -| `TAVILY_API_KEY` | Tavily Web Search API key(agent `web_search` 工具) | +| `AISCAN_DATA_DIR` | 数据目录;优先级低于显式 `--data-dir` | +| `AISCAN_PROXY` / `AISCAN_SCANNER_PROXY` | 扫描工具代理 | +| `AISCAN_CYBERHUB_URL` / `CYBERHUB_URL` | Cyberhub URL | +| `AISCAN_CYBERHUB_KEY` / `CYBERHUB_KEY` | Cyberhub API key | +| `AISCAN_CYBERHUB_MODE` / `CYBERHUB_MODE` | Cyberhub 资源模式 | +| `TAVILY_API_KEY` / `TAVILY_API_KEYS` | Tavily Web Search API key,多个 key 可逗号分隔 | | `FOFA_EMAIL` / `FOFA_KEY` | FOFA 凭据 | -| `HUNTER_API_KEY` | Hunter API key | +| `HUNTER_API_KEY` / `HUNTER_TOKEN` | Hunter 凭据 | +| `RECON_PROXY` | 被动测绘出站代理 | +| `SHODAN_API_KEY`、`QUAKE_TOKEN`、`ZOOMEYE_API_KEY`、`NETLAS_API_KEY` | Uncover 数据源凭据 | +| `CENSYS_API_TOKEN` / `CENSYS_ORGANIZATION_ID` | Censys 凭据 | +| `CRIMINALIP_API_KEY`、`PUBLICWWW_API_KEY`、`HUNTERHOW_API_KEY` | Uncover 数据源凭据 | +| `BINARYEDGE_API_KEY`、`ONYPHE_API_KEY`、`GREYNOISE_API_KEY` | Uncover 数据源凭据 | +| `DRIFTNET_API_KEY`、`DAYDAYMAP_API_KEY`、`ODIN_API_KEY`、`NERDYDATA_API_KEY` | Uncover 数据源凭据 | +| `GOOGLE_API_KEY` / `GOOGLE_API_CX` | Google Search 凭据 | +| `AISCAN_RENDER` | 终端渲染模式:interactive、static、forwarded | +| `AISCAN_REPL` | REPL 输入模式:readline 或 fast | +| `PLAYWRIGHT_CLI_SESSION` | Playwright 默认 session | + +运行时业务环境变量只在 `core/config` 解析一次,再通过运行时配置下传。`PATH`、子进程环境继承以及 Go 标准库的 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` 属于操作系统级行为,不纳入业务配置优先级。前端开发服务器的 `AISCAN_BACKEND_URL` 是 Vite 构建期配置,也不进入 Go 运行时配置。 --- diff --git a/pkg/commands/factory.go b/pkg/commands/factory.go index 3ba9b0c9..6606ff74 100644 --- a/pkg/commands/factory.go +++ b/pkg/commands/factory.go @@ -35,14 +35,15 @@ type Deps struct { SkillStore SkillSource RunnerMode bool - Provider provider.Provider - ScannerProxy string - Logger telemetry.Logger - NodeName string - NodeMeta map[string]any - TavilyKeys string // comma-separated Tavily API keys (build-time fallback) - DataBus *eventbus.Bus[output.ToolDataEvent] - Hooks *hooks.Registry + Provider provider.Provider + ScannerProxy string + Logger telemetry.Logger + NodeName string + NodeMeta map[string]any + TavilyKeys string // comma-separated Tavily API keys + PlaywrightSession string + DataBus *eventbus.Bus[output.ToolDataEvent] + Hooks *hooks.Registry } // Provide stores a typed dependency, allocating the bag on first use so a diff --git a/pkg/runner/app.go b/pkg/runner/app.go index 95e0d8ef..11d2362b 100644 --- a/pkg/runner/app.go +++ b/pkg/runner/app.go @@ -244,13 +244,14 @@ func initCoreCommands(rc ApplicationConfig, llmProvider agent.Provider, skillSto cmdReg := commands.NewRegistry() workDir, _ := os.Getwd() deps := &commands.Deps{ - WorkDir: workDir, - BashTimeout: rc.Tools.BashTimeout, - SkillStore: skillStore, - Provider: llmProvider, - Logger: logger, - TavilyKeys: rc.Tools.TavilyKeys, - Hooks: hookRegistry, + WorkDir: workDir, + BashTimeout: rc.Tools.BashTimeout, + SkillStore: skillStore, + Provider: llmProvider, + Logger: logger, + TavilyKeys: rc.Tools.TavilyKeys, + PlaywrightSession: rc.Tools.PlaywrightSession, + Hooks: hookRegistry, } plan := capability.Select(capability.Options{ Groups: []string{"core", "arsenal", "search", "browser"}, diff --git a/pkg/runner/application_builder.go b/pkg/runner/application_builder.go index 48562905..945f4e95 100644 --- a/pkg/runner/application_builder.go +++ b/pkg/runner/application_builder.go @@ -25,24 +25,26 @@ func AppConfig(option *cfg.Option, features RuntimeFeatures, logger telemetry.Lo Optional: features.ProviderOptional, }, Scanner: ScannerConfig{ - CyberhubURL: option.CyberhubURL, - CyberhubKey: option.CyberhubKey, - CyberhubMode: option.CyberhubMode, - AIEnabled: features.AIEnabled, - VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), - Proxy: option.Proxy, - FofaEmail: option.FofaEmail, - FofaKey: option.FofaKey, - HunterToken: option.HunterToken, - HunterAPIKey: option.HunterAPIKey, - ReconProxy: option.ReconProxy, - ReconLimit: intOptionValue(option.ReconLimit), + CyberhubURL: option.CyberhubURL, + CyberhubKey: option.CyberhubKey, + CyberhubMode: option.CyberhubMode, + AIEnabled: features.AIEnabled, + VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), + Proxy: option.Proxy, + FofaEmail: option.FofaEmail, + FofaKey: option.FofaKey, + HunterToken: option.HunterToken, + HunterAPIKey: option.HunterAPIKey, + ReconProxy: option.ReconProxy, + ReconLimit: intOptionValue(option.ReconLimit), + ReconProviderKeys: cloneStringMap(option.ReconProviderKeys), }, Tools: ToolConfig{ - Enabled: features.ToolsEnabled, - BashTimeout: 300, - TavilyKeys: resolveTavilyKeys(option.TavilyKey, cfg.ResolveString(option.SearchConfig.TavilyKeys, cfg.DefaultTavilyKeys)), - OptionalTools: option.Tools, + Enabled: features.ToolsEnabled, + BashTimeout: 300, + TavilyKeys: resolveTavilyKeys(option.TavilyKey, option.SearchConfig.TavilyKeys, cfg.DefaultTavilyKeys), + PlaywrightSession: option.PlaywrightSession, + OptionalTools: option.Tools, }, Logger: logger, CLISkillPaths: skillPathsFromOptions(option), @@ -70,14 +72,24 @@ func intOptionValue(p *int) int { return 0 } -func resolveTavilyKeys(flagKey, configKeys string) string { - flagKey = strings.TrimSpace(flagKey) - configKeys = strings.TrimSpace(configKeys) - if flagKey != "" && configKeys != "" { - return flagKey + "," + configKeys +func resolveTavilyKeys(primary string, fallbacks ...string) string { + keys := make([]string, 0, len(fallbacks)+1) + for _, raw := range append([]string{primary}, fallbacks...) { + raw = strings.TrimSpace(raw) + if raw != "" { + keys = append(keys, raw) + } + } + return strings.Join(keys, ",") +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil } - if flagKey != "" { - return flagKey + dst := make(map[string]string, len(src)) + for key, value := range src { + dst[key] = value } - return configKeys + return dst } diff --git a/pkg/runner/application_config.go b/pkg/runner/application_config.go index 8aaade0c..20f499c0 100644 --- a/pkg/runner/application_config.go +++ b/pkg/runner/application_config.go @@ -24,25 +24,27 @@ type ApplicationProviderConfig struct { } type ScannerConfig struct { - CyberhubURL string - CyberhubKey string - CyberhubMode string - AIEnabled bool - VerifyMode string - Proxy string - FofaEmail string - FofaKey string - HunterToken string - HunterAPIKey string - ReconProxy string - ReconLimit int + CyberhubURL string + CyberhubKey string + CyberhubMode string + AIEnabled bool + VerifyMode string + Proxy string + FofaEmail string + FofaKey string + HunterToken string + HunterAPIKey string + ReconProxy string + ReconLimit int + ReconProviderKeys map[string]string } type ToolConfig struct { - Enabled bool - BashTimeout int - TavilyKeys string - OptionalTools []string // optional tool groups to enable (e.g. "search", "browser") + Enabled bool + BashTimeout int + TavilyKeys string + PlaywrightSession string + OptionalTools []string // optional tool groups to enable (e.g. "search", "browser") } type IOAConfig struct { diff --git a/pkg/runner/provider_config.go b/pkg/runner/provider_config.go index de1eff24..0576e435 100644 --- a/pkg/runner/provider_config.go +++ b/pkg/runner/provider_config.go @@ -1,13 +1,15 @@ package runner import ( + "strings" + "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" ) func defaultProviderConfig() agent.ProviderConfig { return agent.ProviderConfig{ - Provider: cfg.DefaultProvider, + Provider: agent.NormalizeProvider(cfg.DefaultProvider), BaseURL: cfg.DefaultBaseURL, APIKey: cfg.DefaultAPIKey, Model: cfg.DefaultModel, @@ -19,8 +21,14 @@ func hasSingleProviderFields(option *cfg.Option) bool { } func entryToProviderConfig(entry cfg.LLMProviderEntry) agent.ProviderConfig { + providerName := strings.TrimSpace(entry.Provider) + if providerName == "" { + providerName = agent.InferProviderFromBaseURL(entry.BaseURL) + } else { + providerName = agent.NormalizeProvider(providerName) + } cfg := agent.ProviderConfig{ - Provider: entry.Provider, + Provider: providerName, BaseURL: entry.BaseURL, APIKey: entry.APIKey, Model: entry.Model, @@ -66,12 +74,12 @@ func ProviderConfig(option *cfg.Option) agent.ProviderConfig { } cfg := defaultProviderConfig() if option.Provider != "" { - cfg.Provider = option.Provider + cfg.Provider = agent.NormalizeProvider(option.Provider) } if option.BaseURL != "" { cfg.BaseURL = option.BaseURL if option.Provider == "" { - cfg.Provider = "" + cfg.Provider = agent.InferProviderFromBaseURL(option.BaseURL) } } if option.APIKey != "" { diff --git a/pkg/runner/provider_config_test.go b/pkg/runner/provider_config_test.go index 9476676b..42f1e788 100644 --- a/pkg/runner/provider_config_test.go +++ b/pkg/runner/provider_config_test.go @@ -19,7 +19,7 @@ func TestProviderConfigSelectsActiveProfileAndFallbacks(t *testing.T) { t.Fatalf("primary profile = %+v", primary) } fallbacks := FallbackProviderConfigs(&option) - if len(fallbacks) != 1 || fallbacks[0].Provider != "deepseek" || fallbacks[0].APIKey != "dk-111" { + if len(fallbacks) != 1 || fallbacks[0].Provider != "openai" || fallbacks[0].APIKey != "dk-111" { t.Fatalf("fallback profiles = %+v", fallbacks) } } @@ -33,7 +33,7 @@ func TestProviderConfigExplicitFieldsWin(t *testing.T) { if primary.Provider != "anthropic" || primary.APIKey != "cli-key" || primary.Model != "cli-model" { t.Fatalf("explicit provider = %+v", primary) } - if fallbacks := FallbackProviderConfigs(&option); len(fallbacks) != 1 || fallbacks[0].Provider != "deepseek" { + if fallbacks := FallbackProviderConfigs(&option); len(fallbacks) != 1 || fallbacks[0].Provider != "openai" { t.Fatalf("fallback profiles = %+v", fallbacks) } } diff --git a/pkg/runner/remote_repl_test.go b/pkg/runner/remote_repl_test.go index 7a8ca656..fd0024cc 100644 --- a/pkg/runner/remote_repl_test.go +++ b/pkg/runner/remote_repl_test.go @@ -15,9 +15,7 @@ func TestRuntimeOwnsPersistentMainREPLWithoutProvider(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - t.Setenv("AISCAN_REPL", "fast") - - option := &cfg.Option{} + option := &cfg.Option{REPLMode: "fast"} rt, err := NewAgentRuntime(ctx, option, telemetry.NopLogger(), &RuntimeConfig{ ProviderOptional: true, NoOutput: true, @@ -137,8 +135,7 @@ func TestEphemeralLocalREPLDoesNotCreateBufferedPTYConsole(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - t.Setenv("AISCAN_REPL", "fast") - rt, err := NewAgentRuntime(ctx, &cfg.Option{}, telemetry.NopLogger(), &RuntimeConfig{ + rt, err := NewAgentRuntime(ctx, &cfg.Option{REPLMode: "fast"}, telemetry.NopLogger(), &RuntimeConfig{ ProviderOptional: true, NoOutput: true, REPLMode: REPLEphemeral, diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 20d15b00..9dc16ea9 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -514,7 +514,8 @@ func runInteractiveMode(ctx context.Context, option *cfg.Option, logger telemetr // --------------------------------------------------------------------------- func RunDirectScannerMode(ctx context.Context, option *cfg.Option, rest []string, logger telemetry.Logger) error { - features, scannerArgs, err := DirectScannerRuntimeFeatures(rest) + defaultVerify := cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify) + features, scannerArgs, err := DirectScannerRuntimeFeaturesWithDefault(rest, defaultVerify) if err != nil { return err } diff --git a/pkg/runner/scanner.go b/pkg/runner/scanner.go index 69862489..291b8dbe 100644 --- a/pkg/runner/scanner.go +++ b/pkg/runner/scanner.go @@ -8,13 +8,17 @@ import ( ) func DirectScannerRuntimeFeatures(rest []string) (RuntimeFeatures, []string, error) { + return DirectScannerRuntimeFeaturesWithDefault(rest, config.DefaultVerify) +} + +func DirectScannerRuntimeFeaturesWithDefault(rest []string, defaultVerify string) (RuntimeFeatures, []string, error) { if len(rest) == 0 { return RuntimeFeatures{}, nil, fmt.Errorf("missing scanner command") } if rest[0] != "scan" { return RuntimeFeatures{}, rest, nil } - verifyMode, explicit := scannerVerifyMode(rest[1:]) + verifyMode, explicit := scannerVerifyMode(rest[1:], defaultVerify) sniperEnabled := HasScannerFlag(rest[1:], "--sniper") deepEnabled := HasScannerFlag(rest[1:], "--deep") aiSkillRequested := sniperEnabled || deepEnabled @@ -104,7 +108,7 @@ func isDirectScannerJSONOutput(rest []string) bool { return false } -func scannerVerifyMode(args []string) (string, bool) { +func scannerVerifyMode(args []string, defaultVerify string) (string, bool) { for i := 0; i < len(args); i++ { arg := args[i] key, value, hasValue := strings.Cut(arg, "=") @@ -119,7 +123,7 @@ func scannerVerifyMode(args []string) (string, bool) { } return "", true } - return defaultVerifyMode(), false + return defaultVerifyMode(defaultVerify), false } func replaceOrAppendScannerFlag(args []string, flag, value string) []string { @@ -144,8 +148,8 @@ func replaceOrAppendScannerFlag(args []string, flag, value string) []string { return append(out, flag+"="+value) } -func defaultVerifyMode() string { - value := strings.ToLower(strings.TrimSpace(config.DefaultVerify)) +func defaultVerifyMode(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "off" } diff --git a/pkg/tui/console.go b/pkg/tui/console.go index 85c764bd..f304f35c 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -127,7 +127,7 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf stdout: stdout, stderr: stderr, } - if isTerminal && isLocalAgentTerminal(t) && resolveRenderMode() == ModeInteractive { + if isTerminal && isLocalAgentTerminal(t) && resolveRenderMode(renderModeValue(option)) == ModeInteractive { bridge := newReadlineConsoleBridge(c.Shell(), t.Out, func() bool { return repl.readlineActive.Load() }) @@ -503,7 +503,11 @@ func (r *AgentConsole) fastInputEnabled() bool { if r != nil && r.terminal != nil && r.terminal.Control != nil { isTerminal = r.terminal.Control.IsTerminal() } - return fastInputEnabledForMode(os.Getenv("AISCAN_REPL"), isTerminal) + mode := "" + if r != nil && r.option != nil { + mode = r.option.REPLMode + } + return fastInputEnabledForMode(mode, isTerminal) } func fastInputEnabledForMode(mode string, _ bool) bool { diff --git a/pkg/tui/output.go b/pkg/tui/output.go index f605d79a..133ff521 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -82,7 +82,7 @@ func NewAgentOutput(option *cfg.Option) *AgentOutput { return newAgentOutput(option, os.Stdout, os.Stderr, term.IsTerminal(int(os.Stdout.Fd())), term.IsTerminal(int(os.Stderr.Fd())), - resolveRenderMode()) + resolveRenderMode(renderModeValue(option))) } func NewStaticAgentOutput(option *cfg.Option) *AgentOutput { @@ -93,7 +93,14 @@ func NewStaticAgentOutput(option *cfg.Option) *AgentOutput { } func NewAgentOutputWithWriters(option *cfg.Option, stdout, stderr io.Writer, terminal bool) *AgentOutput { - return newAgentOutputWithWriters(option, stdout, stderr, terminal, resolveRenderMode()) + return newAgentOutputWithWriters(option, stdout, stderr, terminal, resolveRenderMode(renderModeValue(option))) +} + +func renderModeValue(option *cfg.Option) string { + if option == nil { + return "" + } + return option.RenderMode } func NewStaticAgentOutputWithWriters(option *cfg.Option, stdout, stderr io.Writer, terminal bool) *AgentOutput { diff --git a/pkg/tui/render.go b/pkg/tui/render.go index 46d19dcb..0921b88c 100644 --- a/pkg/tui/render.go +++ b/pkg/tui/render.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "io" - "os" "strings" "sync" "time" @@ -24,8 +23,8 @@ const ( ModeForwarded ) -func resolveRenderMode() RenderMode { - switch strings.ToLower(strings.TrimSpace(os.Getenv("AISCAN_RENDER"))) { +func resolveRenderMode(value string) RenderMode { + switch strings.ToLower(strings.TrimSpace(value)) { case "static", "plain", "noninteractive", "non-interactive", "off": return ModeStatic case "forwarded", "forward", "remote", "pipe": diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 95d14491..978992cb 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "time" + agentprovider "github.com/chainreactors/aiscan/agent/provider" "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/webproto" @@ -926,7 +927,7 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) { if len(msg.Payload) > 0 && json.Unmarshal(msg.Payload, &result) == nil { a.mu.Lock() if result.OK { - a.status.Provider = result.Provider + a.status.Provider = agentprovider.NormalizeProvider(result.Provider) a.status.Model = result.Model a.status.ConfigError = "" } else { diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go index 7a492acf..ecfeead9 100644 --- a/pkg/web/config_profiles_test.go +++ b/pkg/web/config_profiles_test.go @@ -25,10 +25,10 @@ func TestActivateLLMProfileSelectsByID(t *testing.T) { if store.cfg.LLM.ActiveProfile != "fast" || store.cfg.LLM.Providers[0].ID != "primary" { t.Fatalf("active profile not switched by id: %+v", store.cfg.LLM) } - if active := store.cfg.LLM.Active(); active.Model != "deepseek-fast" || active.APIKey != "key-2" { + if active := store.cfg.LLM.Active(); active.Provider != "openai" || active.Model != "deepseek-fast" || active.APIKey != "key-2" { t.Fatalf("Active() did not resolve the selected profile: %+v", active) } - if status.LLM.ActiveProfile != "fast" || status.LLM.Model != "deepseek-fast" { + if status.LLM.ActiveProfile != "fast" || status.LLM.Provider != "openai" || status.LLM.Model != "deepseek-fast" { t.Fatalf("status not synchronized: %+v", status.LLM) } } diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go index 5fd0b76a..9b62c509 100644 --- a/pkg/web/config_reload_test.go +++ b/pkg/web/config_reload_test.go @@ -106,7 +106,7 @@ func TestHandleConfigReloadResultUpdatesAgentStatus(t *testing.T) { }) pool.handleAgentMessage(a, WSMessage{Type: "config.result", Payload: payload}) got := a.info().Status - if got.Provider != "deepseek" || got.Model != "deepseek-v4-pro" || got.ConfigError != "" { + if got.Provider != "openai" || got.Model != "deepseek-v4-pro" || got.ConfigError != "" { t.Fatalf("unexpected config result status: %+v", got) } diff --git a/pkg/web/types.go b/pkg/web/types.go index 58618590..76756e0e 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -138,6 +138,7 @@ func ConfigStatusFromDistribute(d *webproto.DistributeConfig, path string, loade cs.LLM.ContextWindow = active.ContextWindow cs.LLM.ActiveProfile = d.LLM.ActiveProfile for _, profile := range d.LLM.Providers { + profile = webproto.NormalizeLLMProvider(profile) cs.LLM.Profiles = append(cs.LLM.Profiles, LLMProfileStatus{ ID: profile.ID, Name: profile.Name, Provider: profile.Provider, BaseURL: profile.BaseURL, APIKeyConfigured: profile.APIKey != "", diff --git a/pkg/webagent/remote.go b/pkg/webagent/remote.go index d6d28f88..9c2504b2 100644 --- a/pkg/webagent/remote.go +++ b/pkg/webagent/remote.go @@ -38,6 +38,7 @@ func fetchRemoteConfig(webURL string) (*cfg.Option, error) { if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { return nil, fmt.Errorf("decode remote config: %w", err) } + webproto.MigrateLLMConfig(&dc.LLM, webproto.LLMProviderConfig{}) return distributeToOption(&dc), nil } @@ -67,6 +68,9 @@ func distributeToOption(d *webproto.DistributeConfig) *cfg.Option { ScanConfig: cfg.ScanConfigOptions{ Verify: d.Scan.Verify, }, + SearchConfig: cfg.SearchConfigOptions{ + TavilyKeys: d.Search.TavilyKeys, + }, } opt.FofaEmail = d.Recon.FofaEmail opt.FofaKey = d.Recon.FofaKey diff --git a/pkg/webagent/remote_test.go b/pkg/webagent/remote_test.go index 2b0f38f6..c5cee377 100644 --- a/pkg/webagent/remote_test.go +++ b/pkg/webagent/remote_test.go @@ -37,7 +37,7 @@ func TestFetchRemoteConfigUsesBearerTokenFromURL(t *testing.T) { t.Fatalf("unexpected remote option: %+v", option.LLMOptions) } primary := option.Providers[0] - if primary.Provider != "deepseek" || primary.Model != "deepseek-chat" { + if primary.Provider != "openai" || primary.Model != "deepseek-chat" { t.Fatalf("unexpected primary profile: %+v", primary) } if primary.MaxTokens != 8192 || primary.ContextWindow != 128000 { diff --git a/pkg/webproto/config.go b/pkg/webproto/config.go index 08b91980..64522052 100644 --- a/pkg/webproto/config.go +++ b/pkg/webproto/config.go @@ -1,6 +1,11 @@ package webproto -import "fmt" +import ( + "fmt" + "strings" + + agentprovider "github.com/chainreactors/aiscan/agent/provider" +) // LLMProviderConfig is one named LLM profile. The profile selected by // ActiveProfile is the runtime primary provider; the remaining entries are @@ -32,10 +37,10 @@ func (c LLMConfig) Active() LLMProviderConfig { } for _, p := range c.Providers { if p.ID == c.ActiveProfile { - return p + return NormalizeLLMProvider(p) } } - return c.Providers[0] + return NormalizeLLMProvider(c.Providers[0]) } // MigrateLLMConfig normalizes a freshly loaded config exactly once: a legacy @@ -53,6 +58,7 @@ func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { llm.Providers = []LLMProviderConfig{flat} } for i := range llm.Providers { + llm.Providers[i] = NormalizeLLMProvider(llm.Providers[i]) if llm.Providers[i].ID == "" { llm.Providers[i].ID = fmt.Sprintf("profile-%d", i+1) } @@ -67,6 +73,17 @@ func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { llm.ActiveProfile = active.ID } +// NormalizeLLMProvider collapses vendor labels to the two supported wire +// protocols while preserving the profile's endpoint, model, and identity. +func NormalizeLLMProvider(profile LLMProviderConfig) LLMProviderConfig { + if strings.TrimSpace(profile.Provider) != "" { + profile.Provider = agentprovider.NormalizeProvider(profile.Provider) + } else { + profile.Provider = agentprovider.InferFromBaseURL(profile.BaseURL) + } + return profile +} + // DistributeConfig is the configuration payload sent from the web server // to agents. All secret fields are included so agents can use them. // Also used by the settings UI (with secrets masked at the handler level). diff --git a/pkg/webproto/config_test.go b/pkg/webproto/config_test.go new file mode 100644 index 00000000..ddb7b963 --- /dev/null +++ b/pkg/webproto/config_test.go @@ -0,0 +1,18 @@ +package webproto + +import "testing" + +func TestMigrateLLMConfigNormalizesProviderProtocol(t *testing.T) { + config := LLMConfig{Providers: []LLMProviderConfig{ + {ID: "deepseek", Provider: "deepseek", BaseURL: "https://api.deepseek.com/v1"}, + {ID: "claude", Provider: "anthropic"}, + }} + MigrateLLMConfig(&config, LLMProviderConfig{}) + + if config.Providers[0].Provider != "openai" { + t.Fatalf("OpenAI-compatible provider = %q", config.Providers[0].Provider) + } + if config.Providers[1].Provider != "anthropic" { + t.Fatalf("Anthropic provider = %q", config.Providers[1].Provider) + } +} diff --git a/tools/ioa/commands_test.go b/tools/ioa/commands_test.go index f01b33d6..6844dade 100644 --- a/tools/ioa/commands_test.go +++ b/tools/ioa/commands_test.go @@ -493,10 +493,10 @@ func TestDefaultSpaceSkipsJoin(t *testing.T) { func TestLLMIOAToolUsage(t *testing.T) { apiKey := os.Getenv("LIVE_TEST_API_KEY") if apiKey == "" { - apiKey = os.Getenv("DEEPSEEK_API_KEY") + apiKey = os.Getenv("OPENAI_API_KEY") } if apiKey == "" { - t.Skip("set LIVE_TEST_API_KEY or DEEPSEEK_API_KEY to run live LLM IOA test") + t.Skip("set LIVE_TEST_API_KEY or OPENAI_API_KEY to run live LLM IOA test") } baseURL := envOr("LIVE_TEST_BASE_URL", "https://api.deepseek.com") model := envOr("LIVE_TEST_MODEL", "deepseek-v4-pro") diff --git a/tools/playwright/browser.go b/tools/playwright/browser.go index 44fd2074..4365f4b7 100644 --- a/tools/playwright/browser.go +++ b/tools/playwright/browser.go @@ -46,8 +46,9 @@ type Command struct { proxyURL string // Browser mode: headed (GUI) vs headless, optional CDP endpoint. - headed bool - cdpURL string + headed bool + cdpURL string + defaultSession string } // New creates a playwright pseudo-command. @@ -55,6 +56,11 @@ func New(workDir string) *Command { return &Command{workDir: workDir} } +func (c *Command) WithDefaultSession(session string) *Command { + c.defaultSession = strings.TrimSpace(session) + return c +} + // SetProxy updates the proxy URL for new browser launches. func (c *Command) SetProxy(proxyURLStr string) { c.proxyMu.Lock() @@ -245,8 +251,9 @@ func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any return nil, fmt.Errorf("playwright: subcommand required\n\n%s", c.Usage()) } - // Extract global -s flag (playwright-cli alignment) and PLAYWRIGHT_CLI_SESSION env var. - globalSession := os.Getenv("PLAYWRIGHT_CLI_SESSION") + // Extract global -s flag. The environment-derived default is resolved once + // by core/config and injected when the command is constructed. + globalSession := c.defaultSession var cleanArgs []string for i := 0; i < len(args); i++ { if args[i] == "-s" && i+1 < len(args) { diff --git a/tools/playwright/browser_test.go b/tools/playwright/browser_test.go index 61a54fe8..7eb7da47 100644 --- a/tools/playwright/browser_test.go +++ b/tools/playwright/browser_test.go @@ -227,6 +227,13 @@ func TestNameAndUsage(t *testing.T) { } } +func TestWithDefaultSession(t *testing.T) { + command := New(".").WithDefaultSession(" session-1 ") + if command.defaultSession != "session-1" { + t.Fatalf("default session = %q", command.defaultSession) + } +} + func TestFormatTextOutput_Truncation(t *testing.T) { long := strings.Repeat("a", maxOutputLen+100) out := formatTextOutput("https://example.com", long) diff --git a/tools/playwright/register.go b/tools/playwright/register.go index a1d58326..72d6dbae 100644 --- a/tools/playwright/register.go +++ b/tools/playwright/register.go @@ -12,7 +12,7 @@ func init() { commands.RegisterFactory(commands.Factory{ Capability: "browser", Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - impl := New(deps.WorkDir) + impl := New(deps.WorkDir).WithDefaultSession(deps.PlaywrightSession) reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run, Close: impl.Close}, "browser") }, }) diff --git a/tools/scan/engine/set.go b/tools/scan/engine/set.go index 66587bcf..ea52784b 100644 --- a/tools/scan/engine/set.go +++ b/tools/scan/engine/set.go @@ -33,6 +33,7 @@ type ReconOptions struct { HunterAPIKey string // 华顺信安后台 API 管理生成的 api-key (推荐, 64 位 hex) Limit int IngressProxy string // 给 uncover 的全局出站代理 (http://, https://, socks5://, socks5h://) + ProviderKeys map[string]string } type Set struct { diff --git a/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go index 530f9c89..b050229a 100644 --- a/tools/scan/engine/set_uncover_recon.go +++ b/tools/scan/engine/set_uncover_recon.go @@ -43,5 +43,13 @@ func mergeReconOptions(base, next ReconOptions) ReconOptions { if next.Limit != 0 { base.Limit = next.Limit } + if len(next.ProviderKeys) > 0 { + if base.ProviderKeys == nil { + base.ProviderKeys = make(map[string]string, len(next.ProviderKeys)) + } + for key, value := range next.ProviderKeys { + base.ProviderKeys[key] = value + } + } return base } diff --git a/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go index f8b85b63..978d82ab 100644 --- a/tools/scan/engine/uncover.go +++ b/tools/scan/engine/uncover.go @@ -31,8 +31,8 @@ type UncoverEngine struct { avail []string } -// NewUncoverEngine builds an engine from ReconOptions credentials merged with -// environment-provided keys (for sources like shodan, censys, etc.). +// NewUncoverEngine builds an engine from fully resolved ReconOptions. It does +// not read process environment variables; core/config owns that boundary. func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine { if logger == nil { logger = telemetry.NopLogger() @@ -55,7 +55,7 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine p.Hunter = append(p.Hunter, opts.HunterToken) } - p.LoadProviderKeysFromEnv() + applyProviderKeys(p, opts.ProviderKeys) keys := p.GetKeys() // uncover's GetKeys only populates the FofaEmail/FofaKey pair when the stored @@ -87,6 +87,38 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine return e } +func applyProviderKeys(p *sources.Provider, values map[string]string) { + appendValue := func(dst *[]string, name string) { + if value := strings.TrimSpace(values[name]); value != "" { + *dst = append(*dst, value) + } + } + appendPair := func(dst *[]string, first, second string) { + left := strings.TrimSpace(values[first]) + right := strings.TrimSpace(values[second]) + if left != "" && right != "" { + *dst = append(*dst, left+":"+right) + } + } + + appendValue(&p.Shodan, "SHODAN_API_KEY") + appendValue(&p.Quake, "QUAKE_TOKEN") + appendValue(&p.Netlas, "NETLAS_API_KEY") + appendValue(&p.CriminalIP, "CRIMINALIP_API_KEY") + appendValue(&p.Publicwww, "PUBLICWWW_API_KEY") + appendValue(&p.HunterHow, "HUNTERHOW_API_KEY") + appendValue(&p.ZoomEye, "ZOOMEYE_API_KEY") + appendValue(&p.Driftnet, "DRIFTNET_API_KEY") + appendValue(&p.Daydaymap, "DAYDAYMAP_API_KEY") + appendValue(&p.Odin, "ODIN_API_KEY") + appendValue(&p.BinaryEdge, "BINARYEDGE_API_KEY") + appendValue(&p.Onyphe, "ONYPHE_API_KEY") + appendValue(&p.GreyNoise, "GREYNOISE_API_KEY") + appendValue(&p.NerdyData, "NERDYDATA_API_KEY") + appendPair(&p.Censys, "CENSYS_API_TOKEN", "CENSYS_ORGANIZATION_ID") + appendPair(&p.Google, "GOOGLE_API_KEY", "GOOGLE_API_CX") +} + func (e *UncoverEngine) detectSources() []string { type check struct { name string diff --git a/tools/scan/engine/uncover_test.go b/tools/scan/engine/uncover_test.go index e151fd93..0194a8d7 100644 --- a/tools/scan/engine/uncover_test.go +++ b/tools/scan/engine/uncover_test.go @@ -49,6 +49,33 @@ func TestNewUncoverEngineFofaLegacyEmailKey(t *testing.T) { } } +func TestNewUncoverEngineDoesNotRereadCredentialEnvironment(t *testing.T) { + t.Setenv("FOFA_EMAIL", "env@example.com") + t.Setenv("FOFA_KEY", "env-key") + t.Setenv("HUNTER_API_KEY", "env-hunter") + + eng := NewUncoverEngine(ReconOptions{ + FofaEmail: "cli@example.com", + FofaKey: "cli-key", + HunterAPIKey: "cli-hunter", + }, nil) + if eng.keys.FofaEmail != "cli@example.com" || eng.keys.FofaKey != "cli-key" { + t.Fatalf("FOFA environment bypassed resolved config: %#v", eng.keys) + } + if eng.keys.HunterToken != "cli-hunter" { + t.Fatalf("Hunter environment bypassed resolved config: %#v", eng.keys) + } +} + +func TestNewUncoverEngineUsesInjectedProviderKeys(t *testing.T) { + eng := NewUncoverEngine(ReconOptions{ProviderKeys: map[string]string{ + "SHODAN_API_KEY": "shodan-key", + }}, nil) + if eng.keys.Shodan != "shodan-key" || !sourceAvailable(eng, "shodan") { + t.Fatalf("injected provider key not applied: %#v", eng.keys) + } +} + func sourceAvailable(e *UncoverEngine, name string) bool { for _, s := range e.Sources() { if s == name { diff --git a/tools/search/tavily.go b/tools/search/tavily.go index 82c7b4b8..a339a6e0 100644 --- a/tools/search/tavily.go +++ b/tools/search/tavily.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/url" - "os" "regexp" "strconv" "strings" @@ -70,8 +69,6 @@ func NewTavilySearch(builtinKeys string) *TavilySearch { } } - addKeys(os.Getenv("TAVILY_API_KEY")) - addKeys(os.Getenv("TAVILY_API_KEYS")) addKeys(builtinKeys) if len(keys) > 0 { diff --git a/tools/search/tavily_test.go b/tools/search/tavily_test.go index 2a5d84ff..5d844944 100644 --- a/tools/search/tavily_test.go +++ b/tools/search/tavily_test.go @@ -18,6 +18,14 @@ func TestParseTavilyArgsBasicQuery(t *testing.T) { } } +func TestNewTavilySearchUsesInjectedKeysOnly(t *testing.T) { + t.Setenv("TAVILY_API_KEY", "ambient-key") + search := NewTavilySearch("resolved-key") + if search.apiKey != "resolved-key" || len(search.apiKeys) != 1 { + t.Fatalf("ambient environment bypassed resolved config: %#v", search.apiKeys) + } +} + func TestParseTavilyArgsWithNum(t *testing.T) { query, num, err := parseTavilyArgs([]string{"nginx", "--num", "8"}) if err != nil { diff --git a/web/frontend/e2e/start-server.mjs b/web/frontend/e2e/start-server.mjs index 2d4fcf57..ba975dde 100644 --- a/web/frontend/e2e/start-server.mjs +++ b/web/frontend/e2e/start-server.mjs @@ -67,7 +67,7 @@ await writeFile(configPath, `llm: providers: - id: e2e name: E2E DeepSeek - provider: deepseek + provider: openai base_url: http://${host}:${llmAddress.port}/v1 api_key: test-key model: deepseek-chat diff --git a/web/frontend/src/components/ConfigPanel.tsx b/web/frontend/src/components/ConfigPanel.tsx index 87d7c777..b3f00ea1 100644 --- a/web/frontend/src/components/ConfigPanel.tsx +++ b/web/frontend/src/components/ConfigPanel.tsx @@ -27,14 +27,8 @@ const TABS: { key: TabKey; label: string }[] = [ ] const LLM_PROVIDERS = [ - { value: 'deepseek', label: 'DeepSeek' }, - { value: 'openai', label: 'OpenAI' }, - { value: 'openrouter', label: 'OpenRouter' }, - { value: 'ollama', label: 'Ollama' }, - { value: 'groq', label: 'Groq' }, - { value: 'moonshot', label: 'Moonshot' }, + { value: 'openai', label: 'OpenAI-compatible' }, { value: 'anthropic', label: 'Anthropic' }, - { value: 'zhipu', label: 'Zhipu GLM' }, ] function emptyForm(): DistributeConfig { @@ -54,6 +48,7 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { const profiles: LLMProviderProfile[] = cs.llm.profiles?.length ? cs.llm.profiles.map(profile => ({ ...profile, + provider: normalizeProvider(profile.provider), api_key: '', context_window: positiveInteger(profile.context_window), max_tokens: positiveInteger(profile.max_tokens), @@ -61,7 +56,7 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { : [{ id: cs.llm.active_profile || 'default', name: cs.llm.model || cs.llm.provider || 'Default', - provider: cs.llm.provider, + provider: normalizeProvider(cs.llm.provider), base_url: cs.llm.base_url, api_key: '', model: cs.llm.model, @@ -87,6 +82,10 @@ function blankLLMProfile(id = `llm-${Date.now()}`): LLMProviderProfile { return { id, name: 'New LLM', provider: 'openai', base_url: '', api_key: '', model: '', proxy: '' } } +function normalizeProvider(provider: string): 'openai' | 'anthropic' { + return provider.trim().toLowerCase() === 'anthropic' ? 'anthropic' : 'openai' +} + function positiveInteger(value: number | undefined): number | undefined { return Number.isSafeInteger(value) && Number(value) > 0 ? value : undefined } @@ -478,7 +477,7 @@ function LLMTab({
updateProfile('api_key', e.target.value)} - placeholder={configuredProfile?.api_key_configured ? t('configuredKeep') : t('requiredUnlessOllama')} /> + placeholder={configuredProfile?.api_key_configured ? t('configuredKeep') : t('apiKeyRequired')} />
diff --git a/web/frontend/src/i18n/locales/en/config.ts b/web/frontend/src/i18n/locales/en/config.ts index b144b1a9..645490a5 100644 --- a/web/frontend/src/i18n/locales/en/config.ts +++ b/web/frontend/src/i18n/locales/en/config.ts @@ -59,7 +59,7 @@ export default { autoSaveSessions: 'Auto-save sessions', // placeholder hints configuredKeep: 'configured; leave blank to keep', - requiredUnlessOllama: 'required unless ollama', + apiKeyRequired: 'API key required', modelRequired: 'Model is required', modelRequiredProfile: 'Profile “{{name}}” requires a model', providerDefault: 'leave empty for provider default', diff --git a/web/frontend/src/i18n/locales/zh/config.ts b/web/frontend/src/i18n/locales/zh/config.ts index 5de3a2cd..229b725d 100644 --- a/web/frontend/src/i18n/locales/zh/config.ts +++ b/web/frontend/src/i18n/locales/zh/config.ts @@ -59,7 +59,7 @@ export default { autoSaveSessions: '自动保存会话', // placeholder hints configuredKeep: '已配置;留空则保持不变', - requiredUnlessOllama: '必填(ollama 除外)', + apiKeyRequired: '需要 API Key', modelRequired: '模型不能为空', modelRequiredProfile: '配置 “{{name}}” 的模型不能为空', providerDefault: '留空则使用 Provider 默认值', From d8119495db156662378a9f67620ecd7565b8c2ad Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 13:18:38 +0800 Subject: [PATCH 149/348] refactor(config): enforce strict provider protocols --- agent/provider/provider.go | 84 ++++++-------- agent/provider/provider_test.go | 36 ++---- agent/types.go | 1 + cmd/aiscan/setup.go | 2 +- core/config/config_gen.go | 4 +- core/config/env.go | 117 +++++++++++--------- core/config/loader_test.go | 107 ++++++++++-------- core/config/options.go | 8 +- docs/mechanisms.md | 6 +- docs/reference.md | 28 ++--- pkg/runner/application_builder.go | 26 ++--- pkg/runner/application_config.go | 26 ++--- pkg/runner/provider_config_test.go | 4 +- pkg/runner/runtime_config.go | 9 +- pkg/web/config_profiles_test.go | 2 +- pkg/web/config_reload_test.go | 2 +- pkg/web/validation.go | 5 + pkg/web/validation_test.go | 17 ++- pkg/webagent/remote_test.go | 2 +- pkg/webproto/config.go | 4 +- pkg/webproto/config_test.go | 10 +- test-skips.json | 2 +- tools/scan/engine/set.go | 2 +- tools/scan/engine/set_uncover_recon.go | 10 +- tools/scan/engine/uncover.go | 4 +- tools/scan/engine/uncover_test.go | 4 +- tools/search/cyberhub.go | 2 +- web/frontend/src/components/ConfigPanel.tsx | 61 +++++++--- web/frontend/src/i18n/locales/en/config.ts | 5 +- web/frontend/src/i18n/locales/zh/config.ts | 9 +- 30 files changed, 327 insertions(+), 272 deletions(-) diff --git a/agent/provider/provider.go b/agent/provider/provider.go index 96ddf9b4..ca422441 100644 --- a/agent/provider/provider.go +++ b/agent/provider/provider.go @@ -42,33 +42,27 @@ type ProviderConfig struct { ContextWindow int `yaml:"context_window,omitempty" config:"context_window"` } -type providerPreset struct { - Protocol string - BaseURL string - APIKeyRequired bool -} +const ( + ProviderOpenAI = "openai" + ProviderAnthropic = "anthropic" +) -var providerPresets = map[string]providerPreset{ - "openai": {Protocol: "openai", BaseURL: "https://api.openai.com/v1", APIKeyRequired: true}, - "anthropic": {Protocol: "anthropic", BaseURL: "https://api.anthropic.com/v1", APIKeyRequired: true}, - "deepseek": {Protocol: "openai", BaseURL: "https://api.deepseek.com/v1", APIKeyRequired: true}, - "openrouter": {Protocol: "openai", BaseURL: "https://openrouter.ai/api/v1", APIKeyRequired: true}, - "groq": {Protocol: "openai", BaseURL: "https://api.groq.com/openai/v1", APIKeyRequired: true}, - "moonshot": {Protocol: "openai", BaseURL: "https://api.moonshot.cn/v1", APIKeyRequired: true}, - "ollama": {Protocol: "openai", BaseURL: "http://localhost:11434/v1"}, - "zhipu": {Protocol: "openai", BaseURL: "https://open.bigmodel.cn/api/paas/v4", APIKeyRequired: true}, +var providerBaseURLs = map[string]string{ + ProviderOpenAI: "https://api.openai.com/v1", + ProviderAnthropic: "https://api.anthropic.com/v1", } -var providerAliases = map[string]string{ - "bigmodel": "zhipu", - "glm": "zhipu", +func NormalizeProvider(name string) string { + return strings.ToLower(strings.TrimSpace(name)) } -func NormalizeProvider(name string) string { - if strings.EqualFold(name, "anthropic") { - return "anthropic" +func IsSupportedProvider(name string) bool { + switch NormalizeProvider(name) { + case ProviderOpenAI, ProviderAnthropic: + return true + default: + return false } - return "openai" } func Resolve(cfg *ProviderConfig) (*ProviderConfig, error) { @@ -80,33 +74,19 @@ func Resolve(cfg *ProviderConfig) (*ProviderConfig, error) { return nil, fmt.Errorf("context_window must be zero or positive") } - providerName := strings.ToLower(strings.TrimSpace(resolved.Provider)) - if alias, ok := providerAliases[providerName]; ok { - providerName = alias - } - + providerName := NormalizeProvider(resolved.Provider) if providerName == "" { - if resolved.BaseURL != "" { - providerName = InferFromBaseURL(resolved.BaseURL) - } else { - providerName = "openai" - } + providerName = InferFromBaseURL(resolved.BaseURL) } - - preset, knownProvider := providerPresets[providerName] - if knownProvider { - if strings.TrimSpace(resolved.BaseURL) == "" { - resolved.BaseURL = preset.BaseURL - } - resolved.Provider = preset.Protocol - } else { - if strings.TrimSpace(resolved.BaseURL) == "" { - return nil, fmt.Errorf("unknown provider %q: set base_url for a custom OpenAI-compatible endpoint", providerName) - } - resolved.Provider = NormalizeProvider(providerName) + if !IsSupportedProvider(providerName) { + return nil, fmt.Errorf("unsupported provider %q: use openai or anthropic", providerName) + } + if strings.TrimSpace(resolved.BaseURL) == "" { + resolved.BaseURL = providerBaseURLs[providerName] } + resolved.Provider = providerName - if strings.TrimSpace(resolved.APIKey) == "" && (!knownProvider || preset.APIKeyRequired) { + if strings.TrimSpace(resolved.APIKey) == "" { return nil, fmt.Errorf("no API key: set --api-key, llm.api_key, or AISCAN_API_KEY") } @@ -131,9 +111,7 @@ func NewProvider(cfg *ProviderConfig) (Provider, error) { } // inferImageSupport guesses whether a provider+model combination accepts -// image content parts based on the provider type and model name heuristics. -// Defaults to true for known provider types (anthropic/openai) and falls -// back to model-name heuristics for unknown providers. +// image content parts based on the protocol and model name heuristics. func inferImageSupport(provider, model string) bool { p := strings.ToLower(strings.TrimSpace(provider)) m := strings.ToLower(strings.TrimSpace(model)) @@ -163,16 +141,20 @@ func inferImageSupport(provider, model string) bool { // silent failure. func InferFromBaseURL(baseURL string) string { if strings.Contains(strings.ToLower(baseURL), "anthropic.com") { - return "anthropic" + return ProviderAnthropic } - return "openai" + return ProviderOpenAI } func NewProviderFromResolved(cfg *ProviderConfig) (Provider, error) { - if strings.ToLower(cfg.Provider) == "anthropic" { + switch NormalizeProvider(cfg.Provider) { + case ProviderAnthropic: return NewAnthropicProvider(cfg) + case ProviderOpenAI: + return NewOpenAIProvider(cfg) + default: + return nil, fmt.Errorf("unsupported provider %q: use openai or anthropic", cfg.Provider) } - return NewOpenAIProvider(cfg) } // Model capability registry extracted from pi's models.generated.ts. diff --git a/agent/provider/provider_test.go b/agent/provider/provider_test.go index 9c0a271f..c168fc8c 100644 --- a/agent/provider/provider_test.go +++ b/agent/provider/provider_test.go @@ -22,14 +22,6 @@ func TestResolveProviderPresets(t *testing.T) { }{ {name: "openai", provider: "openai", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.openai.com/v1"}, {name: "anthropic", provider: "anthropic", apiKey: "key", wantProtocol: "anthropic", wantBaseURL: "https://api.anthropic.com/v1"}, - {name: "deepseek", provider: "deepseek", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.deepseek.com/v1"}, - {name: "openrouter", provider: "openrouter", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://openrouter.ai/api/v1"}, - {name: "groq", provider: "groq", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.groq.com/openai/v1"}, - {name: "moonshot", provider: "moonshot", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.moonshot.cn/v1"}, - {name: "ollama", provider: "ollama", wantProtocol: "openai", wantBaseURL: "http://localhost:11434/v1"}, - {name: "zhipu", provider: "zhipu", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, - {name: "glm alias", provider: "glm", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, - {name: "bigmodel alias", provider: "bigmodel", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, } for _, tt := range tests { @@ -45,30 +37,18 @@ func TestResolveProviderPresets(t *testing.T) { } } -func TestResolvePreservesExplicitDeepSeekBaseURL(t *testing.T) { - resolved, err := Resolve(&ProviderConfig{ - Provider: "deepseek", - BaseURL: "https://gateway.example/v1", - APIKey: "key", - }) - if err != nil { - t.Fatal(err) - } - if resolved.BaseURL != "https://gateway.example/v1" || resolved.Provider != "openai" { - t.Fatalf("Resolve() = %+v", resolved) - } -} - -func TestResolveUnknownProviderRequiresBaseURL(t *testing.T) { - _, err := Resolve(&ProviderConfig{Provider: "custom", APIKey: "key"}) - if err == nil || !strings.Contains(err.Error(), "base_url") { - t.Fatalf("Resolve() error = %v, want base_url guidance", err) +func TestResolveRejectsUnsupportedProvider(t *testing.T) { + for _, name := range []string{"deepseek", "openrouter", "ollama", "custom"} { + _, err := Resolve(&ProviderConfig{Provider: name, BaseURL: "https://gateway.example/v1", APIKey: "key"}) + if err == nil || !strings.Contains(err.Error(), "use openai or anthropic") { + t.Fatalf("Resolve(%q) error = %v", name, err) + } } } func TestResolveUsesBaseURL(t *testing.T) { cfg, err := Resolve(&ProviderConfig{ - Provider: "ollama", + Provider: "openai", BaseURL: "http://localhost:11434/v1", APIKey: "test-key", }) @@ -93,7 +73,7 @@ func TestResolveRejectsNegativeModelLimits(t *testing.T) { func TestResolvePreservesExplicitBaseURL(t *testing.T) { cfg, err := Resolve(&ProviderConfig{ - Provider: "ollama", + Provider: "openai", BaseURL: "http://base-url.example/v1", APIKey: "test-key", }) diff --git a/agent/types.go b/agent/types.go index 957c0164..a8f862f8 100644 --- a/agent/types.go +++ b/agent/types.go @@ -57,6 +57,7 @@ var ( ResolveProvider = provider.Resolve InferProviderFromBaseURL = provider.InferFromBaseURL NormalizeProvider = provider.NormalizeProvider + IsSupportedProvider = provider.IsSupportedProvider ErrCallTimeout = provider.ErrCallTimeout ErrStreamStalled = provider.ErrStreamStalled diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 97983f4b..b109508e 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -63,7 +63,7 @@ func initEngines(ctx context.Context, sc runner.ScannerConfig, logger telemetry. HunterAPIKey: sc.HunterAPIKey, IngressProxy: sc.ReconProxy, Limit: sc.ReconLimit, - ProviderKeys: sc.ReconProviderKeys, + Credentials: sc.UncoverCredentials, } engineSet.SetupUncover(recon, logger) return engineSet diff --git a/core/config/config_gen.go b/core/config/config_gen.go index e68a8382..b97e036a 100644 --- a/core/config/config_gen.go +++ b/core/config/config_gen.go @@ -9,13 +9,13 @@ import ( const configFileHeader = `# aiscan 配置文件 # # 运行时: aiscan 自动加载 ./aiscan.yaml 或 <二进制所在目录>/aiscan.yaml -# 优先级: CLI > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 默认值 +# 优先级: CLI > AIScan/集成环境变量 > 配置文件 > 协议环境变量 > 默认值 # 生成: aiscan --init # # 仅填写需要的字段,留空或删除的字段不会覆盖其他来源的值 # # LLM 配置支持两种格式: -# 格式一 — 单 provider 简写(兼容旧配置): +# 格式一 — 单 provider 简写: # llm: # provider: openai # base_url: https://api.deepseek.com/v1 diff --git a/core/config/env.go b/core/config/env.go index 04d756f7..1ef24bcb 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -1,23 +1,24 @@ package config import ( + "fmt" "os" "strings" ) type envLookup func(string) (string, bool) -// ResolveRuntimeConfig resolves parsed configuration with environment and -// defaults. Provider inference is supplied by the integration layer so config -// remains independent of concrete LLM implementations. -func ResolveRuntimeConfig(option *Option, applyProcessState bool, inferProvider func(string) string) (string, error) { +// ResolveRuntimeConfig resolves parsed configuration with environment and defaults. +func ResolveRuntimeConfig(option *Option, applyProcessState bool) (string, error) { explicit := *option configPath, err := LoadAndApplyConfig(option) if err != nil { return configPath, err } - applyEnvironment(option, explicit, os.LookupEnv, inferProvider) - normalizeProviderOptions(option, inferProvider) + applyEnvironment(option, explicit, os.LookupEnv) + if err := normalizeProviderOptions(option); err != nil { + return configPath, err + } ApplyDefaults(option) if _, err := ResolveOutputPolicy(option); err != nil { return configPath, err @@ -28,20 +29,20 @@ func ResolveRuntimeConfig(option *Option, applyProcessState bool, inferProvider return configPath, nil } -func applyEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { - applyLLMEnvironment(option, explicit, lookup, inferProvider) +func applyEnvironment(option *Option, explicit Option, lookup envLookup) { + applyLLMEnvironment(option, explicit, lookup) applyScannerEnvironment(option, explicit, lookup) applyReconEnvironment(option, explicit, lookup) applyRuntimeEnvironment(option, explicit, lookup) } -func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { +func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup) { providerExplicit := strings.TrimSpace(explicit.Provider) != "" - if v := firstEnv(lookup, "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER"); v != "" && !providerExplicit { + if v := firstEnv(lookup, "AISCAN_PROVIDER"); v != "" && !providerExplicit { option.Provider = v } - selectedProvider := selectedEnvProvider(option, lookup, inferProvider) + selectedProvider := selectedEnvProvider(option, lookup) if option.Provider == "" && selectedProvider != "" && !providerExplicit { option.Provider = selectedProvider } @@ -49,7 +50,7 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe // AISCAN_BASE_URL is aiscan's own namespace: an intentional override that wins // over a base URL set in the config file (CLI --base-url wins via the explicit gate). if strings.TrimSpace(explicit.BaseURL) == "" { - if v := firstEnv(lookup, "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL"); v != "" { + if v := firstEnv(lookup, "AISCAN_BASE_URL"); v != "" { option.BaseURL = v } } @@ -66,11 +67,11 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe } } - // AISCAN_MODEL / AISCAN_LLM_MODEL are aiscan's *own* namespace: an intentional + // AISCAN_MODEL is aiscan's own namespace: an intentional // override that still wins over a model set in the config file (CLI --model // wins over it via the explicit gate). if strings.TrimSpace(explicit.Model) == "" { - if v := firstEnv(lookup, "AISCAN_MODEL", "AISCAN_LLM_MODEL"); v != "" { + if v := firstEnv(lookup, "AISCAN_MODEL"); v != "" { option.Model = v } } @@ -88,7 +89,7 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe // AISCAN_API_KEY is aiscan's own namespace: an intentional override that wins // over a key set in the config file (CLI --api-key wins via the explicit gate). if strings.TrimSpace(explicit.APIKey) == "" { - if v := firstEnv(lookup, "AISCAN_API_KEY", "AISCAN_LLM_API_KEY"); v != "" { + if v := firstEnv(lookup, "AISCAN_API_KEY"); v != "" { option.APIKey = v } } @@ -111,22 +112,22 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe func applyScannerEnvironment(option *Option, explicit Option, lookup envLookup) { if strings.TrimSpace(explicit.CyberhubURL) == "" { - if v := firstEnv(lookup, "AISCAN_CYBERHUB_URL", "CYBERHUB_URL"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_URL"); v != "" { option.CyberhubURL = v } } if strings.TrimSpace(explicit.CyberhubKey) == "" { - if v := firstEnv(lookup, "AISCAN_CYBERHUB_KEY", "CYBERHUB_KEY"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_KEY"); v != "" { option.CyberhubKey = v } } if strings.TrimSpace(explicit.CyberhubMode) == "" { - if v := firstEnv(lookup, "AISCAN_CYBERHUB_MODE", "CYBERHUB_MODE"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_MODE"); v != "" { option.CyberhubMode = v } } if strings.TrimSpace(explicit.Proxy) == "" { - if v := firstEnv(lookup, "AISCAN_PROXY", "AISCAN_SCANNER_PROXY"); v != "" { + if v := firstEnv(lookup, "AISCAN_PROXY"); v != "" { option.Proxy = v } } @@ -154,7 +155,7 @@ func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) { } } if strings.TrimSpace(explicit.TavilyKey) == "" { - if v := firstEnv(lookup, "TAVILY_API_KEY", "TAVILY_API_KEYS"); v != "" { + if v := firstEnv(lookup, "TAVILY_API_KEY"); v != "" { option.TavilyKey = v } } @@ -163,7 +164,7 @@ func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) { option.ReconProxy = v } } - applyReconProviderEnvironment(option, lookup) + applyUncoverEnvironment(option, lookup) } func applyRuntimeEnvironment(option *Option, explicit Option, lookup envLookup) { @@ -183,7 +184,7 @@ func applyRuntimeEnvironment(option *Option, explicit Option, lookup envLookup) } } -var reconProviderEnvNames = []string{ +var uncoverCredentialEnvNames = []string{ "SHODAN_API_KEY", "QUAKE_TOKEN", "NETLAS_API_KEY", @@ -204,23 +205,23 @@ var reconProviderEnvNames = []string{ "NERDYDATA_API_KEY", } -func applyReconProviderEnvironment(option *Option, lookup envLookup) { - for _, name := range reconProviderEnvNames { +func applyUncoverEnvironment(option *Option, lookup envLookup) { + for _, name := range uncoverCredentialEnvNames { if value := firstEnv(lookup, name); value != "" { - if option.ReconProviderKeys == nil { - option.ReconProviderKeys = make(map[string]string) + if option.UncoverCredentials == nil { + option.UncoverCredentials = make(map[string]string) } - option.ReconProviderKeys[name] = value + option.UncoverCredentials[name] = value } } } -func selectedEnvProvider(option *Option, lookup envLookup, inferProvider func(string) string) string { +func selectedEnvProvider(option *Option, lookup envLookup) string { if v := strings.ToLower(strings.TrimSpace(option.Provider)); v != "" { return normalizeProviderName(v) } - if option.BaseURL != "" && inferProvider != nil { - return inferProvider(option.BaseURL) + if option.BaseURL != "" { + return inferProviderName(option.BaseURL) } for _, providerName := range []string{"anthropic", "openai"} { if providerAPIKeyEnv(providerName, lookup) != "" { @@ -235,12 +236,7 @@ func providerBaseURLEnv(providerName string, lookup envLookup) string { if providerName == "" { return "" } - if providerName == "openai" { - if v := firstEnv(lookup, "OPENAI_BASE_URL", "OPENAI_BASEURL", "OPENAI_API_BASE_URL", "OPENAI_API_BASE"); v != "" { - return v - } - } - return firstEnv(lookup, providerEnvName(providerName, "BASE_URL"), providerEnvName(providerName, "BASEURL")) + return firstEnv(lookup, providerEnvName(providerName, "BASE_URL")) } func providerModelEnv(providerName string, lookup envLookup) string { @@ -260,38 +256,59 @@ func providerAPIKeyEnv(providerName string, lookup envLookup) string { } func canonicalEnvProvider(providerName string) string { - if strings.TrimSpace(providerName) == "" { + providerName = normalizeProviderName(providerName) + if !isSupportedProviderName(providerName) { return "" } - return normalizeProviderName(providerName) + return providerName } -func normalizeProviderOptions(option *Option, inferProvider func(string) string) { - if strings.TrimSpace(option.Provider) != "" { - option.Provider = normalizeProviderName(option.Provider) - } else if strings.TrimSpace(option.BaseURL) != "" && inferProvider != nil { - option.Provider = normalizeProviderName(inferProvider(option.BaseURL)) +func normalizeProviderOptions(option *Option) error { + if strings.TrimSpace(option.Provider) != "" || strings.TrimSpace(option.BaseURL) != "" { + providerName, err := resolveProviderName(option.Provider, option.BaseURL) + if err != nil { + return err + } + option.Provider = providerName } for i := range option.Providers { - providerName := strings.TrimSpace(option.Providers[i].Provider) - if providerName != "" { - option.Providers[i].Provider = normalizeProviderName(providerName) - } else if inferProvider != nil { - option.Providers[i].Provider = normalizeProviderName(inferProvider(option.Providers[i].BaseURL)) + providerName, err := resolveProviderName(option.Providers[i].Provider, option.Providers[i].BaseURL) + if err != nil { + return fmt.Errorf("LLM profile %q: %w", option.Providers[i].ID, err) } + option.Providers[i].Provider = providerName } + return nil } func normalizeProviderName(name string) string { - if strings.EqualFold(strings.TrimSpace(name), "anthropic") { + return strings.ToLower(strings.TrimSpace(name)) +} + +func isSupportedProviderName(name string) bool { + return name == "openai" || name == "anthropic" +} + +func inferProviderName(baseURL string) string { + if strings.Contains(strings.ToLower(baseURL), "anthropic.com") { return "anthropic" } return "openai" } +func resolveProviderName(name, baseURL string) (string, error) { + name = normalizeProviderName(name) + if name == "" { + name = inferProviderName(baseURL) + } + if !isSupportedProviderName(name) { + return "", fmt.Errorf("unsupported provider %q: use openai or anthropic", name) + } + return name, nil +} + func providerEnvName(providerName, suffix string) string { providerName = strings.ToUpper(strings.TrimSpace(providerName)) - providerName = strings.ReplaceAll(providerName, "-", "_") return providerName + "_" + suffix } diff --git a/core/config/loader_test.go b/core/config/loader_test.go index f4a996b6..90fa4c81 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -75,7 +75,7 @@ func TestLoadConfig(t *testing.T) { dir := t.TempDir() writeTestConfig(t, dir, ` llm: - provider: deepseek + provider: openai model: deepseek-chat base_url: https://api.deepseek.com/v1 max_tokens: 32768 @@ -98,7 +98,7 @@ ioa: } checks := []struct{ field, got, want string }{ - {"Provider", opt.Provider, "deepseek"}, + {"Provider", opt.Provider, "openai"}, {"Model", opt.Model, "deepseek-chat"}, {"BaseURL", opt.BaseURL, "https://api.deepseek.com/v1"}, {"CyberhubURL", opt.CyberhubURL, "http://hub:9000"}, @@ -494,7 +494,7 @@ func TestResolveRuntimeConfigEnvOverridesConfig(t *testing.T) { dir := t.TempDir() writeTestConfig(t, dir, ` llm: - provider: deepseek + provider: openai base_url: https://config.example/v1 api_key: config-key model: config-model @@ -506,7 +506,7 @@ cyberhub: t.Setenv("AISCAN_BASE_URL", "https://env.example/v1") t.Setenv("AISCAN_API_KEY", "env-key") t.Setenv("AISCAN_LLM_PROXY", "http://env-proxy:7890") - t.Setenv("CYBERHUB_URL", "http://env-hub:9000") + t.Setenv("AISCAN_CYBERHUB_URL", "http://env-hub:9000") withDefaults(t, func() { origDir, _ := os.Getwd() @@ -514,7 +514,7 @@ cyberhub: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } @@ -553,7 +553,7 @@ llm: option.Model = "cli-model" option.BaseURL = "https://cli.example/v1" option.APIKey = "cli-key" - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Model != "cli-model" || option.BaseURL != "https://cli.example/v1" || option.APIKey != "cli-key" { @@ -562,7 +562,7 @@ llm: }) } -func TestResolveRuntimeConfigSupportsOpenAIEnvAliases(t *testing.T) { +func TestResolveRuntimeConfigUsesOpenAIEnvironment(t *testing.T) { t.Setenv("OPENAI_BASE_URL", "https://openai-proxy.example/v1") t.Setenv("OPENAI_MODEL", "gpt-env") t.Setenv("OPENAI_API_KEY", "openai-key") @@ -578,16 +578,16 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Provider != "openai" || option.BaseURL != "https://openai-proxy.example/v1" || option.Model != "gpt-env" || option.APIKey != "openai-key" { - t.Fatalf("OpenAI env aliases not applied: %#v", option.LLMOptions) + t.Fatalf("OpenAI environment not applied: %#v", option.LLMOptions) } }) } -func TestResolveRuntimeConfigSupportsAnthropicEnvAliases(t *testing.T) { +func TestResolveRuntimeConfigUsesAnthropicEnvironment(t *testing.T) { t.Setenv("ANTHROPIC_BASE_URL", "https://anthropic-proxy.example/v1") t.Setenv("ANTHROPIC_MODEL", "claude-env") t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") @@ -603,23 +603,20 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Provider != "anthropic" || option.BaseURL != "https://anthropic-proxy.example/v1" || option.Model != "claude-env" || option.APIKey != "anthropic-key" { - t.Fatalf("Anthropic env aliases not applied: %#v", option.LLMOptions) + t.Fatalf("Anthropic environment not applied: %#v", option.LLMOptions) } }) } -func TestResolveRuntimeConfigNormalizesLegacyProviderToOpenAI(t *testing.T) { +func TestResolveRuntimeConfigRejectsUnsupportedProvider(t *testing.T) { t.Setenv("AISCAN_PROVIDER", "") - t.Setenv("AISCAN_LLM_PROVIDER", "") t.Setenv("AISCAN_API_KEY", "") - t.Setenv("AISCAN_LLM_API_KEY", "") t.Setenv("ANTHROPIC_API_KEY", "") t.Setenv("OPENAI_API_KEY", "openai-compatible-key") - t.Setenv("DEEPSEEK_API_KEY", "ignored-vendor-key") withDefaults(t, func() { dir := t.TempDir() @@ -630,11 +627,8 @@ func TestResolveRuntimeConfigNormalizesLegacyProviderToOpenAI(t *testing.T) { defer os.Chdir(origDir) option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { - t.Fatal(err) - } - if option.Provider != "openai" || option.APIKey != "openai-compatible-key" { - t.Fatalf("legacy provider was not normalized: %#v", option.LLMOptions) + if _, err := ResolveRuntimeConfig(&option, true); err == nil || !strings.Contains(err.Error(), "use openai or anthropic") { + t.Fatalf("ResolveRuntimeConfig() error = %v", err) } }) } @@ -650,15 +644,42 @@ func TestApplyEnvironmentIgnoresVendorSpecificLLMVariables(t *testing.T) { return value, ok } - option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} - applyEnvironment(&option, option, lookup, testProviderInference) - normalizeProviderOptions(&option, testProviderInference) + option := Option{LLMOptions: LLMOptions{Provider: "openai"}} + applyEnvironment(&option, option, lookup) + if err := normalizeProviderOptions(&option); err != nil { + t.Fatal(err) + } if option.Provider != "openai" || option.APIKey != "" || option.BaseURL != "" || option.Model != "" { t.Fatalf("vendor-specific LLM environment should be ignored: %#v", option.LLMOptions) } } -func TestApplyEnvironmentCentralizesRuntimeAndReconValues(t *testing.T) { +func TestApplyEnvironmentIgnoresLegacyAliases(t *testing.T) { + values := map[string]string{ + "AISCAN_LLM_PROVIDER": "anthropic", + "AISCAN_LLM_BASE_URL": "https://legacy.example/v1", + "AISCAN_LLM_MODEL": "legacy-model", + "AISCAN_LLM_API_KEY": "legacy-key", + "OPENAI_BASEURL": "https://legacy-openai.example/v1", + "CYBERHUB_URL": "https://legacy-cyberhub.example", + "TAVILY_API_KEYS": "legacy-tavily-key", + } + lookup := func(name string) (string, bool) { + value, ok := values[name] + return value, ok + } + + option := Option{} + applyEnvironment(&option, Option{}, lookup) + if option.Provider != "" || option.BaseURL != "" || option.Model != "" || option.APIKey != "" { + t.Fatalf("legacy LLM aliases were applied: %#v", option.LLMOptions) + } + if option.CyberhubURL != "" || option.TavilyKey != "" { + t.Fatalf("legacy integration aliases were applied: cyberhub=%q tavily=%q", option.CyberhubURL, option.TavilyKey) + } +} + +func TestApplyEnvironmentCentralizesRuntimeAndUncoverValues(t *testing.T) { values := map[string]string{ "AISCAN_DATA_DIR": "env-data", "AISCAN_RENDER": "static", @@ -672,16 +693,16 @@ func TestApplyEnvironmentCentralizesRuntimeAndReconValues(t *testing.T) { } option := Option{MiscOptions: MiscOptions{DataDir: "config-data"}} - applyEnvironment(&option, Option{}, lookup, testProviderInference) + applyEnvironment(&option, Option{}, lookup) if option.DataDir != "env-data" || option.RenderMode != "static" || option.REPLMode != "fast" || option.PlaywrightSession != "browser-1" { t.Fatalf("runtime environment not resolved: %#v", option) } - if option.ReconProviderKeys["SHODAN_API_KEY"] != "shodan-key" { - t.Fatalf("recon provider environment not resolved: %#v", option.ReconProviderKeys) + if option.UncoverCredentials["SHODAN_API_KEY"] != "shodan-key" { + t.Fatalf("uncover credentials not resolved: %#v", option.UncoverCredentials) } cli := Option{MiscOptions: MiscOptions{DataDir: "cli-data"}} - applyEnvironment(&cli, cli, lookup, testProviderInference) + applyEnvironment(&cli, cli, lookup) if cli.DataDir != "cli-data" { t.Fatalf("CLI data dir should win over env: got %q", cli.DataDir) } @@ -700,7 +721,7 @@ func TestResolveRuntimeConfigTavilyPriority(t *testing.T) { defer os.Chdir(origDir) option := Option{ReconOptions: ReconOptions{TavilyKey: "cli-key"}} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.TavilyKey != "cli-key" || option.SearchConfig.TavilyKeys != "config-key" { @@ -732,7 +753,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Model != "kimi-for-coding" { @@ -749,7 +770,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Model != "claude-opus-4-8" { @@ -781,7 +802,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.BaseURL != "https://kiro.example/v1" { @@ -802,7 +823,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.BaseURL != "https://borrowed.example/v1" { @@ -816,9 +837,8 @@ llm: func TestResolveRuntimeConfigCandidateUsesStagedProfileAndExplicitCLIOverrides(t *testing.T) { for _, key := range []string{ - "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER", "AISCAN_MODEL", "AISCAN_LLM_MODEL", - "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL", - "AISCAN_API_KEY", "AISCAN_LLM_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY", + "AISCAN_PROVIDER", "AISCAN_MODEL", "AISCAN_BASE_URL", "AISCAN_API_KEY", + "OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY", } { t.Setenv(key, "") } @@ -840,7 +860,7 @@ llm: path := filepath.Join(dir, "aiscan.yaml") staged := Option{MiscOptions: MiscOptions{ConfigFile: path}} - if _, err := ResolveRuntimeConfig(&staged, false, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&staged, false); err != nil { t.Fatal(err) } if staged.ActiveProfile != "staged" || len(staged.Providers) != 2 || staged.Providers[1].Model != "staged-model" { @@ -849,9 +869,9 @@ llm: explicit := Option{ MiscOptions: MiscOptions{ConfigFile: path}, - LLMOptions: LLMOptions{Provider: "deepseek", Model: "cli-model", APIKey: "cli-key"}, + LLMOptions: LLMOptions{Provider: "openai", Model: "cli-model", APIKey: "cli-key"}, } - if _, err := ResolveRuntimeConfig(&explicit, false, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&explicit, false); err != nil { t.Fatal(err) } if explicit.Provider != "openai" || explicit.Model != "cli-model" || explicit.APIKey != "cli-key" { @@ -859,13 +879,6 @@ llm: } } -func testProviderInference(baseURL string) string { - if strings.Contains(strings.ToLower(baseURL), "anthropic") { - return "anthropic" - } - return "openai" -} - func withDefaults(t *testing.T, fn func()) { t.Helper() saved := []*string{ diff --git a/core/config/options.go b/core/config/options.go index 7a109a89..7611bc9f 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -24,10 +24,10 @@ type Option struct { // Runtime-only environment settings. Business packages receive these values // after ResolveRuntimeConfig instead of reading the process environment. - RenderMode string `no-flag:"true"` - REPLMode string `no-flag:"true"` - PlaywrightSession string `no-flag:"true"` - ReconProviderKeys map[string]string `no-flag:"true"` + RenderMode string `no-flag:"true"` + REPLMode string `no-flag:"true"` + PlaywrightSession string `no-flag:"true"` + UncoverCredentials map[string]string `no-flag:"true"` } type ScanConfigOptions struct { diff --git a/docs/mechanisms.md b/docs/mechanisms.md index 879703cf..06081c29 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -135,9 +135,9 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事 两个协议 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。 -### Provider presets +### Provider 协议 -品牌 preset 在协议归一化前解析,为 OpenAI、Anthropic、DeepSeek、OpenRouter、Groq、Moonshot、Ollama 和 Zhipu GLM 提供默认 Base URL。`glm`、`bigmodel` 映射到 `zhipu`;显式 Base URL 不会被覆盖。Ollama preset 不要求 API Key。 +运行时只接受 `openai` 和 `anthropic`。两者分别提供官方默认 Base URL;其他模型服务必须显式使用 `openai` 协议并填写 `base_url`。不识别品牌名称,也不做别名映射。 ### hint404 协议提示 @@ -145,7 +145,7 @@ chat endpoint 返回 404 时包裹 actionable 建议(如"设置 `llm.provider= ### InferFromBaseURL -这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。品牌默认地址由 preset 解析,不依赖域名猜测。 +这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。 **文件**: `agent/provider/anthropic.go`, `agent/provider/openai.go`, `agent/provider/http.go`, `agent/provider/provider.go` diff --git a/docs/reference.md b/docs/reference.md index 9f076ea9..b278a5a6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -35,10 +35,10 @@ aiscan [全局参数] [子命令参数] ### 配置优先级 ``` -CLI 参数 > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 编译时默认值 +CLI 参数 > AIScan/集成环境变量 > 配置文件 > 协议环境变量 > 编译时默认值 ``` -`AISCAN_*`、Cyberhub、FOFA、Hunter、Tavily 等明确属于 AIScan 的环境变量会覆盖配置文件。`OPENAI_*`、`ANTHROPIC_*` 等可能由其他工具注入的 Provider 兼容变量只用于填补配置文件中的空值。 +`AISCAN_*`、FOFA、Hunter、Tavily 等明确属于 AIScan 的环境变量会覆盖配置文件。`OPENAI_*`、`ANTHROPIC_*` 只用于填补配置文件中的空值。 ### 配置文件 @@ -209,7 +209,7 @@ misc: | `openai` | OpenAI 及 DeepSeek、OpenRouter、Groq、Moonshot、Ollama 等 OpenAI-compatible API | `https://api.openai.com/v1` | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL` | | `anthropic` | Anthropic Messages API 及兼容网关 | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL` | -除 Anthropic 协议外,其余模型服务统一使用 `openai`,通过 `base_url`、`model` 和 `api_key` 指定实际服务。旧配置中的 `deepseek`、`openrouter`、`ollama` 等 provider 名称会自动归一化为 `openai`。 +除 Anthropic 协议外,其余模型服务统一使用 `openai`,通过 `base_url`、`model` 和 `api_key` 指定实际服务。其他 provider 名称会直接报错。 ### 多 LLM Profile 配置 @@ -376,7 +376,7 @@ aiscan passive -s hunter 'domain.suffix="example.com"' Cyberhub 提供外部指纹库和 POC 模板,可以扩充或替换内置资源。 ```bash -aiscan scan -i http://target.example --cyberhub-url http://127.0.0.1:9000 --cyberhub-key "$CYBERHUB_KEY" +aiscan scan -i http://target.example --cyberhub-url http://127.0.0.1:9000 --cyberhub-key "$AISCAN_CYBERHUB_KEY" ``` 资源模式:`merge`(默认,合并内置和远程)或 `override`(远程覆盖内置)。 @@ -418,22 +418,22 @@ scan: | 变量 | 说明 | | --- | --- | | `OPENAI_API_KEY` | OpenAI API key | -| `OPENAI_BASE_URL` / `OPENAI_BASEURL` | OpenAI/Codex 风格 API base URL | +| `OPENAI_BASE_URL` | OpenAI-compatible API base URL | | `OPENAI_MODEL` | OpenAI/Codex 风格模型名 | | `ANTHROPIC_API_KEY` | Anthropic API key | -| `ANTHROPIC_BASE_URL` / `ANTHROPIC_BASEURL` | Claude Code 风格 API base URL | +| `ANTHROPIC_BASE_URL` | Anthropic-compatible API base URL | | `ANTHROPIC_MODEL` | Claude Code 风格模型名 | | `AISCAN_API_KEY` | 统一 fallback API key(所有 provider 通用) | -| `AISCAN_BASE_URL` / `AISCAN_LLM_BASE_URL` | 统一 LLM API base URL | -| `AISCAN_MODEL` / `AISCAN_LLM_MODEL` | 统一模型名 | -| `AISCAN_PROVIDER` / `AISCAN_LLM_PROVIDER` | 协议类型:`openai` 或 `anthropic` | +| `AISCAN_BASE_URL` | 统一 LLM API base URL | +| `AISCAN_MODEL` | 统一模型名 | +| `AISCAN_PROVIDER` | 协议类型:`openai` 或 `anthropic` | | `AISCAN_LLM_PROXY` | LLM API 请求代理 | | `AISCAN_DATA_DIR` | 数据目录;优先级低于显式 `--data-dir` | -| `AISCAN_PROXY` / `AISCAN_SCANNER_PROXY` | 扫描工具代理 | -| `AISCAN_CYBERHUB_URL` / `CYBERHUB_URL` | Cyberhub URL | -| `AISCAN_CYBERHUB_KEY` / `CYBERHUB_KEY` | Cyberhub API key | -| `AISCAN_CYBERHUB_MODE` / `CYBERHUB_MODE` | Cyberhub 资源模式 | -| `TAVILY_API_KEY` / `TAVILY_API_KEYS` | Tavily Web Search API key,多个 key 可逗号分隔 | +| `AISCAN_PROXY` | 扫描工具代理 | +| `AISCAN_CYBERHUB_URL` | Cyberhub URL | +| `AISCAN_CYBERHUB_KEY` | Cyberhub API key | +| `AISCAN_CYBERHUB_MODE` | Cyberhub 资源模式 | +| `TAVILY_API_KEY` | Tavily Web Search API key,多个 key 可逗号分隔 | | `FOFA_EMAIL` / `FOFA_KEY` | FOFA 凭据 | | `HUNTER_API_KEY` / `HUNTER_TOKEN` | Hunter 凭据 | | `RECON_PROXY` | 被动测绘出站代理 | diff --git a/pkg/runner/application_builder.go b/pkg/runner/application_builder.go index 945f4e95..af6d8a9d 100644 --- a/pkg/runner/application_builder.go +++ b/pkg/runner/application_builder.go @@ -25,19 +25,19 @@ func AppConfig(option *cfg.Option, features RuntimeFeatures, logger telemetry.Lo Optional: features.ProviderOptional, }, Scanner: ScannerConfig{ - CyberhubURL: option.CyberhubURL, - CyberhubKey: option.CyberhubKey, - CyberhubMode: option.CyberhubMode, - AIEnabled: features.AIEnabled, - VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), - Proxy: option.Proxy, - FofaEmail: option.FofaEmail, - FofaKey: option.FofaKey, - HunterToken: option.HunterToken, - HunterAPIKey: option.HunterAPIKey, - ReconProxy: option.ReconProxy, - ReconLimit: intOptionValue(option.ReconLimit), - ReconProviderKeys: cloneStringMap(option.ReconProviderKeys), + CyberhubURL: option.CyberhubURL, + CyberhubKey: option.CyberhubKey, + CyberhubMode: option.CyberhubMode, + AIEnabled: features.AIEnabled, + VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), + Proxy: option.Proxy, + FofaEmail: option.FofaEmail, + FofaKey: option.FofaKey, + HunterToken: option.HunterToken, + HunterAPIKey: option.HunterAPIKey, + ReconProxy: option.ReconProxy, + ReconLimit: intOptionValue(option.ReconLimit), + UncoverCredentials: cloneStringMap(option.UncoverCredentials), }, Tools: ToolConfig{ Enabled: features.ToolsEnabled, diff --git a/pkg/runner/application_config.go b/pkg/runner/application_config.go index 20f499c0..096eb196 100644 --- a/pkg/runner/application_config.go +++ b/pkg/runner/application_config.go @@ -24,19 +24,19 @@ type ApplicationProviderConfig struct { } type ScannerConfig struct { - CyberhubURL string - CyberhubKey string - CyberhubMode string - AIEnabled bool - VerifyMode string - Proxy string - FofaEmail string - FofaKey string - HunterToken string - HunterAPIKey string - ReconProxy string - ReconLimit int - ReconProviderKeys map[string]string + CyberhubURL string + CyberhubKey string + CyberhubMode string + AIEnabled bool + VerifyMode string + Proxy string + FofaEmail string + FofaKey string + HunterToken string + HunterAPIKey string + ReconProxy string + ReconLimit int + UncoverCredentials map[string]string } type ToolConfig struct { diff --git a/pkg/runner/provider_config_test.go b/pkg/runner/provider_config_test.go index 42f1e788..1a1fca1a 100644 --- a/pkg/runner/provider_config_test.go +++ b/pkg/runner/provider_config_test.go @@ -10,7 +10,7 @@ func TestProviderConfigSelectsActiveProfileAndFallbacks(t *testing.T) { option := cfg.Option{LLMOptions: cfg.LLMOptions{ ActiveProfile: "openai", Providers: []cfg.LLMProviderEntry{ - {ID: "deepseek", Provider: "deepseek", APIKey: "dk-111", Model: "deepseek-chat", MaxTokens: 8192}, + {ID: "deepseek", Provider: "openai", APIKey: "dk-111", Model: "deepseek-chat", MaxTokens: 8192}, {ID: "openai", Provider: "openai", APIKey: "sk-222", Model: "gpt-4o", MaxTokens: 32768}, }, }} @@ -27,7 +27,7 @@ func TestProviderConfigSelectsActiveProfileAndFallbacks(t *testing.T) { func TestProviderConfigExplicitFieldsWin(t *testing.T) { option := cfg.Option{LLMOptions: cfg.LLMOptions{ Provider: "anthropic", APIKey: "cli-key", Model: "cli-model", - Providers: []cfg.LLMProviderEntry{{Provider: "deepseek", APIKey: "fallback-key", Model: "deepseek-chat"}}, + Providers: []cfg.LLMProviderEntry{{Provider: "openai", APIKey: "fallback-key", Model: "deepseek-chat"}}, }} primary := ProviderConfig(&option) if primary.Provider != "anthropic" || primary.APIKey != "cli-key" || primary.Model != "cli-model" { diff --git a/pkg/runner/runtime_config.go b/pkg/runner/runtime_config.go index 3ef69b83..a12d31e7 100644 --- a/pkg/runner/runtime_config.go +++ b/pkg/runner/runtime_config.go @@ -1,18 +1,15 @@ package runner -import ( - "github.com/chainreactors/aiscan/agent" - cfg "github.com/chainreactors/aiscan/core/config" -) +import cfg "github.com/chainreactors/aiscan/core/config" // ResolveRuntimeConfig resolves the process configuration and applies process // state such as the data directory. func ResolveRuntimeConfig(option *cfg.Option) (string, error) { - return cfg.ResolveRuntimeConfig(option, true, agent.InferProviderFromBaseURL) + return cfg.ResolveRuntimeConfig(option, true) } // ResolveRuntimeConfigCandidate resolves a staged Web configuration without // mutating process-wide state before the candidate is committed. func ResolveRuntimeConfigCandidate(option *cfg.Option) (string, error) { - return cfg.ResolveRuntimeConfig(option, false, agent.InferProviderFromBaseURL) + return cfg.ResolveRuntimeConfig(option, false) } diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go index ecfeead9..1faa0c64 100644 --- a/pkg/web/config_profiles_test.go +++ b/pkg/web/config_profiles_test.go @@ -12,7 +12,7 @@ func TestActivateLLMProfileSelectsByID(t *testing.T) { store.cfg.LLM.ActiveProfile = "primary" store.cfg.LLM.Providers = []webproto.LLMProviderConfig{ {ID: "primary", Name: "Primary", Provider: "openai", Model: "gpt-primary", APIKey: "key-1"}, - {ID: "fast", Name: "Fast", Provider: "deepseek", Model: "deepseek-fast", APIKey: "key-2"}, + {ID: "fast", Name: "Fast", Provider: "openai", Model: "deepseek-fast", APIKey: "key-2"}, } service := NewService(ServiceConfig{ConfigStore: store}) diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go index 9b62c509..fe100730 100644 --- a/pkg/web/config_reload_test.go +++ b/pkg/web/config_reload_test.go @@ -102,7 +102,7 @@ func TestHandleConfigReloadResultUpdatesAgentStatus(t *testing.T) { pool.register(a) payload, _ := json.Marshal(webproto.ConfigReloadResult{ - OK: true, Provider: "deepseek", Model: "deepseek-v4-pro", + OK: true, Provider: "openai", Model: "deepseek-v4-pro", }) pool.handleAgentMessage(a, WSMessage{Type: "config.result", Payload: payload}) got := a.info().Status diff --git a/pkg/web/validation.go b/pkg/web/validation.go index 250ae8fc..487f2d28 100644 --- a/pkg/web/validation.go +++ b/pkg/web/validation.go @@ -6,6 +6,7 @@ import ( "net/url" "strings" + agentprovider "github.com/chainreactors/aiscan/agent/provider" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -13,6 +14,10 @@ import ( // incomplete profiles before an invalid configuration can be persisted. func ValidateLLMConfig(cfg webproto.LLMConfig) error { for i, profile := range cfg.Providers { + profile = webproto.NormalizeLLMProvider(profile) + if !agentprovider.IsSupportedProvider(profile.Provider) { + return fmt.Errorf("LLM provider %q is unsupported: use openai or anthropic", profile.Provider) + } if strings.TrimSpace(profile.Model) == "" { name := strings.TrimSpace(profile.Name) if name == "" { diff --git a/pkg/web/validation_test.go b/pkg/web/validation_test.go index 62570eb2..676b1f45 100644 --- a/pkg/web/validation_test.go +++ b/pkg/web/validation_test.go @@ -1,6 +1,21 @@ package web -import "testing" +import ( + "strings" + "testing" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func TestValidateLLMConfigRejectsUnsupportedProvider(t *testing.T) { + cfg := webproto.LLMConfig{Providers: []webproto.LLMProviderConfig{{ + Provider: "deepseek", + Model: "deepseek-chat", + }}} + if err := ValidateLLMConfig(cfg); err == nil || !strings.Contains(err.Error(), "use openai or anthropic") { + t.Fatalf("ValidateLLMConfig() error = %v", err) + } +} func TestValidateTarget(t *testing.T) { tests := []struct { diff --git a/pkg/webagent/remote_test.go b/pkg/webagent/remote_test.go index c5cee377..27a3bb07 100644 --- a/pkg/webagent/remote_test.go +++ b/pkg/webagent/remote_test.go @@ -20,7 +20,7 @@ func TestFetchRemoteConfigUsesBearerTokenFromURL(t *testing.T) { var cfg webproto.DistributeConfig cfg.LLM.ActiveProfile = "p1" cfg.LLM.Providers = []webproto.LLMProviderConfig{ - {ID: "p1", Provider: "deepseek", Model: "deepseek-chat", MaxTokens: 8192, ContextWindow: 128000}, + {ID: "p1", Provider: "openai", Model: "deepseek-chat", MaxTokens: 8192, ContextWindow: 128000}, {ID: "p2", Provider: "openai", Model: "gpt-5"}, } _ = json.NewEncoder(w).Encode(cfg) diff --git a/pkg/webproto/config.go b/pkg/webproto/config.go index 64522052..0827fd54 100644 --- a/pkg/webproto/config.go +++ b/pkg/webproto/config.go @@ -73,8 +73,8 @@ func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { llm.ActiveProfile = active.ID } -// NormalizeLLMProvider collapses vendor labels to the two supported wire -// protocols while preserving the profile's endpoint, model, and identity. +// NormalizeLLMProvider canonicalizes protocol casing and infers the protocol +// from base_url when omitted. Validation rejects unsupported protocols. func NormalizeLLMProvider(profile LLMProviderConfig) LLMProviderConfig { if strings.TrimSpace(profile.Provider) != "" { profile.Provider = agentprovider.NormalizeProvider(profile.Provider) diff --git a/pkg/webproto/config_test.go b/pkg/webproto/config_test.go index ddb7b963..f9263b4a 100644 --- a/pkg/webproto/config_test.go +++ b/pkg/webproto/config_test.go @@ -2,10 +2,11 @@ package webproto import "testing" -func TestMigrateLLMConfigNormalizesProviderProtocol(t *testing.T) { +func TestMigrateLLMConfigCanonicalizesProviderProtocol(t *testing.T) { config := LLMConfig{Providers: []LLMProviderConfig{ - {ID: "deepseek", Provider: "deepseek", BaseURL: "https://api.deepseek.com/v1"}, - {ID: "claude", Provider: "anthropic"}, + {ID: "openai", Provider: " OPENAI ", BaseURL: "https://api.deepseek.com/v1"}, + {ID: "claude", Provider: "ANTHROPIC"}, + {ID: "invalid", Provider: "deepseek"}, }} MigrateLLMConfig(&config, LLMProviderConfig{}) @@ -15,4 +16,7 @@ func TestMigrateLLMConfigNormalizesProviderProtocol(t *testing.T) { if config.Providers[1].Provider != "anthropic" { t.Fatalf("Anthropic provider = %q", config.Providers[1].Provider) } + if config.Providers[2].Provider != "deepseek" { + t.Fatalf("unsupported provider must not be rewritten, got %q", config.Providers[2].Provider) + } } diff --git a/test-skips.json b/test-skips.json index aaa353fd..f7ca5eca 100644 --- a/test-skips.json +++ b/test-skips.json @@ -141,7 +141,7 @@ }, { "path": "tools/ioa/commands_test.go", - "format": "set LIVE_TEST_API_KEY or DEEPSEEK_API_KEY to run live LLM IOA test", + "format": "set LIVE_TEST_API_KEY or OPENAI_API_KEY to run live LLM IOA test", "count": 1, "category": "live_llm", "reason": "The IOA integration calls a live model endpoint and requires an explicit credential." diff --git a/tools/scan/engine/set.go b/tools/scan/engine/set.go index ea52784b..1f854915 100644 --- a/tools/scan/engine/set.go +++ b/tools/scan/engine/set.go @@ -33,7 +33,7 @@ type ReconOptions struct { HunterAPIKey string // 华顺信安后台 API 管理生成的 api-key (推荐, 64 位 hex) Limit int IngressProxy string // 给 uncover 的全局出站代理 (http://, https://, socks5://, socks5h://) - ProviderKeys map[string]string + Credentials map[string]string } type Set struct { diff --git a/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go index b050229a..444b3ba0 100644 --- a/tools/scan/engine/set_uncover_recon.go +++ b/tools/scan/engine/set_uncover_recon.go @@ -43,12 +43,12 @@ func mergeReconOptions(base, next ReconOptions) ReconOptions { if next.Limit != 0 { base.Limit = next.Limit } - if len(next.ProviderKeys) > 0 { - if base.ProviderKeys == nil { - base.ProviderKeys = make(map[string]string, len(next.ProviderKeys)) + if len(next.Credentials) > 0 { + if base.Credentials == nil { + base.Credentials = make(map[string]string, len(next.Credentials)) } - for key, value := range next.ProviderKeys { - base.ProviderKeys[key] = value + for key, value := range next.Credentials { + base.Credentials[key] = value } } return base diff --git a/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go index 978d82ab..92e4b91a 100644 --- a/tools/scan/engine/uncover.go +++ b/tools/scan/engine/uncover.go @@ -55,7 +55,7 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine p.Hunter = append(p.Hunter, opts.HunterToken) } - applyProviderKeys(p, opts.ProviderKeys) + applyCredentials(p, opts.Credentials) keys := p.GetKeys() // uncover's GetKeys only populates the FofaEmail/FofaKey pair when the stored @@ -87,7 +87,7 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine return e } -func applyProviderKeys(p *sources.Provider, values map[string]string) { +func applyCredentials(p *sources.Provider, values map[string]string) { appendValue := func(dst *[]string, name string) { if value := strings.TrimSpace(values[name]); value != "" { *dst = append(*dst, value) diff --git a/tools/scan/engine/uncover_test.go b/tools/scan/engine/uncover_test.go index 0194a8d7..6f3c3a32 100644 --- a/tools/scan/engine/uncover_test.go +++ b/tools/scan/engine/uncover_test.go @@ -67,8 +67,8 @@ func TestNewUncoverEngineDoesNotRereadCredentialEnvironment(t *testing.T) { } } -func TestNewUncoverEngineUsesInjectedProviderKeys(t *testing.T) { - eng := NewUncoverEngine(ReconOptions{ProviderKeys: map[string]string{ +func TestNewUncoverEngineUsesInjectedCredentials(t *testing.T) { + eng := NewUncoverEngine(ReconOptions{Credentials: map[string]string{ "SHODAN_API_KEY": "shodan-key", }}, nil) if eng.keys.Shodan != "shodan-key" || !sourceAvailable(eng, "shodan") { diff --git a/tools/search/cyberhub.go b/tools/search/cyberhub.go index 89ae97d7..8fb5510f 100644 --- a/tools/search/cyberhub.go +++ b/tools/search/cyberhub.go @@ -101,7 +101,7 @@ func (c *CyberhubSearch) Usage() string { return cyberhubUsage() } func (c *CyberhubSearch) Run(_ context.Context, execution *commands.Execution) (any, error) { args := execution.Args if c.index == nil { - return nil, fmt.Errorf("search cyberhub: not available — cyberhub resources not loaded. Configure via --cyberhub-url and --cyberhub-key flags, env (CYBERHUB_URL, CYBERHUB_KEY), or config file (cyberhub.url, cyberhub.key). Do not retry until configured") + return nil, fmt.Errorf("search cyberhub: not available — cyberhub resources not loaded. Configure via --cyberhub-url and --cyberhub-key flags, env (AISCAN_CYBERHUB_URL, AISCAN_CYBERHUB_KEY), or config file (cyberhub.url, cyberhub.key). Do not retry until configured") } var opts cyberhubFlags diff --git a/web/frontend/src/components/ConfigPanel.tsx b/web/frontend/src/components/ConfigPanel.tsx index b3f00ea1..20efbf48 100644 --- a/web/frontend/src/components/ConfigPanel.tsx +++ b/web/frontend/src/components/ConfigPanel.tsx @@ -26,9 +26,19 @@ const TABS: { key: TabKey; label: string }[] = [ { key: 'agent', label: 'Agent' }, ] -const LLM_PROVIDERS = [ - { value: 'openai', label: 'OpenAI-compatible' }, - { value: 'anthropic', label: 'Anthropic' }, +type LLMProtocol = 'openai' | 'anthropic' + +const LLM_PROVIDER_PRESETS: { value: string; label: string; protocol: LLMProtocol; baseUrl: string }[] = [ + { value: 'openai', label: 'OpenAI', protocol: 'openai', baseUrl: 'https://api.openai.com/v1' }, + { value: 'anthropic', label: 'Anthropic', protocol: 'anthropic', baseUrl: 'https://api.anthropic.com/v1' }, + { value: 'deepseek', label: 'DeepSeek', protocol: 'openai', baseUrl: 'https://api.deepseek.com/v1' }, + { value: 'openrouter', label: 'OpenRouter', protocol: 'openai', baseUrl: 'https://openrouter.ai/api/v1' }, + { value: 'groq', label: 'Groq', protocol: 'openai', baseUrl: 'https://api.groq.com/openai/v1' }, + { value: 'moonshot', label: 'Moonshot', protocol: 'openai', baseUrl: 'https://api.moonshot.cn/v1' }, + { value: 'ollama', label: 'Ollama', protocol: 'openai', baseUrl: 'http://localhost:11434/v1' }, + { value: 'zhipu', label: 'Zhipu GLM', protocol: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' }, + { value: 'custom-openai', label: '', protocol: 'openai', baseUrl: '' }, + { value: 'custom-anthropic', label: '', protocol: 'anthropic', baseUrl: '' }, ] function emptyForm(): DistributeConfig { @@ -48,7 +58,6 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { const profiles: LLMProviderProfile[] = cs.llm.profiles?.length ? cs.llm.profiles.map(profile => ({ ...profile, - provider: normalizeProvider(profile.provider), api_key: '', context_window: positiveInteger(profile.context_window), max_tokens: positiveInteger(profile.max_tokens), @@ -56,7 +65,7 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { : [{ id: cs.llm.active_profile || 'default', name: cs.llm.model || cs.llm.provider || 'Default', - provider: normalizeProvider(cs.llm.provider), + provider: cs.llm.provider, base_url: cs.llm.base_url, api_key: '', model: cs.llm.model, @@ -79,11 +88,15 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { } function blankLLMProfile(id = `llm-${Date.now()}`): LLMProviderProfile { - return { id, name: 'New LLM', provider: 'openai', base_url: '', api_key: '', model: '', proxy: '' } + return { id, name: 'New LLM', provider: 'openai', base_url: 'https://api.openai.com/v1', api_key: '', model: '', proxy: '' } } -function normalizeProvider(provider: string): 'openai' | 'anthropic' { - return provider.trim().toLowerCase() === 'anthropic' ? 'anthropic' : 'openai' +function providerPresetValue(profile: LLMProviderProfile): string { + const baseURL = profile.base_url.trim().replace(/\/+$/, '') + if (!baseURL) return `custom-${profile.provider}` + return LLM_PROVIDER_PRESETS.find(preset => + preset.protocol === profile.provider && preset.baseUrl.replace(/\/+$/, '') === baseURL + )?.value || `custom-${profile.provider}` } function positiveInteger(value: number | undefined): number | undefined { @@ -298,17 +311,35 @@ function LLMTab({ const profile = profiles.find(item => item.id === selectedProfileID) || profiles[0] const configuredProfile = cs?.llm.profiles?.find(item => item.id === profile?.id) - const updateProfile = (key: K, value: LLMProviderProfile[K]) => { + const patchProfile = (patch: Partial) => { if (!profile) return setForm(current => ({ ...current, llm: { ...current.llm, - providers: current.llm.providers.map(item => item.id === profile.id ? { ...item, [key]: value } : item), + providers: current.llm.providers.map(item => item.id === profile.id ? { ...item, ...patch } : item), }, })) } + const updateProfile = (key: K, value: LLMProviderProfile[K]) => { + patchProfile({ [key]: value } as Partial) + } + + const resetProviderState = () => { + setModels([]) + setModelsError(null) + setModelsNotice(null) + setResult(null) + } + + const selectProviderPreset = (value: string) => { + const selected = LLM_PROVIDER_PRESETS.find(item => item.value === value) + if (!selected) return + patchProfile({ provider: selected.protocol, base_url: selected.baseUrl }) + resetProviderState() + } + const addProfile = () => { const next = blankLLMProfile() setForm(current => ({ ...current, llm: { ...current.llm, providers: [...current.llm.providers, next] } })) @@ -425,11 +456,15 @@ function LLMTab({ updateProfile('name', e.target.value)} placeholder={t('profileNameHint')} /> - - - {LLM_PROVIDERS.map((provider) => {provider.label})} + {LLM_PROVIDER_PRESETS.map((preset) => ( + + {preset.label || t(preset.protocol === 'openai' ? 'customOpenAI' : 'customAnthropic')} · {preset.protocol} + + ))} diff --git a/web/frontend/src/i18n/locales/en/config.ts b/web/frontend/src/i18n/locales/en/config.ts index 645490a5..e079a23b 100644 --- a/web/frontend/src/i18n/locales/en/config.ts +++ b/web/frontend/src/i18n/locales/en/config.ts @@ -15,6 +15,9 @@ export default { selectProvider: 'Select provider', // field labels provider: 'Provider', + providerPresetHint: 'Sets protocol and Base URL automatically', + customOpenAI: 'Custom OpenAI-compatible', + customAnthropic: 'Custom Anthropic-compatible', model: 'Model', profileName: 'Profile name', profileNameHint: 'e.g. DeepSeek production', @@ -62,7 +65,7 @@ export default { apiKeyRequired: 'API key required', modelRequired: 'Model is required', modelRequiredProfile: 'Profile “{{name}}” requires a model', - providerDefault: 'leave empty for provider default', + providerDefault: 'filled by preset or enter a custom endpoint', modelDefault: 'leave empty for model default', cyberhubApiKey: 'cyberhub API key', fofaApiKey: 'FOFA API key', diff --git a/web/frontend/src/i18n/locales/zh/config.ts b/web/frontend/src/i18n/locales/zh/config.ts index 229b725d..3b24c9c3 100644 --- a/web/frontend/src/i18n/locales/zh/config.ts +++ b/web/frontend/src/i18n/locales/zh/config.ts @@ -12,9 +12,12 @@ export default { save: '保存', failedLoad: '加载配置失败', failedSave: '保存配置失败', - selectProvider: '选择 Provider', + selectProvider: '选择服务商', // field labels - provider: 'Provider', + provider: '服务商', + providerPresetHint: '自动设置协议与 Base URL', + customOpenAI: '自定义 OpenAI-compatible', + customAnthropic: '自定义 Anthropic-compatible', model: '模型', profileName: '配置名称', profileNameHint: '例如:DeepSeek 生产环境', @@ -62,7 +65,7 @@ export default { apiKeyRequired: '需要 API Key', modelRequired: '模型不能为空', modelRequiredProfile: '配置 “{{name}}” 的模型不能为空', - providerDefault: '留空则使用 Provider 默认值', + providerDefault: '由预设填充,也可输入自定义地址', modelDefault: '留空则使用模型默认值', cyberhubApiKey: 'Cyberhub API Key', fofaApiKey: 'FOFA API Key', From b3611750f1de5a2e2574ecc97c045b1ad244bfca Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 22:38:34 +0800 Subject: [PATCH 150/348] fix(agent): clamp retry backoff before shifting --- agent/retry.go | 13 +++++++++---- agent/retry_test.go | 7 +++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/agent/retry.go b/agent/retry.go index bf61c0ca..7aeff4fd 100644 --- a/agent/retry.go +++ b/agent/retry.go @@ -96,11 +96,16 @@ func isRetryableByMessage(err error) bool { // backward compatibility with external callers such as runner and webagent // reconnect logic. func RetryDelay(attempt int) time.Duration { - delay := time.Second << uint(attempt) - if delay > 10*time.Second { - delay = 10 * time.Second + if attempt < 0 { + attempt = 0 } - return delay + // Clamp before shifting. A large attempt previously overflowed the duration + // shift to zero, turning a persistent authentication failure into a tight + // reconnect loop that could saturate the control plane. + if attempt >= 4 { + return 10 * time.Second + } + return time.Second << uint(attempt) } // retryDelayFor computes the backoff for an LLM call retry. It honors a diff --git a/agent/retry_test.go b/agent/retry_test.go index 5ceea4c5..c2728fbe 100644 --- a/agent/retry_test.go +++ b/agent/retry_test.go @@ -424,6 +424,13 @@ func TestRetryDelayBackoffSequence(t *testing.T) { t.Errorf("attempt %d: RetryDelay = %s, want %s", i, got, w) } } + + if got := RetryDelay(-1); got != time.Second { + t.Errorf("negative attempt: RetryDelay = %s, want 1s", got) + } + if got := RetryDelay(64); got != 10*time.Second { + t.Errorf("large attempt: RetryDelay = %s, want 10s", got) + } } func TestComputeRetryDelaySequence(t *testing.T) { From b93601a8f69c3a3fb55f76994e85528a99ed1131 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 22:38:34 +0800 Subject: [PATCH 151/348] fix(web): initialize app from resolved configuration --- cmd/aiscan/web_full.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 4ab0556a..14b800a4 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -38,7 +38,10 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom } defer store.Close() - application, err := initWebApp(ctx, explicitOption, logger) + // The initial app must use the fully resolved option, including values loaded + // from the config file and environment. explicitOption is only the seed for + // later staged reloads, where the candidate config is resolved independently. + application, err := initWebApp(ctx, option, logger) if err != nil { return fmt.Errorf("init aiscan: %s", err) } From 3d27cd5f90763c3333e113262471456f191c4d82 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 23:08:35 +0800 Subject: [PATCH 152/348] test(tui): isolate bang output newline contract --- pkg/tui/console_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pkg/tui/console_test.go b/pkg/tui/console_test.go index 52d8cf57..a5ba5c25 100644 --- a/pkg/tui/console_test.go +++ b/pkg/tui/console_test.go @@ -9,13 +9,13 @@ import ( "os" "path/filepath" "reflect" - "runtime" "strings" "testing" "time" "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/tui/readline/inputrc" rlterm "github.com/chainreactors/tui/readline/terminal" @@ -86,15 +86,21 @@ func TestAgentConsoleArgsForLineBangCommand(t *testing.T) { } } +type consoleTextTool struct { + output string +} + +func (t *consoleTextTool) Name() string { return "bash" } +func (t *consoleTextTool) Description() string { return "console output test tool" } +func (t *consoleTextTool) Definition() tool.Definition { return tool.Definition{} } +func (t *consoleTextTool) Execute(context.Context, string) (tool.Result, error) { + return tool.TextResult(t.output), nil +} + func TestAgentConsoleBangCommandTerminatesOutputLine(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell assertion is unix-only") - } var stdout, stderr bytes.Buffer registry := commands.NewRegistry() - bash := commands.NewBashTool(t.TempDir(), 5) - defer bash.Close() - registry.RegisterTool(bash) + registry.RegisterTool(&consoleTextTool{output: "DIRECT_OK"}) repl := NewAgentConsoleWithWriters(context.Background(), &cfg.Option{}, AppInfo{Commands: registry}, nil, &stdout, &stderr) if _, err := repl.ExecuteLineAndWait("!printf DIRECT_OK"); err != nil { From ea6c0e05d175e36576c839f86224abea6a997517 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 23:14:54 +0800 Subject: [PATCH 153/348] test(tui): retire obsolete platform skip --- test-skips.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/test-skips.json b/test-skips.json index f7ca5eca..cf012a39 100644 --- a/test-skips.json +++ b/test-skips.json @@ -97,13 +97,6 @@ "category": "capability", "reason": "Two mixed HTTP/headless templates reference variables unavailable to the headless-only compile fixture." }, - { - "path": "pkg/tui/console_test.go", - "format": "shell assertion is unix-only", - "count": 1, - "category": "platform", - "reason": "The assertion targets POSIX shell rendering." - }, { "path": "pkg/web/agents_test.go", "format": "chromium not found, skipping browser e2e test", From ee91aa6ae12de54f25020b65ef656cb6390a71cb Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 23:41:06 +0800 Subject: [PATCH 154/348] fix(scan): avoid per-run global logger mutation --- tools/scan/engine/spray.go | 11 +++++------ tools/scan/engine/spray_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tools/scan/engine/spray.go b/tools/scan/engine/spray.go index b340266c..34edee59 100644 --- a/tools/scan/engine/spray.go +++ b/tools/scan/engine/spray.go @@ -48,9 +48,6 @@ func SprayCheckStream(ctx context.Context, eng *spray.Engine, opts SprayCheckOpt return nil, fmt.Errorf("spray engine is not available") } sprayExecutionMu.Lock() - if opts.Debug { - telemetry.EnableLogsDebug() - } runCtx, cancel := sprayInvocationContext(ctx, opts) sprayCtx := spray.NewContext(). WithContext(runCtx). @@ -139,6 +136,11 @@ func defaultSprayInvocationTimeout(opts SprayCheckOptions) time.Duration { func buildSprayOption(opts SprayCheckOptions) *spray.Option { sprayOpt := spray.NewDefaultOption() coreOpt := sprayOpt.Option + // The SDK configures its shared logger once when the engine is initialized, + // and scan --debug configures it before pipeline workers start. Keeping the + // per-run Quiet flag enabled makes upstream NewRunner call SetQuiet while + // other engines are logging, which races on the shared logger. + coreOpt.Quiet = false coreOpt.Threads = opts.Threads coreOpt.Timeout = opts.Timeout coreOpt.Host = opts.Host @@ -159,9 +161,6 @@ func buildSprayOption(opts SprayCheckOptions) *spray.Option { coreOpt.CrawlDepth = opts.CrawlDepth } coreOpt.Debug = opts.Debug - if opts.Debug { - coreOpt.Quiet = false - } if opts.Proxy != "" { coreOpt.Proxies = []string{opts.Proxy} } diff --git a/tools/scan/engine/spray_test.go b/tools/scan/engine/spray_test.go index ab619462..63af5354 100644 --- a/tools/scan/engine/spray_test.go +++ b/tools/scan/engine/spray_test.go @@ -38,6 +38,16 @@ func TestBuildSprayOptionAppliesDebugAndRuntimeOptions(t *testing.T) { } } +func TestBuildSprayOptionAvoidsPerRunGlobalLoggerMutation(t *testing.T) { + opt := buildSprayOption(SprayCheckOptions{}) + if opt.Quiet { + t.Fatal("quiet = true; upstream NewRunner would mutate the shared logger") + } + if !opt.NoBar { + t.Fatal("no bar = false; SDK runs must not install a global progress writer") + } +} + func TestDefaultSprayInvocationTimeoutBoundsCrawl(t *testing.T) { got := defaultSprayInvocationTimeout(SprayCheckOptions{Timeout: 5, Crawl: true, CrawlDepth: 2}) if got != 80*time.Second { From c8502304f0f100b61425ec738c72eba28a0fc3c7 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 1 Aug 2026 18:54:03 +0800 Subject: [PATCH 155/348] feat: unify web protocols on protobuf and ConnectRPC --- README_CN.md | 1 + agent/agent.go | 54 +- agent/agent_test.go | 63 +- agent/aop_emit.go | 260 +-- agent/aop_emit_test.go | 116 +- agent/compact.go | 8 +- agent/evaluator/loop.go | 10 +- agent/evaluator/loop_test.go | 26 +- agent/helpers_test.go | 16 +- agent/input.go | 56 +- agent/input_test.go | 14 +- agent/loop.go | 51 +- agent/loop_test.go | 92 +- agent/probe/llm.go | 2 +- agent/provider/anthropic.go | 5 +- agent/provider/frame.go | 31 + agent/provider/http.go | 24 +- agent/provider/openai.go | 5 +- agent/provider/types.go | 2 + agent/provider_frame_test.go | 152 ++ agent/retry.go | 10 +- agent/retry_test.go | 4 +- agent/subagent.go | 28 +- agent/subagent_test.go | 40 +- agent/types.go | 44 +- .../chat/chatconnect/session.connect.go | 283 +++ aop/aiscan/chat/session.pb.go | 1841 ++++++++++++++++ aop/aiscan/chat/session_grpc.pb.go | 331 +++ aop/aiscan/client.go | 45 + aop/aiscan/client_test.go | 15 + aop/aiscan/extensions/extensions.go | 103 + aop/aiscan/scan/scan.pb.go | 1945 +++++++++++++++++ aop/aiscan/scan/scan_grpc.pb.go | 322 +++ aop/aiscan/scan/scanconnect/scan.connect.go | 250 +++ aop/aiscan/transport/agent.pb.go | 1330 +++++++++++ aop/aiscan/transport/agent_grpc.pb.go | 141 ++ aop/aiscan/transport/extensions.pb.go | 790 +++++++ aop/aiscan/transport/operation.pb.go | 1556 +++++++++++++ aop/aiscan/transport/telemetry.pb.go | 861 ++++++++ aop/aiscan/transport/terminal.pb.go | 506 +++++ aop/aopconnect/chat.connect.go | 249 +++ aop/chat.pb.go | 1661 ++++++++++++++ aop/chat_grpc.pb.go | 322 +++ aop/content.pb.go | 1159 ++++++++++ aop/event.pb.go | 1778 +++++++++++++++ aop/helpers.go | 167 ++ aop/helpers_test.go | 45 + aop/interop_fixture_test.go | 60 + aop/value.pb.go | 227 ++ cmd/aiscan/cli_test.go | 3 +- cmd/aiscan/setup.go | 4 +- cmd/aiscan/web_full.go | 44 +- cmd/aiscan/web_full_test.go | 12 +- core/aop/decode.go | 16 - core/aop/event.go | 57 - core/aop/ext.go | 39 - core/aop/ext_types_gen.go | 35 - core/aop/gen_error.go | 14 - core/aop/gen_message.go | 36 - core/aop/gen_message_delta.go | 17 - core/aop/gen_session_start.go | 14 - core/aop/gen_status.go | 8 - core/aop/gen_tool_call.go | 17 - core/aop/gen_tool_result.go | 26 - core/aop/gen_turn.go | 5 - core/aop/gen_usage_session.go | 42 - core/aop/generate.go | 12 - core/aop/schema_test.go | 125 -- core/aop/tool_result.go | 60 - core/aop/tool_result_test.go | 34 - core/aop/x/command/command.go | 13 - core/aop/x/compact/compact.go | 14 - core/aop/x/compact/generate.go | 3 - core/aop/x/compact/types_gen.go | 17 - core/aop/x/delegation/delegation.go | 13 - core/aop/x/delegation/generate.go | 3 - core/aop/x/delegation/types_gen.go | 33 - core/aop/x/eval/eval.go | 16 - core/aop/x/eval/generate.go | 3 - core/aop/x/eval/types_gen.go | 28 - core/aop/x/ioa/generate.go | 3 - core/aop/x/ioa/ioa.go | 10 - core/aop/x/ioa/types_gen.go | 34 - .../config.go => core/config/distribute.go | 58 +- .../config/distribute_test.go | 2 +- core/config/options.go | 36 +- core/deps/architecture_test.go | 170 +- core/output/timeline.go | 144 +- core/output/timeline_test.go | 58 +- core/resources/resources.go | 6 +- core/tool/definition.go | 2 +- docs/agent-runtime-multipath-analysis.md | 19 +- docs/mechanisms.md | 62 +- docs/web-chat-api.md | 413 ++++ examples/aop-chat/client.go | 93 + examples/external-go-client/go.mod | 21 + examples/external-go-client/go.sum | 38 + examples/external-go-client/main.go | 164 ++ examples/web-chat/client.go | 303 +++ examples/web-chat/client_test.go | 129 ++ examples/web-chat/main.go | 66 + go.mod | 9 +- go.sum | 24 + pkg/headless/action_types.go | 108 +- pkg/headless/engine.go | 12 +- pkg/headless/engine_test.go | 4 +- pkg/headless/http_client.go | 16 +- pkg/runner/runner.go | 31 +- pkg/runner/runtime_protocol.go | 204 +- pkg/runner/runtime_protocol_test.go | 107 +- pkg/runner/runtime_semantics_test.go | 102 +- pkg/runner/runtime_session.go | 150 +- pkg/runner/runtime_session_isolation_test.go | 6 +- pkg/runner/stdio.go | 42 +- pkg/runner/stdio_concurrency_test.go | 28 +- pkg/runner/stdio_test.go | 142 +- pkg/runner/subagent_handoff.go | 73 +- pkg/runner/subagent_handoff_test.go | 61 +- pkg/transport/transport.go | 4 +- pkg/tui/commands.go | 8 +- pkg/tui/format.go | 21 +- pkg/tui/output.go | 201 +- pkg/tui/output_test.go | 114 +- pkg/tui/remote_console.go | 8 +- pkg/tui/remote_console_test.go | 10 +- pkg/{webagent => web/agent}/agent.go | 156 +- pkg/web/agent/agent_test.go | 23 + pkg/{webagent => web/agent}/aop_tool.go | 64 +- pkg/web/agent/aop_tool_test.go | 151 ++ pkg/web/agent/connection.go | 50 + pkg/web/agent/connection_lifecycle_test.go | 82 + pkg/web/agent/exec_test.go | 37 + pkg/web/agent/file_test.go | 59 + pkg/web/agent/identity.go | 103 + pkg/web/agent/proto_connection.go | 584 +++++ pkg/{webagent => web/agent}/pty.go | 9 +- pkg/{webagent => web/agent}/remote.go | 11 +- pkg/{webagent => web/agent}/remote_test.go | 12 +- pkg/web/agent/stream.go | 62 + pkg/{webagent => web/agent}/toolnode.go | 41 +- pkg/web/agent/toolnode_test.go | 218 ++ pkg/web/agent/upload_test.go | 26 + pkg/web/agent_stream.go | 196 ++ pkg/web/agent_stream_handler.go | 265 +++ pkg/web/agents.go | 750 +++---- pkg/web/agents_session_end_test.go | 106 +- pkg/web/agents_test.go | 340 ++- pkg/web/aop_grpc.go | 503 +++++ pkg/web/aop_transport_test.go | 305 +++ pkg/web/auth.go | 69 +- pkg/web/auth/auth.go | 59 + pkg/web/auth_test.go | 18 +- pkg/web/broker.go | 128 ++ pkg/web/broker_test.go | 225 ++ pkg/web/command_test.go | 72 +- pkg/web/config_profiles_test.go | 26 +- pkg/web/config_reload_test.go | 51 +- pkg/web/config_transaction_test.go | 14 +- pkg/web/conn_probe_test.go | 4 +- pkg/web/connect.go | 139 ++ pkg/web/connect_test.go | 268 +++ pkg/web/eval_forward_test.go | 52 +- pkg/web/grpc.go | 61 + pkg/web/handler.go | 361 +-- pkg/web/llm_probe_test.go | 14 +- pkg/web/probe.go | 12 +- pkg/web/replay_test.go | 154 +- pkg/web/scan_connect.go | 64 + pkg/web/scan_grpc.go | 44 + pkg/web/scan_lifecycle_test.go | 82 +- pkg/web/scan_rpc.go | 231 ++ pkg/web/service.go | 658 ++---- pkg/web/service_test.go | 62 +- pkg/web/session_connect.go | 333 +++ pkg/web/sse.go | 185 -- pkg/web/sse_test.go | 272 --- pkg/web/store_sqlite.go | 424 ++-- pkg/web/store_sqlite_test.go | 159 +- pkg/web/terminal/codec.go | 100 + pkg/web/terminal/codec_test.go | 30 + pkg/web/types.go | 74 +- pkg/web/upload_test.go | 78 +- pkg/web/validation.go | 6 +- pkg/web/validation_test.go | 4 +- pkg/webagent/agent_test.go | 513 ----- pkg/webagent/aop_tool_test.go | 153 -- pkg/webagent/connection.go | 378 ---- pkg/webagent/connection_lifecycle_test.go | 90 - pkg/webagent/exec.go | 100 - pkg/webagent/exec_test.go | 46 - pkg/webagent/file.go | 120 - pkg/webagent/file_test.go | 110 - pkg/webagent/identity.go | 96 - pkg/webagent/stream.go | 64 - pkg/webagent/toolnode_test.go | 275 --- pkg/webagent/upload_test.go | 38 - pkg/webproto/message.go | 232 -- pkg/webproto/message_test.go | 69 - proto/aiscan/chat/session.proto | 136 ++ proto/aiscan/scan/scan.proto | 145 ++ proto/aiscan/transport/agent.proto | 90 + proto/aiscan/transport/extensions.proto | 59 + proto/aiscan/transport/operation.proto | 115 + proto/aiscan/transport/telemetry.proto | 69 + proto/aiscan/transport/terminal.proto | 44 + proto/generate.go | 8 + proto/internal/generate_ts/main.go | 56 + test-skips.json | 7 - tools/neutron/neutron.go | 5 +- tools/proxy/mitm.go | 58 +- tools/scan/command.go | 4 +- tools/scan/jsonl_writer.go | 10 +- web/frontend/cyber-ui | 2 +- web/frontend/e2e/aiscan-web.spec.ts | 261 ++- web/frontend/e2e/start-server.mjs | 13 + web/frontend/package-lock.json | 93 + web/frontend/package.json | 4 + web/frontend/src/api.ts | 576 +++-- web/frontend/src/compat/agent-protocol.ts | 34 - web/frontend/src/components/ChatPanel.tsx | 95 +- .../src/components/terminal/AgentTerminal.tsx | 32 +- .../components/terminal/TerminalDetails.tsx | 12 +- web/frontend/src/hooks/useChatSession.ts | 248 +-- web/frontend/src/viewer/index.ts | 2 +- web/frontend/tsconfig.json | 4 +- web/frontend/vite.config.ts | 2 +- web/static/.gitkeep | 1 - 227 files changed, 26517 insertions(+), 7818 deletions(-) create mode 100644 agent/provider/frame.go create mode 100644 agent/provider_frame_test.go create mode 100644 aop/aiscan/chat/chatconnect/session.connect.go create mode 100644 aop/aiscan/chat/session.pb.go create mode 100644 aop/aiscan/chat/session_grpc.pb.go create mode 100644 aop/aiscan/client.go create mode 100644 aop/aiscan/client_test.go create mode 100644 aop/aiscan/extensions/extensions.go create mode 100644 aop/aiscan/scan/scan.pb.go create mode 100644 aop/aiscan/scan/scan_grpc.pb.go create mode 100644 aop/aiscan/scan/scanconnect/scan.connect.go create mode 100644 aop/aiscan/transport/agent.pb.go create mode 100644 aop/aiscan/transport/agent_grpc.pb.go create mode 100644 aop/aiscan/transport/extensions.pb.go create mode 100644 aop/aiscan/transport/operation.pb.go create mode 100644 aop/aiscan/transport/telemetry.pb.go create mode 100644 aop/aiscan/transport/terminal.pb.go create mode 100644 aop/aopconnect/chat.connect.go create mode 100644 aop/chat.pb.go create mode 100644 aop/chat_grpc.pb.go create mode 100644 aop/content.pb.go create mode 100644 aop/event.pb.go create mode 100644 aop/helpers.go create mode 100644 aop/helpers_test.go create mode 100644 aop/interop_fixture_test.go create mode 100644 aop/value.pb.go delete mode 100644 core/aop/decode.go delete mode 100644 core/aop/event.go delete mode 100644 core/aop/ext.go delete mode 100644 core/aop/ext_types_gen.go delete mode 100644 core/aop/gen_error.go delete mode 100644 core/aop/gen_message.go delete mode 100644 core/aop/gen_message_delta.go delete mode 100644 core/aop/gen_session_start.go delete mode 100644 core/aop/gen_status.go delete mode 100644 core/aop/gen_tool_call.go delete mode 100644 core/aop/gen_tool_result.go delete mode 100644 core/aop/gen_turn.go delete mode 100644 core/aop/gen_usage_session.go delete mode 100644 core/aop/generate.go delete mode 100644 core/aop/schema_test.go delete mode 100644 core/aop/tool_result.go delete mode 100644 core/aop/tool_result_test.go delete mode 100644 core/aop/x/command/command.go delete mode 100644 core/aop/x/compact/compact.go delete mode 100644 core/aop/x/compact/generate.go delete mode 100644 core/aop/x/compact/types_gen.go delete mode 100644 core/aop/x/delegation/delegation.go delete mode 100644 core/aop/x/delegation/generate.go delete mode 100644 core/aop/x/delegation/types_gen.go delete mode 100644 core/aop/x/eval/eval.go delete mode 100644 core/aop/x/eval/generate.go delete mode 100644 core/aop/x/eval/types_gen.go delete mode 100644 core/aop/x/ioa/generate.go delete mode 100644 core/aop/x/ioa/ioa.go delete mode 100644 core/aop/x/ioa/types_gen.go rename pkg/webproto/config.go => core/config/distribute.go (61%) rename pkg/webproto/config_test.go => core/config/distribute_test.go (97%) create mode 100644 docs/web-chat-api.md create mode 100644 examples/aop-chat/client.go create mode 100644 examples/external-go-client/go.mod create mode 100644 examples/external-go-client/go.sum create mode 100644 examples/external-go-client/main.go create mode 100644 examples/web-chat/client.go create mode 100644 examples/web-chat/client_test.go create mode 100644 examples/web-chat/main.go rename pkg/{webagent => web/agent}/agent.go (71%) create mode 100644 pkg/web/agent/agent_test.go rename pkg/{webagent => web/agent}/aop_tool.go (56%) create mode 100644 pkg/web/agent/aop_tool_test.go create mode 100644 pkg/web/agent/connection.go create mode 100644 pkg/web/agent/connection_lifecycle_test.go create mode 100644 pkg/web/agent/exec_test.go create mode 100644 pkg/web/agent/file_test.go create mode 100644 pkg/web/agent/identity.go create mode 100644 pkg/web/agent/proto_connection.go rename pkg/{webagent => web/agent}/pty.go (90%) rename pkg/{webagent => web/agent}/remote.go (88%) rename pkg/{webagent => web/agent}/remote_test.go (84%) create mode 100644 pkg/web/agent/stream.go rename pkg/{webagent => web/agent}/toolnode.go (64%) create mode 100644 pkg/web/agent/toolnode_test.go create mode 100644 pkg/web/agent/upload_test.go create mode 100644 pkg/web/agent_stream.go create mode 100644 pkg/web/agent_stream_handler.go create mode 100644 pkg/web/aop_grpc.go create mode 100644 pkg/web/aop_transport_test.go create mode 100644 pkg/web/auth/auth.go create mode 100644 pkg/web/broker.go create mode 100644 pkg/web/broker_test.go create mode 100644 pkg/web/connect.go create mode 100644 pkg/web/connect_test.go create mode 100644 pkg/web/grpc.go create mode 100644 pkg/web/scan_connect.go create mode 100644 pkg/web/scan_grpc.go create mode 100644 pkg/web/scan_rpc.go create mode 100644 pkg/web/session_connect.go delete mode 100644 pkg/web/sse.go delete mode 100644 pkg/web/sse_test.go create mode 100644 pkg/web/terminal/codec.go create mode 100644 pkg/web/terminal/codec_test.go delete mode 100644 pkg/webagent/agent_test.go delete mode 100644 pkg/webagent/aop_tool_test.go delete mode 100644 pkg/webagent/connection.go delete mode 100644 pkg/webagent/connection_lifecycle_test.go delete mode 100644 pkg/webagent/exec.go delete mode 100644 pkg/webagent/exec_test.go delete mode 100644 pkg/webagent/file.go delete mode 100644 pkg/webagent/file_test.go delete mode 100644 pkg/webagent/identity.go delete mode 100644 pkg/webagent/stream.go delete mode 100644 pkg/webagent/toolnode_test.go delete mode 100644 pkg/webagent/upload_test.go delete mode 100644 pkg/webproto/message.go delete mode 100644 pkg/webproto/message_test.go create mode 100644 proto/aiscan/chat/session.proto create mode 100644 proto/aiscan/scan/scan.proto create mode 100644 proto/aiscan/transport/agent.proto create mode 100644 proto/aiscan/transport/extensions.proto create mode 100644 proto/aiscan/transport/operation.proto create mode 100644 proto/aiscan/transport/telemetry.proto create mode 100644 proto/aiscan/transport/terminal.proto create mode 100644 proto/generate.go create mode 100644 proto/internal/generate_ts/main.go delete mode 100644 web/frontend/src/compat/agent-protocol.ts delete mode 100644 web/static/.gitkeep diff --git a/README_CN.md b/README_CN.md index a951c1ae..c50dd0e9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -209,6 +209,7 @@ llm: | [Scan 模式详解](docs/scan.md) | 扫描流水线、AI 增强、输出格式 | | [Agent 模式详解](docs/agent.md) | Agent 工具集、Goal Evaluation、REPL | | [IOA 协作](docs/ioa.md) | 多 Agent 协作架构、Space/Node/Message 模型 | +| [Web 自然语言 API](docs/web-chat-api.md) | API 接口、Go 接入示例和调试排障 | | [参考手册](docs/reference.md) | 配置、LLM Provider、全局参数、扫描器用法、FAQ | | [Changelog](docs/changelog.md) | 版本变更记录 | diff --git a/agent/agent.go b/agent/agent.go index f14b92bd..1ca83ed7 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -6,8 +6,10 @@ import ( "sync" "github.com/chainreactors/aiscan/agent/inbox" - "github.com/chainreactors/aiscan/core/aop/x/delegation" + providerpkg "github.com/chainreactors/aiscan/agent/provider" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/telemetry" + "google.golang.org/protobuf/proto" ) type Agent struct { @@ -57,14 +59,14 @@ func (a *Agent) Run(ctx context.Context, input Input, opts ...RunOption) (*Resul cfg.TurnID = randomID() cfg.emitter = cfg.emitter.turn(cfg.TurnID) } + if cfg.CaptureProviderFrames { + runCtx = providerpkg.WithFrameObserver(runCtx, cfg.emitter.providerFrame) + } cfg.Messages = a.MessagesSnapshot() if cfg.Inbox == nil { cfg.Inbox = inbox.NewBuffered(SubInboxCapacity) } msg := inbox.FromChatMessage(userMsg, inbox.OriginUser) - if input.NoEcho { - msg.Meta = map[string]any{"no_echo": true} - } if err := cfg.Inbox.Push(msg); err != nil { return nil, fmt.Errorf("push prompt: %w", err) } @@ -116,6 +118,9 @@ func (a *Agent) Continue(ctx context.Context, opts ...RunOption) (*Result, error cfg.TurnID = randomID() cfg.emitter = cfg.emitter.turn(cfg.TurnID) } + if cfg.CaptureProviderFrames { + runCtx = providerpkg.WithFrameObserver(runCtx, cfg.emitter.providerFrame) + } cfg.Messages = a.MessagesSnapshot() result, runErr := runLoop(runCtx, cfg) a.saveState(result, runErr) @@ -215,35 +220,36 @@ func (a *Agent) DeriveNamed(name string) *Agent { return a.deriveNamed(name, "", nil) } -func (a *Agent) deriveNamed(name, parentToolCallID string, detail *delegation.DelegationDetail) *Agent { +func (a *Agent) deriveNamed(name, parentToolCallID string, detail *ext.DelegationDetail) *Agent { return deriveNamedFromConfig(a.configSnapshot(), name, parentToolCallID, detail) } -func deriveNamedFromConfig(cfg Config, name, parentToolCallID string, detail *delegation.DelegationDetail) *Agent { +func deriveNamedFromConfig(cfg Config, name, parentToolCallID string, detail *ext.DelegationDetail) *Agent { return NewAgent(Config{ - Provider: cfg.Provider, - Tools: cfg.Tools, - Model: cfg.Model, - MaxTokens: cfg.MaxTokens, - ContextWindow: cfg.ContextWindow, - Logger: cfg.Logger, - MaxRetries: cfg.MaxRetries, - MaxParallelTools: cfg.MaxParallelTools, - Stream: cfg.Stream, - Temperature: cfg.Temperature, - CacheRetention: cfg.CacheRetention, - Bus: cfg.Bus, - Hooks: cfg.Hooks, - AgentName: name, - ParentSessionID: cfg.SessionID, - ParentToolCallID: parentToolCallID, - Delegation: detail, + Provider: cfg.Provider, + Tools: cfg.Tools, + Model: cfg.Model, + MaxTokens: cfg.MaxTokens, + ContextWindow: cfg.ContextWindow, + Logger: cfg.Logger, + MaxRetries: cfg.MaxRetries, + MaxParallelTools: cfg.MaxParallelTools, + Stream: cfg.Stream, + Temperature: cfg.Temperature, + CacheRetention: cfg.CacheRetention, + CaptureProviderFrames: cfg.CaptureProviderFrames, + Bus: cfg.Bus, + Hooks: cfg.Hooks, + AgentName: name, + ParentSessionID: cfg.SessionID, + ParentToolCallID: parentToolCallID, + Delegation: detail, }) } // EmitStatus emits an AOP status event on the agent's session. Used by // out-of-kernel helpers (evaluator) so their events carry session/seq. -func (a *Agent) EmitStatus(state, namespace string, detail any, turnID ...string) { +func (a *Agent) EmitStatus(state, namespace string, detail proto.Message, turnID ...string) { a.mu.Lock() em := a.Cfg.emitter a.mu.Unlock() diff --git a/agent/agent_test.go b/agent/agent_test.go index 637c77de..959e1644 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/json" "fmt" "os" "os/exec" @@ -12,7 +11,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/skills" @@ -73,7 +72,7 @@ func TestRunExecutesToolLoop(t *testing.T) { Provider: llm, Tools: tools, Model: "test", - Bus: testBus(func(e aop.Event) { events = append(events, e.Type) }), + Bus: testBus(func(e *aop.Event) { events = append(events, eventKind(e)) }), })).Run(context.Background(), TextInput("use tool")) if err != nil { t.Fatalf("Run() error = %v", err) @@ -91,7 +90,7 @@ func TestRunExecutesToolLoop(t *testing.T) { if !hasToolMessage(requests[1].Messages, "call-1", "tool output") { t.Fatalf("second request missing tool result: %#v", requests[1].Messages) } - if !containsEvent(events, aop.TypeToolCall) || !containsEvent(events, aop.TypeToolResult) { + if !containsEvent(events, "tool.call") || !containsEvent(events, "tool.result") { t.Fatalf("tool events missing: %#v", events) } } @@ -162,12 +161,12 @@ func TestAgentPromptReturnsRunScopedNewMessages(t *testing.T) { func TestProviderErrorEmitsAgentEndAndUpdatesState(t *testing.T) { tools := commands.NewRegistry() llm := &scriptedProvider{err: fmt.Errorf("boom")} - var events []aop.Event + var events []*aop.Event a := NewAgent(Config{ Provider: llm, Tools: tools, Model: "test", - Bus: testBus(func(event aop.Event) { + Bus: testBus(func(event *aop.Event) { events = append(events, event) }), }) @@ -180,9 +179,9 @@ func TestProviderErrorEmitsAgentEndAndUpdatesState(t *testing.T) { t.Fatalf("result = %#v, want result with Err", result) } if got := eventTypes(events); !reflect.DeepEqual(got, []string{ - aop.TypeMessage, - aop.TypeStatus, - aop.TypeError, + "message", + "status", + "error", }) { t.Fatalf("events = %#v", got) } @@ -190,12 +189,12 @@ func TestProviderErrorEmitsAgentEndAndUpdatesState(t *testing.T) { t.Fatalf("turns = %d, want 1", result.Turns) } last := lastEvent(events) - if last.Type != aop.TypeError { + if eventKind(last) != "error" { t.Fatalf("last event = %#v, want error", last) } - var endData aop.ErrorData - if err := json.Unmarshal(last.Data, &endData); err != nil { - t.Fatal(err) + endData := last.GetError() + if endData == nil { + t.Fatal("error event missing payload") } if endData.Message == "" { t.Fatalf("error event missing message: %+v", endData) @@ -824,18 +823,15 @@ func TestLiveLLMTmuxInteraction(t *testing.T) { systemPrompt := buildTmuxTestPrompt(registry) var events []string - handleEvent := func(event aop.Event) { - switch event.Type { - case aop.TypeToolCall: - var data aop.ToolCallData - if json.Unmarshal(event.Data, &data) == nil { - args, _ := json.Marshal(data.Args) - events = append(events, fmt.Sprintf("[TOOL] %s → %s", data.ToolName, args)) + handleEvent := func(event *aop.Event) { + switch eventKind(event) { + case "tool.call": + if data := event.GetToolCall(); data != nil { + events = append(events, fmt.Sprintf("[TOOL] %s → %s", data.Name, data.GetArguments().GetData())) } - case aop.TypeToolResult: - var data aop.ToolResultData - if json.Unmarshal(event.Data, &data) == nil { - result := fmt.Sprintf("%v", data.Content) + case "tool.result": + if data := event.GetToolResult(); data != nil { + result := fmt.Sprintf("%v", data.Output) if len(result) > 300 { result = result[:300] + "..." } @@ -978,8 +974,8 @@ func TestMultiTurnContextInheritanceAndCache(t *testing.T) { systemPrompt := "You are a math tutor. " + strings.Repeat("You always answer arithmetic questions with just the numeric result. ", 30) - var events []aop.Event - handler := func(e aop.Event) { + var events []*aop.Event + handler := func(e *aop.Event) { events = append(events, e) } @@ -991,7 +987,7 @@ func TestMultiTurnContextInheritanceAndCache(t *testing.T) { Model: cfg.Model, SystemPrompt: systemPrompt, CacheRetention: CacheShort, - Bus: testBus(func(e aop.Event) { handler(e) }), + Bus: testBus(func(e *aop.Event) { handler(e) }), Logger: telemetry.NopLogger(), MaxRetries: 1, } @@ -1128,9 +1124,9 @@ func TestMultiTurnWithToolCallsCache(t *testing.T) { calcTool := &recordingTool{name: "calculate", output: "42"} tools.RegisterTool(calcTool) - var usageEvents []aop.Event - handler := func(e aop.Event) { - if e.Type == aop.TypeUsage { + var usageEvents []*aop.Event + handler := func(e *aop.Event) { + if eventKind(e) == "usage" { usageEvents = append(usageEvents, e) } } @@ -1141,7 +1137,7 @@ func TestMultiTurnWithToolCallsCache(t *testing.T) { Model: cfg.Model, SystemPrompt: systemPrompt, CacheRetention: CacheShort, - Bus: testBus(func(e aop.Event) { handler(e) }), + Bus: testBus(func(e *aop.Event) { handler(e) }), Logger: telemetry.NopLogger(), MaxRetries: 1, } @@ -1185,10 +1181,9 @@ func TestMultiTurnWithToolCallsCache(t *testing.T) { } for i, e := range usageEvents { - var data aop.UsageData - if json.Unmarshal(e.Data, &data) == nil { + if data := e.GetUsage(); data != nil { t.Logf("Usage event %d: prompt=%d cache_read=%d cache_write=%d", - i, data.InputTokens, data.CacheReadTokens, data.CacheWriteTokens) + i, data.InputTokens, data.Detail["cache_read"], data.Detail["cache_write"]) } } } diff --git a/agent/aop_emit.go b/agent/aop_emit.go index 173717f8..4a38ec76 100644 --- a/agent/aop_emit.go +++ b/agent/aop_emit.go @@ -1,228 +1,234 @@ package agent import ( - "encoding/json" + "encoding/base64" "fmt" "sync/atomic" - "time" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/aop/x/delegation" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + partText = "text" + partReasoning = "reasoning" + statusTokenBudgetWarning = "token_budget_warning" + statusLLMRequest = "llm_request" + aopStatusNamespace = "aop" ) -// aopEmitter is the agent kernel's single event-emission path. Every event -// leaves through it so seq numbering, message_id allocation, and session -// tagging stay consistent per session. Safe for concurrent use. type aopEmitter struct { - bus *eventbus.Bus[aop.Event] + bus *eventbus.Bus[*aop.Event] agentName string sessionID string turnID string parentSessionID string parentToolCallID string - delegation *delegation.DelegationDetail + delegation *ext.DelegationDetail state *emitState } type emitState struct { - seq atomic.Int64 + seq atomic.Uint64 messageSeq atomic.Int64 } -func newAOPEmitter(bus *eventbus.Bus[aop.Event], agentName, sessionID, parentSessionID, parentToolCallID string, detail *delegation.DelegationDetail, msgCounter int64) *aopEmitter { +func newAOPEmitter(bus *eventbus.Bus[*aop.Event], agentName, sessionID, parentSessionID, parentToolCallID string, detail *ext.DelegationDetail, msgCounter int64) *aopEmitter { em := &aopEmitter{ - bus: bus, - agentName: agentName, - sessionID: sessionID, - parentSessionID: parentSessionID, - parentToolCallID: parentToolCallID, - delegation: detail, - state: &emitState{}, + bus: bus, agentName: agentName, sessionID: sessionID, + parentSessionID: parentSessionID, parentToolCallID: parentToolCallID, + delegation: detail, state: &emitState{}, } em.state.messageSeq.Store(msgCounter) return em } func (e *aopEmitter) turn(turnID string) *aopEmitter { - return &aopEmitter{bus: e.bus, agentName: e.agentName, sessionID: e.sessionID, turnID: turnID, parentSessionID: e.parentSessionID, parentToolCallID: e.parentToolCallID, delegation: e.delegation, state: e.state} -} - -func (e *aopEmitter) event(typ string, data any) aop.Event { - raw, err := json.Marshal(data) - if err != nil { - raw, _ = json.Marshal(map[string]string{"marshal_error": err.Error()}) - } - return aop.Event{ - Type: typ, - TS: time.Now().UTC().Format(time.RFC3339Nano), - SessionID: e.sessionID, - TurnID: e.turnID, - Agent: e.agentName, - Seq: int(e.state.seq.Add(1)), - Data: raw, + return &aopEmitter{ + bus: e.bus, agentName: e.agentName, sessionID: e.sessionID, turnID: turnID, + parentSessionID: e.parentSessionID, parentToolCallID: e.parentToolCallID, + delegation: e.delegation, state: e.state, } - } -func (e *aopEmitter) emit(typ string, data any) { - ev := e.event(typ, data) - e.bus.Emit(ev) +func (e *aopEmitter) emit(event *aop.Event) { + seq := e.state.seq.Add(1) + event.Id = fmt.Sprintf("e-%d", seq) + event.EmittedAt = timestamppb.Now() + event.SessionId = e.sessionID + event.TurnId = e.turnID + event.Emitter = e.agentName + event.Seq = seq + e.bus.Emit(event) } -func (e *aopEmitter) emitWithExt(typ string, data any, namespace string, ext any) { - ev := e.event(typ, data) - if err := aop.SetExt(&ev, namespace, ext); err != nil { - return +func (e *aopEmitter) emitWithExt(event *aop.Event, namespace string, value proto.Message) { + if err := aop.SetProtoExtension(event, namespace, value); err == nil { + e.emit(event) } - e.bus.Emit(ev) } func (e *aopEmitter) allocMessageID() string { return fmt.Sprintf("m-%d", e.state.messageSeq.Add(1)) } -func (e *aopEmitter) messageCounter() int64 { - return e.state.messageSeq.Load() -} +func (e *aopEmitter) messageCounter() int64 { return e.state.messageSeq.Load() } func (e *aopEmitter) sessionStart(model string) { - data := aop.SessionStartData{ - Model: model, - ParentSessionID: e.parentSessionID, - ParentToolCallID: e.parentToolCallID, - } + event := &aop.Event{Payload: &aop.Event_SessionStarted{SessionStarted: &aop.SessionStarted{ + Model: model, ParentSessionId: e.parentSessionID, ParentToolCallId: e.parentToolCallID, + }}} if e.delegation != nil { - e.emitWithExt(aop.TypeSessionStart, data, delegation.NS, *e.delegation) + e.emitWithExt(event, ext.DelegationNamespace, e.delegation) return } - e.emit(aop.TypeSessionStart, data) + e.emit(event) } func (e *aopEmitter) sessionEnd(reason string) { - e.emit(aop.TypeSessionEnd, aop.SessionEndData{Reason: reason}) + e.emit(&aop.Event{Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: reason}}}) } func (e *aopEmitter) turnStart() { - e.emit(aop.TypeTurnStart, aop.TurnStartData{}) + e.emit(&aop.Event{Payload: &aop.Event_TurnStarted{TurnStarted: &aop.TurnStarted{}}}) } func (e *aopEmitter) turnEnd(stop StopReason, totalUsage Usage, contextTokens int, runErr error) { - data := aop.TurnEndData{Stop: string(stop), Usage: usageData(totalUsage), ContextTokens: contextTokens} + ended := &aop.TurnEnded{StopReason: string(stop), Usage: usageData(totalUsage), ContextTokens: uint64(max(contextTokens, 0))} if runErr != nil { - data.Error = runErr.Error() + ended.Error = &aop.ProtocolError{Message: runErr.Error()} } - e.emit(aop.TypeTurnEnd, data) + e.emit(&aop.Event{Payload: &aop.Event_TurnEnded{TurnEnded: ended}}) } -// message emits a complete message event, allocating a fresh message_id. -// Returns the allocated id. -func (e *aopEmitter) message(role string, parts []aop.MessagePart) string { +func (e *aopEmitter) message(role string, content []*aop.Content) string { id := e.allocMessageID() - e.messageWithID(id, role, parts) + e.messageWithID(id, role, content) return id } -// messageWithID emits a complete message event with a caller-chosen id — -// used when a streaming message's id was allocated before the retry loop so -// deltas and the final message share it across retries. -func (e *aopEmitter) messageWithID(id, role string, parts []aop.MessagePart) { - e.emit(aop.TypeMessage, aop.MessageData{MessageID: id, Role: role, Parts: parts}) +func (e *aopEmitter) messageWithID(id, role string, content []*aop.Content) { + e.messageWithIdentity(id, role, "", content) } -func (e *aopEmitter) messageDelta(messageID string, partIndex int, partType, delta string) { - e.emit(aop.TypeMessageDelta, aop.MessageDeltaData{ - MessageID: messageID, - PartIndex: partIndex, - PartType: partType, - Delta: delta, - }) +func (e *aopEmitter) messageWithIdentity(id, role, name string, content []*aop.Content) { + e.emit(&aop.Event{Payload: &aop.Event_Message{Message: &aop.Message{Id: id, Role: role, Name: name, Content: content}}}) +} + +func (e *aopEmitter) messageDelta(messageID string, contentIndex int, partType, delta string) { + messageDelta := &aop.MessageDelta{ + MessageId: messageID, ContentIndex: uint32(max(contentIndex, 0)), Operation: aop.DeltaOperation_DELTA_OPERATION_APPEND, + } + if partType == partReasoning { + messageDelta.Value = &aop.MessageDelta_Reasoning{Reasoning: delta} + } else { + messageDelta.Value = &aop.MessageDelta_Text{Text: delta} + } + e.emit(&aop.Event{Payload: &aop.Event_MessageDelta{MessageDelta: messageDelta}}) } func (e *aopEmitter) toolCall(toolCallID, toolName string, args any, workDir string) { - data := aop.ToolCallData{ - ToolCallID: toolCallID, - ToolName: toolName, - Args: args, - WorkDir: workDir, + arguments, err := aop.JSONValue(args) + if err != nil { + e.errorEvt(err, false) + return } + call := &aop.ToolCall{Id: toolCallID, Name: toolName, Kind: "function", Arguments: arguments, WorkingDirectory: workDir} + event := &aop.Event{Payload: &aop.Event_ToolCall{ToolCall: call}} if detail, ok := delegationFromToolCall(toolName, args); ok { - e.emitWithExt(aop.TypeToolCall, data, delegation.NS, detail) + e.emitWithExt(event, ext.DelegationNamespace, &detail) return } - e.emit(aop.TypeToolCall, data) + e.emit(event) } -func (e *aopEmitter) toolResult(toolCallID, toolName string, content, details any, terminate, isError bool, durationMs int) { - e.emit(aop.TypeToolResult, aop.ToolResultData{ - ToolCallID: toolCallID, - ToolName: toolName, - Content: content, - Details: details, - Terminate: terminate, - IsError: isError, - DurationMs: durationMs, - }) +func (e *aopEmitter) toolResult(toolCallID, toolName string, content []*aop.Content, details any, terminate, isError bool, durationMs int) { + detail, err := aop.JSONValue(details) + if err != nil { + e.errorEvt(err, false) + return + } + result := &aop.ToolResult{ + CallId: toolCallID, Name: toolName, Output: content, Detail: detail, + Terminate: terminate, IsError: isError, DurationMs: uint64(max(durationMs, 0)), + } + e.emit(&aop.Event{Payload: &aop.Event_ToolResult{ToolResult: result}}) } -func (e *aopEmitter) usage(u *Usage, model string) { - if u == nil { +func (e *aopEmitter) usage(usage *Usage, model string) { + if usage == nil { return } - e.emit(aop.TypeUsage, aop.UsageData{ - InputTokens: u.PromptTokens, - OutputTokens: u.CompletionTokens, - TotalTokens: u.TotalTokens, - CacheReadTokens: u.CacheReadTokens, - CacheWriteTokens: u.CacheWriteTokens, - Model: model, - }) + value := usageData(*usage) + value.Model = model + e.emit(&aop.Event{Payload: &aop.Event_Usage{Usage: value}}) } func (e *aopEmitter) errorEvt(err error, retryable bool) { - e.emit(aop.TypeError, aop.ErrorData{Message: err.Error(), Retryable: retryable}) + e.emit(&aop.Event{Payload: &aop.Event_Error{Error: &aop.ProtocolError{Message: err.Error(), Retryable: retryable}}}) +} + +func (e *aopEmitter) providerFrame(frame ProviderRawFrame) { + direction := aop.Direction_DIRECTION_UNSPECIFIED + if frame.Direction == "request" { + direction = aop.Direction_DIRECTION_REQUEST + } else if frame.Direction == "response" { + direction = aop.Direction_DIRECTION_RESPONSE + } + e.emit(&aop.Event{Payload: &aop.Event_ProviderFrame{ProviderFrame: &aop.ProviderFrame{ + Provider: frame.Provider, Protocol: frame.Protocol, EventType: frame.EventType, + Direction: direction, Transport: frame.Transport, Payload: frame.Payload, MediaType: frame.MediaType, + }}}) } -func (e *aopEmitter) status(state, namespace string, detail any) { - if detail == nil { - e.emit(aop.TypeStatus, aop.StatusData{State: state}) +func (e *aopEmitter) status(state, namespace string, detail proto.Message) { + event := &aop.Event{Payload: &aop.Event_Status{Status: &aop.Status{State: state}}} + if namespace != "" && detail != nil { + e.emitWithExt(event, namespace, detail) return } - e.emitWithExt(aop.TypeStatus, aop.StatusData{State: state}, namespace, detail) + e.emit(event) } -func usageData(u Usage) *aop.UsageData { - if u == (Usage{}) { +func usageData(usage Usage) *aop.TokenUsage { + if usage == (Usage{}) { return nil } - return &aop.UsageData{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens, TotalTokens: u.TotalTokens, CacheReadTokens: u.CacheReadTokens, CacheWriteTokens: u.CacheWriteTokens} + return &aop.TokenUsage{ + InputTokens: uint64(max(usage.PromptTokens, 0)), OutputTokens: uint64(max(usage.CompletionTokens, 0)), + TotalTokens: uint64(max(usage.TotalTokens, 0)), Detail: map[string]uint64{ + "cache_read": uint64(max(usage.CacheReadTokens, 0)), "cache_write": uint64(max(usage.CacheWriteTokens, 0)), + }, + } } -// messagePartsFromChat flattens a ChatMessage into AOP parts for echo/persist. -func messagePartsFromChat(msg ChatMessage) []aop.MessagePart { - var parts []aop.MessagePart - if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { - parts = append(parts, aop.MessagePart{Type: aop.PartReasoning, Text: *msg.ReasoningContent}) +func messagePartsFromChat(message ChatMessage) []*aop.Content { + var content []*aop.Content + if message.ReasoningContent != nil && *message.ReasoningContent != "" { + content = append(content, aop.Reasoning(*message.ReasoningContent)) } - if msg.Content != nil && *msg.Content != "" { - parts = append(parts, aop.MessagePart{Type: aop.PartText, Text: *msg.Content}) + if message.Content != nil && *message.Content != "" { + content = append(content, aop.Text(*message.Content)) } - for _, p := range msg.ContentParts { - switch p.Type { + for _, part := range message.ContentParts { + switch part.Type { case "text": - if p.Text != "" { - parts = append(parts, aop.MessagePart{Type: aop.PartText, Text: p.Text}) + if part.Text != "" { + content = append(content, aop.Text(part.Text)) } case "image_url": - if p.ImageURL == nil { + if part.ImageURL == nil { continue } - mediaType, base64Data := ParseDataURI(p.ImageURL.URL) - parts = append(parts, aop.MessagePart{ - Type: aop.PartImage, - Image: &aop.ImageSource{Base64: base64Data, MediaType: mediaType}, - }) + mediaType, base64Data := ParseDataURI(part.ImageURL.URL) + data, err := base64.StdEncoding.DecodeString(base64Data) + if err == nil { + content = append(content, aop.Image(mediaType, data)) + } } } - return parts + return content } diff --git a/agent/aop_emit_test.go b/agent/aop_emit_test.go index d858286a..dd096deb 100644 --- a/agent/aop_emit_test.go +++ b/agent/aop_emit_test.go @@ -6,26 +6,28 @@ import ( "sync/atomic" "testing" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + "github.com/chainreactors/aiscan/core/eventbus" ) // streamEventCollector records message/message.delta events from the bus. type streamEventCollector struct { mu sync.Mutex - deltas []aop.MessageDeltaData - messages []aop.MessageData + deltas []*aop.MessageDelta + messages []*aop.Message } -func (c *streamEventCollector) handler(event aop.Event) { - switch event.Type { - case aop.TypeMessageDelta: - if d, err := aop.DecodeData[aop.MessageDeltaData](event); err == nil { +func (c *streamEventCollector) handler(event *aop.Event) { + switch eventKind(event) { + case "message.delta": + if d := event.GetMessageDelta(); d != nil { c.mu.Lock() c.deltas = append(c.deltas, d) c.mu.Unlock() } - case aop.TypeMessage: - if d, err := aop.DecodeData[aop.MessageData](event); err == nil { + case "message": + if d := event.GetMessage(); d != nil { c.mu.Lock() c.messages = append(c.messages, d) c.mu.Unlock() @@ -33,10 +35,10 @@ func (c *streamEventCollector) handler(event aop.Event) { } } -func (c *streamEventCollector) assistantMessages() []aop.MessageData { +func (c *streamEventCollector) assistantMessages() []*aop.Message { c.mu.Lock() defer c.mu.Unlock() - var out []aop.MessageData + var out []*aop.Message for _, m := range c.messages { if m.Role == "assistant" { out = append(out, m) @@ -71,27 +73,27 @@ func TestStreamDeltasAndFinalMessageShareMessageID(t *testing.T) { } collector.mu.Lock() - deltas := append([]aop.MessageDeltaData(nil), collector.deltas...) + deltas := append([]*aop.MessageDelta(nil), collector.deltas...) collector.mu.Unlock() if len(deltas) != 4 { t.Fatalf("deltas = %d, want 4", len(deltas)) } - messageID := deltas[0].MessageID + messageID := deltas[0].MessageId if messageID == "" { t.Fatal("delta has empty message_id") } for _, d := range deltas { - if d.MessageID != messageID { - t.Fatalf("delta message_id = %q, want stable %q", d.MessageID, messageID) + if d.MessageId != messageID { + t.Fatalf("delta message_id = %q, want stable %q", d.MessageId, messageID) } - switch d.PartType { - case aop.PartReasoning: - if d.PartIndex != 0 { - t.Fatalf("reasoning delta part_index = %d, want 0", d.PartIndex) + switch d.Value.(type) { + case *aop.MessageDelta_Reasoning: + if d.ContentIndex != 0 { + t.Fatalf("reasoning delta content_index = %d, want 0", d.ContentIndex) } - case aop.PartText: - if d.PartIndex != 1 { - t.Fatalf("text delta part_index = %d, want 1 (reasoning present)", d.PartIndex) + case *aop.MessageDelta_Text: + if d.ContentIndex != 1 { + t.Fatalf("text delta content_index = %d, want 1 (reasoning present)", d.ContentIndex) } } } @@ -100,13 +102,13 @@ func TestStreamDeltasAndFinalMessageShareMessageID(t *testing.T) { if len(finals) != 1 { t.Fatalf("assistant messages = %d, want 1", len(finals)) } - if finals[0].MessageID != messageID { - t.Fatalf("final message id = %q, want delta id %q", finals[0].MessageID, messageID) + if finals[0].Id != messageID { + t.Fatalf("final message id = %q, want delta id %q", finals[0].Id, messageID) } - if len(finals[0].Parts) != 2 || - finals[0].Parts[0].Type != aop.PartReasoning || finals[0].Parts[0].Text != "think-hard" || - finals[0].Parts[1].Type != aop.PartText || finals[0].Parts[1].Text != "ans-wer" { - t.Fatalf("final parts = %+v", finals[0].Parts) + if len(finals[0].Content) != 2 || + finals[0].Content[0].GetReasoning().GetText() != "think-hard" || + finals[0].Content[1].GetText().GetText() != "ans-wer" { + t.Fatalf("final content = %+v", finals[0].Content) } } @@ -160,22 +162,68 @@ func TestMessageIDStableAcrossStreamRetry(t *testing.T) { } collector.mu.Lock() - deltas := append([]aop.MessageDeltaData(nil), collector.deltas...) + deltas := append([]*aop.MessageDelta(nil), collector.deltas...) collector.mu.Unlock() if len(deltas) == 0 { t.Fatal("no deltas recorded") } - messageID := deltas[0].MessageID + messageID := deltas[0].MessageId for _, d := range deltas { - if d.MessageID != messageID { - t.Fatalf("delta id %q differs from %q after retry", d.MessageID, messageID) + if d.MessageId != messageID { + t.Fatalf("delta id %q differs from %q after retry", d.MessageId, messageID) } } finals := collector.assistantMessages() if len(finals) != 1 { t.Fatalf("assistant messages = %d, want exactly 1 across retries", len(finals)) } - if finals[0].MessageID != messageID { - t.Fatalf("final message id = %q, want %q", finals[0].MessageID, messageID) + if finals[0].Id != messageID { + t.Fatalf("final message id = %q, want %q", finals[0].Id, messageID) + } +} + +func TestStatusPreservesTypedExtensionNamespace(t *testing.T) { + bus := eventbus.New[*aop.Event]() + var emitted *aop.Event + bus.Subscribe(func(event *aop.Event) { emitted = event }) + emitter := newAOPEmitter(bus, "agent-1", "session-1", "", "", nil, 0) + emitter.status(ext.CompactStateEnd, ext.CompactNamespace, &ext.CompactDetail{ + TokensBefore: 1000, + TokensAfter: 400, + KeptMessages: 8, + }) + + if emitted == nil || emitted.GetStatus().GetState() != ext.CompactStateEnd { + t.Fatalf("status event = %+v", emitted) + } + detail, ok, err := ext.GetCompactDetail(emitted) + if err != nil || !ok || detail.TokensBefore != 1000 || detail.TokensAfter != 400 || detail.KeptMessages != 8 { + t.Fatalf("compact detail = %+v, ok=%v, err=%v", detail, ok, err) + } + if emitted.GetStatus().Detail != nil { + t.Fatal("status detail must have one canonical representation in event.extensions") + } +} + +func TestToolResultEmitterPreservesAllProtocolFields(t *testing.T) { + bus := eventbus.New[*aop.Event]() + var emitted *aop.Event + bus.Subscribe(func(event *aop.Event) { emitted = event }) + emitter := newAOPEmitter(bus, "agent-1", "session-1", "", "", nil, 0).turn("turn-1") + emitter.toolResult("call-1", "scan", []*aop.Content{ + aop.Text("done"), + aop.Image("image/png", []byte("image")), + }, map[string]any{"ports": 3}, true, true, 12) + + result := emitted.GetToolResult() + if result == nil || result.CallId != "call-1" || result.Name != "scan" || !result.Terminate || !result.IsError || result.DurationMs != 12 { + t.Fatalf("tool result = %+v", result) + } + if len(result.Output) != 2 || result.Output[0].GetText().GetText() != "done" || string(result.Output[1].GetMedia().GetResource().GetData()) != "image" { + t.Fatalf("tool result output = %+v", result.Output) + } + detail, err := aop.DecodeJSON[map[string]int](result.Detail) + if err != nil || detail["ports"] != 3 { + t.Fatalf("tool result detail = %+v, err=%v", detail, err) } } diff --git a/agent/compact.go b/agent/compact.go index e1b6786b..0c10e70b 100644 --- a/agent/compact.go +++ b/agent/compact.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/truncate" ) @@ -90,10 +90,10 @@ func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, } a.mu.Unlock() - em.status(xcompact.StateStart, "", nil) + em.status(ext.CompactStateStart, "", nil) newMsgs, result, err := compactHistory(ctx, cfg, msgs) if err != nil { - em.status(xcompact.StateError, xcompact.NS, xcompact.Detail{Error: err.Error()}) + em.status(ext.CompactStateError, ext.CompactNamespace, &ext.CompactDetail{Error: err.Error()}) return nil, err } @@ -101,7 +101,7 @@ func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, a.state.Messages = newMsgs a.mu.Unlock() - em.status(xcompact.StateEnd, xcompact.NS, xcompact.Detail{TokensBefore: result.TokensBefore, TokensAfter: result.TokensAfter, KeptMessages: result.KeptMessages}) + em.status(ext.CompactStateEnd, ext.CompactNamespace, &ext.CompactDetail{TokensBefore: uint64(max(result.TokensBefore, 0)), TokensAfter: uint64(max(result.TokensAfter, 0)), KeptMessages: uint64(max(result.KeptMessages, 0))}) return result, nil } diff --git a/agent/evaluator/loop.go b/agent/evaluator/loop.go index c41febb9..129b82e8 100644 --- a/agent/evaluator/loop.go +++ b/agent/evaluator/loop.go @@ -7,7 +7,7 @@ import ( "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/agent/provider" - xeval "github.com/chainreactors/aiscan/core/aop/x/eval" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/telemetry" ) @@ -30,7 +30,7 @@ func NewLoopConfig(p provider.Provider, model string, logger telemetry.Logger, g // NewLoopConfigWithInput preserves transport controls and multimodal parts on // the first evaluation round. Boundaries that already published the user input -// use this constructor so NoEcho is not lost when entering Goal mode. +// use this constructor so the original multimodal input is preserved in Goal mode. func NewLoopConfigWithInput(p provider.Provider, model string, logger telemetry.Logger, input agent.Input, criteria string, maxRounds int) EvalLoopConfig { return newLoopConfig(p, model, logger, strings.TrimSpace(input.Text()), input, criteria, maxRounds) } @@ -91,7 +91,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . return finish(result), lastVerdict, result.Err } - a.EmitStatus(xeval.StateStart, xeval.NS, xeval.Detail{Round: round, MaxRounds: cfg.MaxEvalRounds}, cfg.TurnID) + a.EmitStatus(ext.EvalStateStart, ext.EvalNamespace, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0))}, cfg.TurnID) verdict, evalErr := cfg.Evaluator.Evaluate( ctx, cfg.Goal, cfg.Criteria, @@ -100,7 +100,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . if evalErr != nil { cfg.Evaluator.cfg.Logger.Warnf("evaluate error (round %d): %s", round, evalErr) - a.EmitStatus(xeval.StateError, xeval.NS, xeval.Detail{Round: round, MaxRounds: cfg.MaxEvalRounds, Error: evalErr.Error()}, cfg.TurnID) + a.EmitStatus(ext.EvalStateError, ext.EvalNamespace, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0)), Error: evalErr.Error()}, cfg.TurnID) if round == cfg.MaxEvalRounds { return finish(result), lastVerdict, evalErr } @@ -110,7 +110,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . } lastVerdict = verdict - a.EmitStatus(xeval.StateEnd, xeval.NS, xeval.Detail{Round: round, MaxRounds: cfg.MaxEvalRounds, Pass: verdict.Pass, Reason: verdict.Reason}, cfg.TurnID) + a.EmitStatus(ext.EvalStateEnd, ext.EvalNamespace, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0)), Pass: verdict.Pass, Reason: verdict.Reason}, cfg.TurnID) cfg.Evaluator.cfg.Logger.Importantf("evaluate round %d: pass=%v inherit_context=%v reason=%q", round, verdict.Pass, verdict.InheritContext, verdict.Reason) if verdict.Pass { diff --git a/agent/evaluator/loop_test.go b/agent/evaluator/loop_test.go index cd63ac0e..03b9e267 100644 --- a/agent/evaluator/loop_test.go +++ b/agent/evaluator/loop_test.go @@ -6,7 +6,7 @@ import ( "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/agent/provider" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" ) @@ -22,7 +22,7 @@ func (p *fixedProvider) ChatCompletion(_ context.Context, request *provider.Chat return p.response, nil } -func TestRunWithEvalPreservesInitialInputNoEcho(t *testing.T) { +func TestRunWithEvalPreservesInitialInputAndEmitsCanonicalUserMessage(t *testing.T) { agentProvider := &fixedProvider{response: &provider.ChatCompletionResponse{ Choices: []provider.Choice{{Message: provider.NewTextMessage("assistant", "done")}}, }} @@ -40,9 +40,9 @@ func TestRunWithEvalPreservesInitialInputNoEcho(t *testing.T) { }}}, }} - bus := eventbus.New[aop.Event]() - var events []aop.Event - bus.Subscribe(func(event aop.Event) { events = append(events, event) }) + bus := eventbus.New[*aop.Event]() + var events []*aop.Event + bus.Subscribe(func(event *aop.Event) { events = append(events, event) }) ag := agent.NewAgent(agent.Config{ Provider: agentProvider, Model: "test", @@ -54,7 +54,6 @@ func TestRunWithEvalPreservesInitialInputNoEcho(t *testing.T) { {Text: "inspect this"}, {Image: &agent.InputImage{Base64: "AA==", MediaType: "image/png"}}, }, - NoEcho: true, } result, verdict, err := RunWithEval(context.Background(), ag, @@ -66,18 +65,15 @@ func TestRunWithEvalPreservesInitialInputNoEcho(t *testing.T) { t.Fatalf("RunWithEval() result = %+v, verdict = %+v", result, verdict) } + var userMessages int for _, event := range events { - if event.Type != aop.TypeMessage { - continue - } - data, decodeErr := aop.DecodeData[aop.MessageData](event) - if decodeErr != nil { - t.Fatalf("decode message event: %v", decodeErr) - } - if data.Role == "user" { - t.Fatalf("NoEcho eval emitted user message: %+v", event) + if aop.Kind(event) == "message" && event.GetMessage().GetRole() == "user" { + userMessages++ } } + if userMessages != 1 { + t.Fatalf("canonical user messages = %d, want 1", userMessages) + } if agentProvider.request == nil { t.Fatal("agent provider received no request") diff --git a/agent/helpers_test.go b/agent/helpers_test.go index e2850e77..a1763d1e 100644 --- a/agent/helpers_test.go +++ b/agent/helpers_test.go @@ -11,15 +11,15 @@ import ( "testing" "github.com/chainreactors/aiscan/agent/inbox" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/skills" ) -func testBus(handler func(aop.Event)) *eventbus.Bus[aop.Event] { - b := eventbus.New[aop.Event]() +func testBus(handler func(*aop.Event)) *eventbus.Bus[*aop.Event] { + b := eventbus.New[*aop.Event]() if handler != nil { b.Subscribe(handler) } @@ -258,17 +258,19 @@ func containsEvent(events []string, want string) bool { return false } -func eventTypes(events []aop.Event) []string { +func eventTypes(events []*aop.Event) []string { out := make([]string, 0, len(events)) for _, event := range events { - out = append(out, event.Type) + out = append(out, aop.Kind(event)) } return out } -func lastEvent(events []aop.Event) aop.Event { +func eventKind(event *aop.Event) string { return aop.Kind(event) } + +func lastEvent(events []*aop.Event) *aop.Event { if len(events) == 0 { - return aop.Event{} + return nil } return events[len(events)-1] } diff --git a/agent/input.go b/agent/input.go index d07bfbc9..8262a62f 100644 --- a/agent/input.go +++ b/agent/input.go @@ -7,7 +7,7 @@ import ( "os" "strings" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" ) // maxInputImageBytes caps a single input image (20 MiB), matching common @@ -31,10 +31,10 @@ type InputPart struct { // Input is the agent's inbound unit. A text-only input becomes a plain user // message; inputs with images become a multimodal message. type Input struct { - Parts []InputPart - // NoEcho suppresses the user message echo event — set by boundaries - // (web) that already delivered/persisted the message themselves. - NoEcho bool + MessageID string + Role string + Name string + Parts []InputPart } func TextInput(text string) Input { @@ -43,21 +43,27 @@ func TextInput(text string) Input { // InputFromAOPMessage maps the protocol's typed message parts into the Agent's // provider input. Session.Run is the only runtime entry point that calls it. -func InputFromAOPMessage(data aop.MessageData) Input { - input := Input{} - for _, part := range data.Parts { - switch part.Type { - case aop.PartText: - input.Parts = append(input.Parts, InputPart{Text: part.Text}) - case aop.PartImage: - if part.Image == nil { +func InputFromAOPMessage(message *aop.Message) Input { + input := Input{MessageID: message.GetId(), Role: message.GetRole(), Name: message.GetName()} + if message == nil { + return input + } + for _, content := range message.Content { + switch value := content.Value.(type) { + case *aop.Content_Text: + input.Parts = append(input.Parts, InputPart{Text: value.Text.Text}) + case *aop.Content_Media: + if value.Media.Kind != "image" || value.Media.Resource == nil { continue } - input.Parts = append(input.Parts, InputPart{Image: &InputImage{ - Path: part.Image.Path, - Base64: part.Image.Base64, - MediaType: part.Image.MediaType, - }}) + image := &InputImage{MediaType: value.Media.Resource.MediaType} + switch source := value.Media.Resource.Source.(type) { + case *aop.Resource_Data: + image.Base64 = base64.StdEncoding.EncodeToString(source.Data) + case *aop.Resource_Uri: + image.Path = source.Uri + } + input.Parts = append(input.Parts, InputPart{Image: image}) } } return input @@ -81,6 +87,10 @@ func (in Input) Text() string { // chatMessage validates the input and converts it to an LLM message. func (in Input) chatMessage() (ChatMessage, error) { + role := in.Role + if role == "" { + role = "user" + } hasImage := false for _, p := range in.Parts { if p.Image != nil { @@ -89,7 +99,10 @@ func (in Input) chatMessage() (ChatMessage, error) { } } if !hasImage { - return NewTextMessage("user", in.Text()), nil + message := NewTextMessage(role, in.Text()) + message.AOPMessageID = in.MessageID + message.Name = in.Name + return message, nil } parts := make([]ContentPart, 0, len(in.Parts)) for _, p := range in.Parts { @@ -105,7 +118,10 @@ func (in Input) chatMessage() (ChatMessage, error) { } parts = append(parts, ImagePart(mediaType, data, "high")) } - return NewMultimodalMessage("user", parts), nil + message := NewMultimodalMessage(role, parts) + message.AOPMessageID = in.MessageID + message.Name = in.Name + return message, nil } // load resolves the image to (mediaType, base64Data), enforcing the size cap. diff --git a/agent/input_test.go b/agent/input_test.go index 1f60fac5..aa0d9332 100644 --- a/agent/input_test.go +++ b/agent/input_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" ) // pngBytes is a minimal PNG header so http.DetectContentType sniffs image/png. @@ -127,12 +127,12 @@ func TestInputImageEmptySource(t *testing.T) { } func TestInputFromAOPMessageMapsParts(t *testing.T) { - input := InputFromAOPMessage(aop.MessageData{ - MessageID: "m-1", - Role: "user", - Parts: []aop.MessagePart{ - {Type: aop.PartText, Text: "hi"}, - {Type: aop.PartImage, Image: &aop.ImageSource{Base64: "AAAA", MediaType: "image/png"}}, + input := InputFromAOPMessage(&aop.Message{ + Id: "m-1", + Role: "user", + Content: []*aop.Content{ + aop.Text("hi"), + aop.Image("image/png", []byte{0, 0, 0}), }, }) if len(input.Parts) != 2 || input.Parts[0].Text != "hi" || input.Parts[1].Image == nil { diff --git a/agent/loop.go b/agent/loop.go index ee756a6c..3b62143b 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/base64" "encoding/json" "fmt" "sort" @@ -10,8 +11,9 @@ import ( "time" "github.com/chainreactors/aiscan/agent/inbox" - "github.com/chainreactors/aiscan/core/aop" - xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" @@ -73,9 +75,12 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { } for _, cm := range inboxMsgs[i].ToChatMessages() { transcript.append(cm) - noEcho, _ := inboxMsgs[i].Meta["no_echo"].(bool) - if inboxMsgs[i].Origin == inbox.OriginUser && !noEcho { - em.message("user", messagePartsFromChat(cm)) + if inboxMsgs[i].Origin == inbox.OriginUser { + if cm.AOPMessageID != "" { + em.messageWithIdentity(cm.AOPMessageID, cm.Role, cm.Name, messagePartsFromChat(cm)) + } else { + em.message(cm.Role, messagePartsFromChat(cm)) + } } } } @@ -158,7 +163,9 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { return end(result, result.Err, StopReasonBudget) } if transcript.totalUsage.TotalTokens >= cfg.TokenBudget*DefaultTokenBudgetWarningPct/100 { - em.status(aop.StatusTokenBudgetWarning, aop.NSAOP, aop.BudgetWarning{ContextTokens: transcript.contextTokens, TokenBudget: cfg.TokenBudget}) + em.status(statusTokenBudgetWarning, aopStatusNamespace, &transport.BudgetWarning{ + ContextTokens: uint64(max(transcript.contextTokens, 0)), TokenBudget: uint64(max(cfg.TokenBudget, 0)), + }) cfg.Logger.Warnf("token budget warning: %d/%d (80%%)", transcript.totalUsage.TotalTokens, cfg.TokenBudget) } } @@ -280,7 +287,7 @@ func runAutoCompaction(ctx context.Context, cfg Config, em *aopEmitter, transcri return false, nil } - em.status(xcompact.StateStart, "", nil) + em.status(ext.CompactStateStart, "", nil) newMessages, result, err := compactHistory(ctx, CompactConfig{ Provider: cfg.Provider, Model: cfg.Model, @@ -289,14 +296,14 @@ func runAutoCompaction(ctx context.Context, cfg Config, em *aopEmitter, transcri MaxTokens: cfg.MaxTokens, }, transcript.messages) if err != nil { - em.status(xcompact.StateError, xcompact.NS, xcompact.Detail{Error: err.Error()}) + em.status(ext.CompactStateError, ext.CompactNamespace, &ext.CompactDetail{Error: err.Error()}) return false, err } transcript.replace(newMessages, result.TokensAfter) - em.status(xcompact.StateEnd, xcompact.NS, xcompact.Detail{ - TokensBefore: result.TokensBefore, - TokensAfter: result.TokensAfter, - KeptMessages: result.KeptMessages, + em.status(ext.CompactStateEnd, ext.CompactNamespace, &ext.CompactDetail{ + TokensBefore: uint64(max(result.TokensBefore, 0)), + TokensAfter: uint64(max(result.TokensAfter, 0)), + KeptMessages: uint64(max(result.KeptMessages, 0)), }) cfg.Logger.Importantf("context compacted reason=%s tokens=%d->%d kept_messages=%d", reason, result.TokensBefore, result.TokensAfter, result.KeptMessages) @@ -541,13 +548,21 @@ func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc T return afterToolCall(toolCtx, cfg, assistantMsg, tc, execution, time.Since(startedAt).Milliseconds()) } -// eventContent returns the AOP tool.result payload: a plain string, or the -// {content, images} variant when the tool returned images. -func (e toolExecution) eventContent() any { - if e.fullResult != nil { - return aop.ToolResultContentFromResult(*e.fullResult, e.eventResultText()) +func (e toolExecution) eventContent() []*aop.Content { + content := []*aop.Content{aop.Text(e.eventResultText())} + if e.fullResult == nil { + return content } - return e.eventResultText() + for _, block := range e.fullResult.Content { + if block.Type != "image" { + continue + } + data, err := base64.StdEncoding.DecodeString(block.Base64Data) + if err == nil { + content = append(content, aop.Image(block.MimeType, data)) + } + } + return content } func (e toolExecution) eventResultText() string { diff --git a/agent/loop_test.go b/agent/loop_test.go index 45decb4d..dae7e2bd 100644 --- a/agent/loop_test.go +++ b/agent/loop_test.go @@ -10,7 +10,7 @@ import ( "github.com/chainreactors/aiscan/agent/inbox" "github.com/chainreactors/aiscan/agent/tmux" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/pkg/commands" @@ -41,8 +41,8 @@ func TestRunEmitsTurnEndAfterToolResults(t *testing.T) { Provider: llm, Tools: tools, Model: "test", - Bus: testBus(func(event aop.Event) { - events = append(events, event.Type) + Bus: testBus(func(event *aop.Event) { + events = append(events, eventKind(event)) }), })).Run(context.Background(), TextInput("use tool")) if err != nil { @@ -53,12 +53,12 @@ func TestRunEmitsTurnEndAfterToolResults(t *testing.T) { } want := []string{ - aop.TypeMessage, - aop.TypeStatus, - aop.TypeToolCall, - aop.TypeToolResult, - aop.TypeStatus, - aop.TypeMessage, + "message", + "status", + "tool.call", + "tool.result", + "status", + "message", } if !reflect.DeepEqual(events, want) { t.Fatalf("events = %#v, want %#v", events, want) @@ -153,17 +153,17 @@ func TestStreamingProviderEmitsMessageUpdates(t *testing.T) { Tools: tools, Model: "test", Stream: true, - Bus: testBus(func(event aop.Event) { - if event.Type != aop.TypeMessageDelta { + Bus: testBus(func(event *aop.Event) { + if eventKind(event) != "message.delta" { return } - data, err := aop.DecodeData[aop.MessageDeltaData](event) - if err != nil { + data := event.GetMessageDelta() + if data == nil { return } updates++ - if data.PartType == aop.PartText { - contentDeltas = append(contentDeltas, data.Delta) + if _, ok := data.Value.(*aop.MessageDelta_Text); ok { + contentDeltas = append(contentDeltas, data.GetText()) } }), })).Run(context.Background(), TextInput("stream")) @@ -196,18 +196,18 @@ func TestStreamingMessageUpdateCarriesUsage(t *testing.T) { Tools: tools, Model: "test", Stream: true, - Bus: testBus(func(event aop.Event) { - if event.Type != aop.TypeUsage { + Bus: testBus(func(event *aop.Event) { + if eventKind(event) != "usage" { return } - data, err := aop.DecodeData[aop.UsageData](event) - if err != nil { + data := event.GetUsage() + if data == nil { return } updateUsage = &Usage{ - PromptTokens: data.InputTokens, - CompletionTokens: data.OutputTokens, - TotalTokens: data.TotalTokens, + PromptTokens: int(data.InputTokens), + CompletionTokens: int(data.OutputTokens), + TotalTokens: int(data.TotalTokens), } }), })).Run(context.Background(), TextInput("stream")) @@ -238,8 +238,8 @@ func TestStatefulAgentTracksStreamingMessage(t *testing.T) { Tools: tools, Model: "test", Stream: true, - Bus: testBus(func(event aop.Event) { - if event.Type == aop.TypeMessageDelta { + Bus: testBus(func(event *aop.Event) { + if eventKind(event) == "message.delta" { sawUpdate = true } }), @@ -574,12 +574,12 @@ func TestTokenBudgetWarning(t *testing.T) { Tools: tools, Model: "test", TokenBudget: 1000, - Bus: testBus(func(event aop.Event) { - if event.Type != aop.TypeStatus { + Bus: testBus(func(event *aop.Event) { + if eventKind(event) != "status" { return } - data, err := aop.DecodeData[aop.StatusData](event) - if err == nil && data.State == aop.StatusTokenBudgetWarning { + data := event.GetStatus() + if data != nil && data.State == statusTokenBudgetWarning { sawWarning = true } }), @@ -765,17 +765,16 @@ func TestTurnEndEventCarriesUsage(t *testing.T) { }, } - var turnEndUsage *aop.UsageData + var turnEndUsage *aop.TokenUsage _, err := (NewAgent(Config{ Provider: llm, Tools: tools, Model: "test", - Bus: testBus(func(event aop.Event) { - switch event.Type { - case aop.TypeUsage: - if data, err := aop.DecodeData[aop.UsageData](event); err == nil { - u := data - turnEndUsage = &u + Bus: testBus(func(event *aop.Event) { + switch eventKind(event) { + case "usage": + if data := event.GetUsage(); data != nil { + turnEndUsage = data } } }), @@ -1228,14 +1227,13 @@ func TestEventCarriesCacheUsage(t *testing.T) { }, } - var captured *aop.UsageData - handler := func(e aop.Event) { - if e.Type != aop.TypeUsage { + var captured *aop.TokenUsage + handler := func(e *aop.Event) { + if eventKind(e) != "usage" { return } - if data, err := aop.DecodeData[aop.UsageData](e); err == nil { - u := data - captured = &u + if data := e.GetUsage(); data != nil { + captured = data } } @@ -1244,7 +1242,7 @@ func TestEventCarriesCacheUsage(t *testing.T) { Tools: commands.NewRegistry(), Model: "test", SystemPrompt: "sys", - Bus: testBus(func(e aop.Event) { handler(e) }), + Bus: testBus(func(e *aop.Event) { handler(e) }), Logger: telemetry.NopLogger(), })).Run(context.Background(), TextInput("test")) if err != nil { @@ -1254,11 +1252,11 @@ func TestEventCarriesCacheUsage(t *testing.T) { if captured == nil { t.Fatal("usage event missing") } - if captured.CacheReadTokens != 60 { - t.Errorf("usage CacheReadTokens = %d, want 60", captured.CacheReadTokens) + if captured.Detail["cache_read"] != 60 { + t.Errorf("usage cache_read = %d, want 60", captured.Detail["cache_read"]) } - if captured.CacheWriteTokens != 20 { - t.Errorf("usage CacheWriteTokens = %d, want 20", captured.CacheWriteTokens) + if captured.Detail["cache_write"] != 20 { + t.Errorf("usage cache_write = %d, want 20", captured.Detail["cache_write"]) } - fmt.Printf("Event carries cache usage: read=%d write=%d\n", captured.CacheReadTokens, captured.CacheWriteTokens) + fmt.Printf("Event carries cache usage: read=%d write=%d\n", captured.Detail["cache_read"], captured.Detail["cache_write"]) } diff --git a/agent/probe/llm.go b/agent/probe/llm.go index c87cb528..63a808de 100644 --- a/agent/probe/llm.go +++ b/agent/probe/llm.go @@ -11,7 +11,7 @@ import ( // LLMProbeRequest carries the connection parameters the user wants to verify // or use for model enumeration. It mirrors the LLM section of -// webproto.DistributeConfig. An empty APIKey means "use the key already stored +// config.DistributeConfig. An empty APIKey means "use the key already stored // in the config" (matching the settings UI where a configured key is left blank // to keep it unchanged). Model is only required for TestLLM; ListLLMModels // ignores it. diff --git a/agent/provider/anthropic.go b/agent/provider/anthropic.go index 2910ee45..9e1c5adb 100644 --- a/agent/provider/anthropic.go +++ b/agent/provider/anthropic.go @@ -57,13 +57,16 @@ func (p *AnthropicProvider) ChatCompletion(ctx context.Context, req *ChatComplet if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } + captureFrame(ctx, RawFrame{Provider: p.Name(), Protocol: ProviderAnthropic, Direction: "request", Transport: "http", Payload: bodyBytes, MediaType: "application/json"}) data, err := (&apiRequest{client: p.client, timeout: timeoutFromConfig(p.config.Timeout)}).do( ctx, "POST", p.completionEndpoint(), bodyBytes, p.setAuthHeaders, ) if err != nil { + captureAPIErrorFrame(ctx, p.Name(), ProviderAnthropic, err) return nil, hint404(err, p.completionEndpoint(), "OpenAI", "openai") } + captureFrame(ctx, RawFrame{Provider: p.Name(), Protocol: ProviderAnthropic, Direction: "response", Transport: "http", Payload: data, MediaType: "application/json"}) result, err := parseAnthropicResponse(data) if err != nil { @@ -92,7 +95,7 @@ func (p *AnthropicProvider) ChatCompletionStream(ctx context.Context, req *ChatC parser := &anthropicStreamParser{} events, err := streamSSE(ctx, p.client, timeoutFromConfig(p.config.Timeout), - p.completionEndpoint(), bodyBytes, p.setAuthHeaders, + p.completionEndpoint(), bodyBytes, p.setAuthHeaders, p.Name(), ProviderAnthropic, false, parser.parse, ) diff --git a/agent/provider/frame.go b/agent/provider/frame.go new file mode 100644 index 00000000..0ddea136 --- /dev/null +++ b/agent/provider/frame.go @@ -0,0 +1,31 @@ +package provider + +import "context" + +type RawFrame struct { + Provider string + Protocol string + EventType string + Direction string + Transport string + Payload []byte + MediaType string +} + +type frameObserverKey struct{} + +func WithFrameObserver(ctx context.Context, observer func(RawFrame)) context.Context { + if observer == nil { + return ctx + } + return context.WithValue(ctx, frameObserverKey{}, observer) +} + +func captureFrame(ctx context.Context, frame RawFrame) { + observer, _ := ctx.Value(frameObserverKey{}).(func(RawFrame)) + if observer == nil { + return + } + frame.Payload = append([]byte(nil), frame.Payload...) + observer(frame) +} diff --git a/agent/provider/http.go b/agent/provider/http.go index f3bd7947..d2a86262 100644 --- a/agent/provider/http.go +++ b/agent/provider/http.go @@ -118,9 +118,12 @@ func streamSSE( endpoint string, body []byte, setHeaders func(*http.Request), + providerName string, + protocol string, acceptDoneMarker bool, parse func(eventType string, data []byte) (ChatCompletionStreamEvent, error), ) (<-chan ChatCompletionStreamEvent, error) { + captureFrame(ctx, RawFrame{Provider: providerName, Protocol: protocol, Direction: "request", Transport: "http", Payload: body, MediaType: "application/json"}) reqCtx, reqCancel := context.WithCancel(ctx) httpReq, err := http.NewRequestWithContext(reqCtx, "POST", endpoint, bytes.NewReader(body)) @@ -147,6 +150,7 @@ func streamSSE( if readErr != nil { return nil, wrapReadError(ctx, timedOut, timeout, "read response", readErr) } + captureFrame(ctx, RawFrame{Provider: providerName, Protocol: protocol, EventType: "error", Direction: "response", Transport: "sse", Payload: respBody, MediaType: "application/json"}) return nil, &APIError{StatusCode: resp.StatusCode, Message: string(respBody), Header: resp.Header.Clone()} } @@ -182,7 +186,14 @@ func streamSSE( if !strings.HasPrefix(line, "data:") { continue } - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + rawLine := scanner.Bytes() + colon := bytes.Index(rawLine, []byte("data:")) + rawData := rawLine[colon+len("data:"):] + if len(rawData) > 0 && rawData[0] == ' ' { + rawData = rawData[1:] + } + captureFrame(ctx, RawFrame{Provider: providerName, Protocol: protocol, EventType: sseEvent, Direction: "response", Transport: "sse", Payload: rawData, MediaType: "application/json"}) + data := strings.TrimSpace(string(rawData)) if data == "[DONE]" { if !acceptDoneMarker { sseSend(ctx, events, ChatCompletionStreamEvent{Err: ErrStreamIncomplete}) @@ -232,6 +243,17 @@ func streamSSE( return events, nil } +func captureAPIErrorFrame(ctx context.Context, providerName, protocol string, err error) { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return + } + captureFrame(ctx, RawFrame{ + Provider: providerName, Protocol: protocol, EventType: "error", Direction: "response", + Transport: "http", Payload: []byte(apiErr.Message), MediaType: "application/json", + }) +} + func sseSend(ctx context.Context, ch chan<- ChatCompletionStreamEvent, event ChatCompletionStreamEvent) { select { case ch <- event: diff --git a/agent/provider/openai.go b/agent/provider/openai.go index 8426febf..6baa23f3 100644 --- a/agent/provider/openai.go +++ b/agent/provider/openai.go @@ -51,13 +51,16 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *ChatCompletion if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } + captureFrame(ctx, RawFrame{Provider: p.Name(), Protocol: ProviderOpenAI, Direction: "request", Transport: "http", Payload: bodyBytes, MediaType: "application/json"}) data, err := (&apiRequest{client: p.client, timeout: timeoutFromConfig(p.config.Timeout)}).do( ctx, "POST", p.completionEndpoint(), bodyBytes, p.setAuthHeaders, ) if err != nil { + captureAPIErrorFrame(ctx, p.Name(), ProviderOpenAI, err) return nil, hint404(err, p.completionEndpoint(), "Anthropic", "anthropic") } + captureFrame(ctx, RawFrame{Provider: p.Name(), Protocol: ProviderOpenAI, Direction: "response", Transport: "http", Payload: data, MediaType: "application/json"}) var result ChatCompletionResponse if err := json.Unmarshal(data, &result); err != nil { @@ -84,7 +87,7 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *ChatComp } events, err := streamSSE(ctx, p.client, timeoutFromConfig(p.config.Timeout), - p.completionEndpoint(), bodyBytes, p.setAuthHeaders, + p.completionEndpoint(), bodyBytes, p.setAuthHeaders, p.Name(), ProviderOpenAI, true, func(_ string, data []byte) (ChatCompletionStreamEvent, error) { return parseOpenAIStreamChunk(data) diff --git a/agent/provider/types.go b/agent/provider/types.go index 47b9ad25..a7238102 100644 --- a/agent/provider/types.go +++ b/agent/provider/types.go @@ -41,6 +41,8 @@ func ImagePart(mimeType, base64Data, detail string) ContentPart { } type ChatMessage struct { + AOPMessageID string `json:"-"` + Name string `json:"name,omitempty"` Role string `json:"role"` Content *string `json:"content,omitempty"` ContentParts []ContentPart `json:"-"` diff --git a/agent/provider_frame_test.go b/agent/provider_frame_test.go new file mode 100644 index 00000000..78a62a11 --- /dev/null +++ b/agent/provider_frame_test.go @@ -0,0 +1,152 @@ +package agent + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + aop "github.com/chainreactors/aiscan/aop" +) + +func TestProviderFrameCapturePreservesExactBytesAndIsOptIn(t *testing.T) { + responseBody := []byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2},"x_unknown":{"nested":[1,true]}}`) + requests := make(chan []byte, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(responseBody) + })) + defer server.Close() + + newProvider := func() Provider { + provider, err := NewProvider(&ProviderConfig{ + Provider: "openai", BaseURL: server.URL + "/v1", APIKey: "secret", Model: "test", Timeout: 5, + }) + if err != nil { + t.Fatal(err) + } + return provider + } + + var frames []*aop.ProviderFrame + _, err := NewAgent(Config{ + Provider: newProvider(), Model: "test", CaptureProviderFrames: true, + Bus: testBus(func(event *aop.Event) { + if frame := event.GetProviderFrame(); frame != nil { + frames = append(frames, frame) + } + }), + }).Run(context.Background(), TextInput("hello")) + if err != nil { + t.Fatal(err) + } + requestBody := <-requests + if len(frames) != 2 { + t.Fatalf("provider frames = %d, want request and response", len(frames)) + } + if frames[0].Direction != aop.Direction_DIRECTION_REQUEST || string(frames[0].Payload) != string(requestBody) { + t.Fatalf("request frame = %+v, body=%s", frames[0], requestBody) + } + if frames[1].Direction != aop.Direction_DIRECTION_RESPONSE || string(frames[1].Payload) != string(responseBody) { + t.Fatalf("response frame = %+v", frames[1]) + } + if len(frames[0].Metadata) != 0 || len(frames[1].Metadata) != 0 { + t.Fatalf("provider credentials or headers leaked into metadata: %+v", frames) + } + + frames = nil + _, err = NewAgent(Config{ + Provider: newProvider(), Model: "test", CaptureProviderFrames: false, + Bus: testBus(func(event *aop.Event) { + if frame := event.GetProviderFrame(); frame != nil { + frames = append(frames, frame) + } + }), + }).Run(context.Background(), TextInput("hello")) + if err != nil { + t.Fatal(err) + } + <-requests + if len(frames) != 0 { + t.Fatalf("provider frames emitted while capture disabled: %+v", frames) + } +} + +func TestAnthropicProviderFrameCapturePreservesExactBytes(t *testing.T) { + responseBody := []byte(`{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1},"x_unknown":{"raw":"kept"}}`) + requests := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(responseBody) + })) + defer server.Close() + provider, err := NewProvider(&ProviderConfig{ + Provider: "anthropic", BaseURL: server.URL + "/v1", APIKey: "secret", Model: "claude-test", Timeout: 5, + }) + if err != nil { + t.Fatal(err) + } + var frames []*aop.ProviderFrame + _, err = NewAgent(Config{ + Provider: provider, Model: "claude-test", CaptureProviderFrames: true, + Bus: testBus(func(event *aop.Event) { + if frame := event.GetProviderFrame(); frame != nil { + frames = append(frames, frame) + } + }), + }).Run(context.Background(), TextInput("hello")) + if err != nil { + t.Fatal(err) + } + requestBody := <-requests + if len(frames) != 2 || frames[0].Protocol != "anthropic" || string(frames[0].Payload) != string(requestBody) || string(frames[1].Payload) != string(responseBody) { + t.Fatalf("anthropic provider frames = %+v", frames) + } +} + +func TestProviderFrameCapturePreservesSSEFrameOrder(t *testing.T) { + chunks := [][]byte{ + []byte(`{"choices":[{"delta":{"role":"assistant"},"finish_reason":""}],"unknown":1}`), + []byte(`{"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"unknown":{"nested":true}}`), + []byte(`[DONE]`), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + for _, chunk := range chunks { + _, _ = w.Write(append(append([]byte("data: "), chunk...), '\n', '\n')) + } + })) + defer server.Close() + provider, err := NewProvider(&ProviderConfig{ + Provider: "openai", BaseURL: server.URL + "/v1", APIKey: "secret", Model: "test", Timeout: 5, + }) + if err != nil { + t.Fatal(err) + } + var frames []*aop.ProviderFrame + _, err = NewAgent(Config{ + Provider: provider, Model: "test", Stream: true, CaptureProviderFrames: true, + Bus: testBus(func(event *aop.Event) { + if frame := event.GetProviderFrame(); frame != nil { + frames = append(frames, frame) + } + }), + }).Run(context.Background(), TextInput("hello")) + if err != nil { + t.Fatal(err) + } + if len(frames) != 4 { + t.Fatalf("provider frames = %d, want request plus 3 SSE frames", len(frames)) + } + for index, chunk := range chunks { + frame := frames[index+1] + if frame.Transport != "sse" || string(frame.Payload) != string(chunk) { + t.Fatalf("SSE frame %d = %+v, want %s", index, frame, chunk) + } + } +} diff --git a/agent/retry.go b/agent/retry.go index 7aeff4fd..85c8321e 100644 --- a/agent/retry.go +++ b/agent/retry.go @@ -13,7 +13,7 @@ import ( "time" "github.com/chainreactors/aiscan/agent/provider" - "github.com/chainreactors/aiscan/core/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/telemetry" ) @@ -228,7 +228,9 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm return ChatMessage{}, nil, fmt.Errorf("cannot create LLM request at turn %d: %w", turn, err) } req.MaxTokens = maxTokens - em.status(aop.StatusLLMRequest, aop.NSAOP, aop.LLMRequest{Model: req.Model, Messages: len(req.Messages), MaxTokens: req.MaxTokens, Stream: cfg.Stream}) + em.status(statusLLMRequest, aopStatusNamespace, &transport.LLMRequestDetail{ + Model: req.Model, Messages: uint32(len(req.Messages)), MaxTokens: uint32(max(req.MaxTokens, 0)), Stream: cfg.Stream, + }) if cfg.Stream { if streaming, ok := cfg.Provider.(StreamingProvider); ok { return streamAssistantMessageWithUsage(ctx, streaming, req, em, cfg.Logger, turn, messageID) @@ -315,14 +317,14 @@ func streamAssistantMessageWithUsage(ctx context.Context, p StreamingProvider, r builder.Apply(event.Delta) if event.Delta.ReasoningContent != nil && *event.Delta.ReasoningContent != "" { seenReasoning = true - em.messageDelta(messageID, 0, aop.PartReasoning, *event.Delta.ReasoningContent) + em.messageDelta(messageID, 0, partReasoning, *event.Delta.ReasoningContent) } if event.Delta.Content != nil && *event.Delta.Content != "" { textIndex := 0 if seenReasoning { textIndex = 1 } - em.messageDelta(messageID, textIndex, aop.PartText, *event.Delta.Content) + em.messageDelta(messageID, textIndex, partText, *event.Delta.Content) } } } diff --git a/agent/retry_test.go b/agent/retry_test.go index c2728fbe..c027fac9 100644 --- a/agent/retry_test.go +++ b/agent/retry_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/chainreactors/aiscan/agent/provider" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" @@ -217,7 +217,7 @@ func TestStreamAssistantMessageReturnsContextErrorOnClosedCanceledStream(t *test _, _, err := streamAssistantMessageWithUsage(ctx, &scriptedProvider{}, &ChatCompletionRequest{Model: "test"}, - newAOPEmitter(eventbus.New[aop.Event](), "aiscan", "test-session", "", "", nil, 0), + newAOPEmitter(eventbus.New[*aop.Event](), "aiscan", "test-session", "", "", nil, 0), telemetry.NopLogger(), 1, "m-1", diff --git a/agent/subagent.go b/agent/subagent.go index b336a130..96c93af4 100644 --- a/agent/subagent.go +++ b/agent/subagent.go @@ -11,7 +11,7 @@ import ( "time" "github.com/chainreactors/aiscan/agent/inbox" - "github.com/chainreactors/aiscan/core/aop/x/delegation" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" @@ -164,20 +164,20 @@ func (t *SubAgentTool) create(ctx context.Context, prompt, typeName, name, mode, } } -func delegationFromToolCall(toolName string, args any) (delegation.DelegationDetail, bool) { +func delegationFromToolCall(toolName string, args any) (ext.DelegationDetail, bool) { if toolName != "subagent" { - return delegation.DelegationDetail{}, false + return ext.DelegationDetail{}, false } values, ok := args.(map[string]any) if !ok { - return delegation.DelegationDetail{}, false + return ext.DelegationDetail{}, false } if action, _ := values["action"].(string); action != "" && action != "create" { - return delegation.DelegationDetail{}, false + return ext.DelegationDetail{}, false } task, _ := values["prompt"].(string) if strings.TrimSpace(task) == "" { - return delegation.DelegationDetail{}, false + return ext.DelegationDetail{}, false } name, _ := values["name"].(string) typeName, _ := values["type"].(string) @@ -185,22 +185,22 @@ func delegationFromToolCall(toolName string, args any) (delegation.DelegationDet return delegationDetail(task, typeName, name, mode), true } -func delegationDetail(task, typeName, name, mode string) delegation.DelegationDetail { - detail := delegation.DelegationDetail{ +func delegationDetail(task, typeName, name, mode string) ext.DelegationDetail { + detail := ext.DelegationDetail{ Task: task, AgentName: name, AgentType: typeName, } switch mode { case "sync": - detail.RunMode = delegation.DelegationDetailRunModeForeground - detail.ContextMode = delegation.DelegationDetailContextModeFresh + detail.RunMode = ext.DelegationRunForeground + detail.ContextMode = ext.DelegationContextFresh case "async": - detail.RunMode = delegation.DelegationDetailRunModeBackground - detail.ContextMode = delegation.DelegationDetailContextModeFresh + detail.RunMode = ext.DelegationRunBackground + detail.ContextMode = ext.DelegationContextFresh case "fork": - detail.RunMode = delegation.DelegationDetailRunModeBackground - detail.ContextMode = delegation.DelegationDetailContextModeFork + detail.RunMode = ext.DelegationRunBackground + detail.ContextMode = ext.DelegationContextFork } return detail } diff --git a/agent/subagent_test.go b/agent/subagent_test.go index 6eb134ba..3374f895 100644 --- a/agent/subagent_test.go +++ b/agent/subagent_test.go @@ -7,8 +7,8 @@ import ( "time" "github.com/chainreactors/aiscan/agent/inbox" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/aop/x/delegation" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" @@ -66,9 +66,9 @@ func TestSubAgentUsesExecutingAgentContext(t *testing.T) { activeInbox := inbox.NewBuffered(DefaultInboxCapacity) var mu sync.Mutex - var events []aop.Event - bus := eventbus.New[aop.Event]() - bus.Subscribe(func(event aop.Event) { + var events []*aop.Event + bus := eventbus.New[*aop.Event]() + bus.Subscribe(func(event *aop.Event) { mu.Lock() events = append(events, event) mu.Unlock() @@ -98,24 +98,24 @@ func TestSubAgentUsesExecutingAgentContext(t *testing.T) { mu.Lock() defer mu.Unlock() for _, event := range events { - if event.Type != aop.TypeSessionStart || event.Agent != "context-worker" { + if eventKind(event) != "session.started" || event.Emitter != "context-worker" { continue } - data, err := aop.DecodeData[aop.SessionStartData](event) - if err != nil { - t.Fatalf("decode session.start: %v", err) + data := event.GetSessionStarted() + if data == nil { + t.Fatal("session.started payload missing") } - if data.ParentSessionID != "active-session" { - t.Fatalf("parent session = %q, want active-session", data.ParentSessionID) + if data.ParentSessionId != "active-session" { + t.Fatalf("parent session = %q, want active-session", data.ParentSessionId) } - if data.ParentToolCallID != "spawn-context" { - t.Fatalf("parent tool call = %q, want spawn-context", data.ParentToolCallID) + if data.ParentToolCallId != "spawn-context" { + t.Fatalf("parent tool call = %q, want spawn-context", data.ParentToolCallId) } - detail, ok, err := delegation.Get(event) + detail, ok, err := ext.GetDelegation(event) if err != nil || !ok { t.Fatalf("delegation ext = %#v, %v, %v", detail, ok, err) } - if detail.AgentName != "context-worker" || detail.Task != "work" || detail.RunMode != delegation.DelegationDetailRunModeBackground { + if detail.AgentName != "context-worker" || detail.Task != "work" || detail.RunMode != ext.DelegationRunBackground { t.Fatalf("delegation detail = %#v", detail) } return @@ -124,9 +124,9 @@ func TestSubAgentUsesExecutingAgentContext(t *testing.T) { } func TestSubAgentToolCallCarriesDelegationExtension(t *testing.T) { - bus := eventbus.New[aop.Event]() - events := make(chan aop.Event, 1) - bus.Subscribe(func(event aop.Event) { events <- event }) + bus := eventbus.New[*aop.Event]() + events := make(chan *aop.Event, 1) + bus.Subscribe(func(event *aop.Event) { events <- event }) em := newAOPEmitter(bus, "aiscan", "parent-session", "", "", nil, 0) em.toolCall("spawn-1", "subagent", map[string]any{ @@ -138,14 +138,14 @@ func TestSubAgentToolCallCarriesDelegationExtension(t *testing.T) { }, "") event := <-events - detail, ok, err := delegation.Get(event) + detail, ok, err := ext.GetDelegation(event) if err != nil || !ok { t.Fatalf("delegation ext = %#v, %v, %v", detail, ok, err) } if detail.Task != "inspect the repository" || detail.AgentName != "explorer" || detail.AgentType != "reviewer" { t.Fatalf("delegation detail = %#v", detail) } - if detail.RunMode != delegation.DelegationDetailRunModeBackground || detail.ContextMode != delegation.DelegationDetailContextModeFork { + if detail.RunMode != ext.DelegationRunBackground || detail.ContextMode != ext.DelegationContextFork { t.Fatalf("delegation modes = %#v", detail) } } diff --git a/agent/types.go b/agent/types.go index a8f862f8..c0a9477d 100644 --- a/agent/types.go +++ b/agent/types.go @@ -8,8 +8,8 @@ import ( "github.com/chainreactors/aiscan/agent/hooks" "github.com/chainreactors/aiscan/agent/inbox" "github.com/chainreactors/aiscan/agent/provider" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/aop/x/delegation" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" @@ -30,6 +30,7 @@ type ImageURL = provider.ImageURL type ChatCompletionRequest = provider.ChatCompletionRequest type ChatCompletionResponse = provider.ChatCompletionResponse type ChatCompletionStreamEvent = provider.ChatCompletionStreamEvent +type ProviderRawFrame = provider.RawFrame type Choice = provider.Choice type Usage = provider.Usage type APIError = provider.APIError @@ -145,7 +146,7 @@ type Config struct { TokenBudget int Logger telemetry.Logger TransformContext TransformContextFunc - Bus *eventbus.Bus[aop.Event] + Bus *eventbus.Bus[*aop.Event] // Hooks is the typed extension registry shared by a runtime and its derived // agents. Nil means no handlers and keeps the dispatch fast path allocation-free. Hooks *hooks.Registry @@ -165,33 +166,36 @@ type Config struct { TurnID string ParentSessionID string ParentToolCallID string - Delegation *delegation.DelegationDetail + Delegation *ext.DelegationDetail // AgentName tags emitted AOP events; defaults to "aiscan". AgentName string // MessageCounter seeds message_id allocation ("m-") when a session is // restored; Result.MessageCounter carries the final value for saving. MessageCounter int64 + // CaptureProviderFrames emits exact provider request/response bytes as AOP + // ProviderFrame events. Disabled by default because payloads may be sensitive. + CaptureProviderFrames bool emitter *aopEmitter } // Builder methods — each returns a modified copy (Config is a value type). -func (c Config) WithProvider(p Provider) Config { c.Provider = p; return c } -func (c Config) WithTools(t tool.Executor) Config { c.Tools = t; return c } -func (c Config) WithModel(m string) Config { c.Model = m; return c } -func (c Config) WithSystemPrompt(s string) Config { c.SystemPrompt = s; return c } -func (c Config) WithMessages(msgs []ChatMessage) Config { c.Messages = msgs; return c } -func (c Config) WithStream(s bool) Config { c.Stream = s; return c } -func (c Config) WithInbox(ib inbox.Inbox) Config { c.Inbox = ib; return c } -func (c Config) WithLogger(l telemetry.Logger) Config { c.Logger = l; return c } -func (c Config) WithBus(b *eventbus.Bus[aop.Event]) Config { c.Bus = b; return c } -func (c Config) WithMaxTokens(n int) Config { c.MaxTokens = n; return c } -func (c Config) WithContextWindow(n int) Config { c.ContextWindow = n; return c } -func (c Config) WithTemperature(t float64) Config { c.Temperature = &t; return c } -func (c Config) WithMaxRetries(n int) Config { c.MaxRetries = n; return c } -func (c Config) WithTokenBudget(n int) Config { c.TokenBudget = n; return c } -func (c Config) WithExpander(e *inbox.Expander) Config { c.Expander = e; return c } +func (c Config) WithProvider(p Provider) Config { c.Provider = p; return c } +func (c Config) WithTools(t tool.Executor) Config { c.Tools = t; return c } +func (c Config) WithModel(m string) Config { c.Model = m; return c } +func (c Config) WithSystemPrompt(s string) Config { c.SystemPrompt = s; return c } +func (c Config) WithMessages(msgs []ChatMessage) Config { c.Messages = msgs; return c } +func (c Config) WithStream(s bool) Config { c.Stream = s; return c } +func (c Config) WithInbox(ib inbox.Inbox) Config { c.Inbox = ib; return c } +func (c Config) WithLogger(l telemetry.Logger) Config { c.Logger = l; return c } +func (c Config) WithBus(b *eventbus.Bus[*aop.Event]) Config { c.Bus = b; return c } +func (c Config) WithMaxTokens(n int) Config { c.MaxTokens = n; return c } +func (c Config) WithContextWindow(n int) Config { c.ContextWindow = n; return c } +func (c Config) WithTemperature(t float64) Config { c.Temperature = &t; return c } +func (c Config) WithMaxRetries(n int) Config { c.MaxRetries = n; return c } +func (c Config) WithTokenBudget(n int) Config { c.TokenBudget = n; return c } +func (c Config) WithExpander(e *inbox.Expander) Config { c.Expander = e; return c } func (c Config) WithTransformContext(fn TransformContextFunc) Config { c.TransformContext = fn return c @@ -245,7 +249,7 @@ func (c Config) init() Config { c.Inbox = inbox.NewBuffered(SubInboxCapacity) } if c.Bus == nil { - c.Bus = eventbus.New[aop.Event]() + c.Bus = eventbus.New[*aop.Event]() } if c.emitter == nil { c.emitter = newAOPEmitter(c.Bus, c.AgentName, c.SessionID, c.ParentSessionID, c.ParentToolCallID, c.Delegation, c.MessageCounter) diff --git a/aop/aiscan/chat/chatconnect/session.connect.go b/aop/aiscan/chat/chatconnect/session.connect.go new file mode 100644 index 00000000..6610ad2b --- /dev/null +++ b/aop/aiscan/chat/chatconnect/session.connect.go @@ -0,0 +1,283 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: aiscan/chat/session.proto + +package chatconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + chat "github.com/chainreactors/aiscan/aop/aiscan/chat" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // SessionServiceName is the fully-qualified name of the SessionService service. + SessionServiceName = "aiscan.chat.SessionService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // SessionServiceListSessionsProcedure is the fully-qualified name of the SessionService's + // ListSessions RPC. + SessionServiceListSessionsProcedure = "/aiscan.chat.SessionService/ListSessions" + // SessionServiceGetSessionProcedure is the fully-qualified name of the SessionService's GetSession + // RPC. + SessionServiceGetSessionProcedure = "/aiscan.chat.SessionService/GetSession" + // SessionServiceResetSessionProcedure is the fully-qualified name of the SessionService's + // ResetSession RPC. + SessionServiceResetSessionProcedure = "/aiscan.chat.SessionService/ResetSession" + // SessionServiceDeleteSessionProcedure is the fully-qualified name of the SessionService's + // DeleteSession RPC. + SessionServiceDeleteSessionProcedure = "/aiscan.chat.SessionService/DeleteSession" + // SessionServiceListCommandsProcedure is the fully-qualified name of the SessionService's + // ListCommands RPC. + SessionServiceListCommandsProcedure = "/aiscan.chat.SessionService/ListCommands" + // SessionServiceExecuteCommandProcedure is the fully-qualified name of the SessionService's + // ExecuteCommand RPC. + SessionServiceExecuteCommandProcedure = "/aiscan.chat.SessionService/ExecuteCommand" + // SessionServiceUploadSessionFileProcedure is the fully-qualified name of the SessionService's + // UploadSessionFile RPC. + SessionServiceUploadSessionFileProcedure = "/aiscan.chat.SessionService/UploadSessionFile" +) + +// SessionServiceClient is a client for the aiscan.chat.SessionService service. +type SessionServiceClient interface { + ListSessions(context.Context, *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) + GetSession(context.Context, *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) + ResetSession(context.Context, *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) + DeleteSession(context.Context, *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) + ListCommands(context.Context, *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) + ExecuteCommand(context.Context, *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) + UploadSessionFile(context.Context, *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) +} + +// NewSessionServiceClient constructs a client for the aiscan.chat.SessionService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SessionServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + sessionServiceMethods := chat.File_aiscan_chat_session_proto.Services().ByName("SessionService").Methods() + return &sessionServiceClient{ + listSessions: connect.NewClient[chat.ListSessionsRequest, chat.ListSessionsResponse]( + httpClient, + baseURL+SessionServiceListSessionsProcedure, + connect.WithSchema(sessionServiceMethods.ByName("ListSessions")), + connect.WithClientOptions(opts...), + ), + getSession: connect.NewClient[chat.GetSessionRequest, chat.GetSessionResponse]( + httpClient, + baseURL+SessionServiceGetSessionProcedure, + connect.WithSchema(sessionServiceMethods.ByName("GetSession")), + connect.WithClientOptions(opts...), + ), + resetSession: connect.NewClient[chat.ResetSessionRequest, chat.ResetSessionResponse]( + httpClient, + baseURL+SessionServiceResetSessionProcedure, + connect.WithSchema(sessionServiceMethods.ByName("ResetSession")), + connect.WithClientOptions(opts...), + ), + deleteSession: connect.NewClient[chat.DeleteSessionRequest, chat.DeleteSessionResponse]( + httpClient, + baseURL+SessionServiceDeleteSessionProcedure, + connect.WithSchema(sessionServiceMethods.ByName("DeleteSession")), + connect.WithClientOptions(opts...), + ), + listCommands: connect.NewClient[chat.ListCommandsRequest, chat.ListCommandsResponse]( + httpClient, + baseURL+SessionServiceListCommandsProcedure, + connect.WithSchema(sessionServiceMethods.ByName("ListCommands")), + connect.WithClientOptions(opts...), + ), + executeCommand: connect.NewClient[chat.ExecuteCommandRequest, chat.ExecuteCommandResponse]( + httpClient, + baseURL+SessionServiceExecuteCommandProcedure, + connect.WithSchema(sessionServiceMethods.ByName("ExecuteCommand")), + connect.WithClientOptions(opts...), + ), + uploadSessionFile: connect.NewClient[chat.UploadSessionFileRequest, chat.UploadSessionFileResponse]( + httpClient, + baseURL+SessionServiceUploadSessionFileProcedure, + connect.WithSchema(sessionServiceMethods.ByName("UploadSessionFile")), + connect.WithClientOptions(opts...), + ), + } +} + +// sessionServiceClient implements SessionServiceClient. +type sessionServiceClient struct { + listSessions *connect.Client[chat.ListSessionsRequest, chat.ListSessionsResponse] + getSession *connect.Client[chat.GetSessionRequest, chat.GetSessionResponse] + resetSession *connect.Client[chat.ResetSessionRequest, chat.ResetSessionResponse] + deleteSession *connect.Client[chat.DeleteSessionRequest, chat.DeleteSessionResponse] + listCommands *connect.Client[chat.ListCommandsRequest, chat.ListCommandsResponse] + executeCommand *connect.Client[chat.ExecuteCommandRequest, chat.ExecuteCommandResponse] + uploadSessionFile *connect.Client[chat.UploadSessionFileRequest, chat.UploadSessionFileResponse] +} + +// ListSessions calls aiscan.chat.SessionService.ListSessions. +func (c *sessionServiceClient) ListSessions(ctx context.Context, req *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) { + return c.listSessions.CallUnary(ctx, req) +} + +// GetSession calls aiscan.chat.SessionService.GetSession. +func (c *sessionServiceClient) GetSession(ctx context.Context, req *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) { + return c.getSession.CallUnary(ctx, req) +} + +// ResetSession calls aiscan.chat.SessionService.ResetSession. +func (c *sessionServiceClient) ResetSession(ctx context.Context, req *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) { + return c.resetSession.CallUnary(ctx, req) +} + +// DeleteSession calls aiscan.chat.SessionService.DeleteSession. +func (c *sessionServiceClient) DeleteSession(ctx context.Context, req *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) { + return c.deleteSession.CallUnary(ctx, req) +} + +// ListCommands calls aiscan.chat.SessionService.ListCommands. +func (c *sessionServiceClient) ListCommands(ctx context.Context, req *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) { + return c.listCommands.CallUnary(ctx, req) +} + +// ExecuteCommand calls aiscan.chat.SessionService.ExecuteCommand. +func (c *sessionServiceClient) ExecuteCommand(ctx context.Context, req *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) { + return c.executeCommand.CallUnary(ctx, req) +} + +// UploadSessionFile calls aiscan.chat.SessionService.UploadSessionFile. +func (c *sessionServiceClient) UploadSessionFile(ctx context.Context, req *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) { + return c.uploadSessionFile.CallUnary(ctx, req) +} + +// SessionServiceHandler is an implementation of the aiscan.chat.SessionService service. +type SessionServiceHandler interface { + ListSessions(context.Context, *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) + GetSession(context.Context, *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) + ResetSession(context.Context, *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) + DeleteSession(context.Context, *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) + ListCommands(context.Context, *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) + ExecuteCommand(context.Context, *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) + UploadSessionFile(context.Context, *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) +} + +// NewSessionServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewSessionServiceHandler(svc SessionServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + sessionServiceMethods := chat.File_aiscan_chat_session_proto.Services().ByName("SessionService").Methods() + sessionServiceListSessionsHandler := connect.NewUnaryHandler( + SessionServiceListSessionsProcedure, + svc.ListSessions, + connect.WithSchema(sessionServiceMethods.ByName("ListSessions")), + connect.WithHandlerOptions(opts...), + ) + sessionServiceGetSessionHandler := connect.NewUnaryHandler( + SessionServiceGetSessionProcedure, + svc.GetSession, + connect.WithSchema(sessionServiceMethods.ByName("GetSession")), + connect.WithHandlerOptions(opts...), + ) + sessionServiceResetSessionHandler := connect.NewUnaryHandler( + SessionServiceResetSessionProcedure, + svc.ResetSession, + connect.WithSchema(sessionServiceMethods.ByName("ResetSession")), + connect.WithHandlerOptions(opts...), + ) + sessionServiceDeleteSessionHandler := connect.NewUnaryHandler( + SessionServiceDeleteSessionProcedure, + svc.DeleteSession, + connect.WithSchema(sessionServiceMethods.ByName("DeleteSession")), + connect.WithHandlerOptions(opts...), + ) + sessionServiceListCommandsHandler := connect.NewUnaryHandler( + SessionServiceListCommandsProcedure, + svc.ListCommands, + connect.WithSchema(sessionServiceMethods.ByName("ListCommands")), + connect.WithHandlerOptions(opts...), + ) + sessionServiceExecuteCommandHandler := connect.NewUnaryHandler( + SessionServiceExecuteCommandProcedure, + svc.ExecuteCommand, + connect.WithSchema(sessionServiceMethods.ByName("ExecuteCommand")), + connect.WithHandlerOptions(opts...), + ) + sessionServiceUploadSessionFileHandler := connect.NewUnaryHandler( + SessionServiceUploadSessionFileProcedure, + svc.UploadSessionFile, + connect.WithSchema(sessionServiceMethods.ByName("UploadSessionFile")), + connect.WithHandlerOptions(opts...), + ) + return "/aiscan.chat.SessionService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case SessionServiceListSessionsProcedure: + sessionServiceListSessionsHandler.ServeHTTP(w, r) + case SessionServiceGetSessionProcedure: + sessionServiceGetSessionHandler.ServeHTTP(w, r) + case SessionServiceResetSessionProcedure: + sessionServiceResetSessionHandler.ServeHTTP(w, r) + case SessionServiceDeleteSessionProcedure: + sessionServiceDeleteSessionHandler.ServeHTTP(w, r) + case SessionServiceListCommandsProcedure: + sessionServiceListCommandsHandler.ServeHTTP(w, r) + case SessionServiceExecuteCommandProcedure: + sessionServiceExecuteCommandHandler.ServeHTTP(w, r) + case SessionServiceUploadSessionFileProcedure: + sessionServiceUploadSessionFileHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedSessionServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedSessionServiceHandler struct{} + +func (UnimplementedSessionServiceHandler) ListSessions(context.Context, *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ListSessions is not implemented")) +} + +func (UnimplementedSessionServiceHandler) GetSession(context.Context, *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.GetSession is not implemented")) +} + +func (UnimplementedSessionServiceHandler) ResetSession(context.Context, *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ResetSession is not implemented")) +} + +func (UnimplementedSessionServiceHandler) DeleteSession(context.Context, *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.DeleteSession is not implemented")) +} + +func (UnimplementedSessionServiceHandler) ListCommands(context.Context, *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ListCommands is not implemented")) +} + +func (UnimplementedSessionServiceHandler) ExecuteCommand(context.Context, *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ExecuteCommand is not implemented")) +} + +func (UnimplementedSessionServiceHandler) UploadSessionFile(context.Context, *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.UploadSessionFile is not implemented")) +} diff --git a/aop/aiscan/chat/session.pb.go b/aop/aiscan/chat/session.pb.go new file mode 100644 index 00000000..4853d0a9 --- /dev/null +++ b/aop/aiscan/chat/session.pb.go @@ -0,0 +1,1841 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/chat/session.proto + +package chat + +import ( + aop "github.com/chainreactors/aiscan/aop" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SessionRecord struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session *aop.Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + AgentName string `protobuf:"bytes,2,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` + ScanIds []string `protobuf:"bytes,3,rep,name=scan_ids,json=scanIds,proto3" json:"scan_ids,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` +} + +func (x *SessionRecord) Reset() { + *x = SessionRecord{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SessionRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRecord) ProtoMessage() {} + +func (x *SessionRecord) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRecord.ProtoReflect.Descriptor instead. +func (*SessionRecord) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{0} +} + +func (x *SessionRecord) GetSession() *aop.Session { + if x != nil { + return x.Session + } + return nil +} + +func (x *SessionRecord) GetAgentName() string { + if x != nil { + return x.AgentName + } + return "" +} + +func (x *SessionRecord) GetScanIds() []string { + if x != nil { + return x.ScanIds + } + return nil +} + +func (x *SessionRecord) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *SessionRecord) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +type ListSessionsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AfterCursor string `protobuf:"bytes,1,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + IncludeClosed bool `protobuf:"varint,3,opt,name=include_closed,json=includeClosed,proto3" json:"include_closed,omitempty"` +} + +func (x *ListSessionsRequest) Reset() { + *x = ListSessionsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsRequest) ProtoMessage() {} + +func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListSessionsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{1} +} + +func (x *ListSessionsRequest) GetAfterCursor() string { + if x != nil { + return x.AfterCursor + } + return "" +} + +func (x *ListSessionsRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSessionsRequest) GetIncludeClosed() bool { + if x != nil { + return x.IncludeClosed + } + return false +} + +type ListSessionsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sessions []*SessionRecord `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` +} + +func (x *ListSessionsResponse) Reset() { + *x = ListSessionsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListSessionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsResponse) ProtoMessage() {} + +func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. +func (*ListSessionsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{2} +} + +func (x *ListSessionsResponse) GetSessions() []*SessionRecord { + if x != nil { + return x.Sessions + } + return nil +} + +func (x *ListSessionsResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +type GetSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` +} + +func (x *GetSessionRequest) Reset() { + *x = GetSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionRequest) ProtoMessage() {} + +func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. +func (*GetSessionRequest) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{3} +} + +func (x *GetSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type GetSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session *SessionRecord `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *GetSessionResponse) Reset() { + *x = GetSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionResponse) ProtoMessage() {} + +func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. +func (*GetSessionResponse) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{4} +} + +func (x *GetSessionResponse) GetSession() *SessionRecord { + if x != nil { + return x.Session + } + return nil +} + +type ResetSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + NewSessionId string `protobuf:"bytes,3,opt,name=new_session_id,json=newSessionId,proto3" json:"new_session_id,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` +} + +func (x *ResetSessionRequest) Reset() { + *x = ResetSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResetSessionRequest) ProtoMessage() {} + +func (x *ResetSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResetSessionRequest.ProtoReflect.Descriptor instead. +func (*ResetSessionRequest) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{5} +} + +func (x *ResetSessionRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ResetSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ResetSessionRequest) GetNewSessionId() string { + if x != nil { + return x.NewSessionId + } + return "" +} + +func (x *ResetSessionRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +type ResetSessionReceipt struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Previous *aop.Session `protobuf:"bytes,1,opt,name=previous,proto3" json:"previous,omitempty"` + Current *SessionRecord `protobuf:"bytes,2,opt,name=current,proto3" json:"current,omitempty"` +} + +func (x *ResetSessionReceipt) Reset() { + *x = ResetSessionReceipt{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResetSessionReceipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResetSessionReceipt) ProtoMessage() {} + +func (x *ResetSessionReceipt) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResetSessionReceipt.ProtoReflect.Descriptor instead. +func (*ResetSessionReceipt) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{6} +} + +func (x *ResetSessionReceipt) GetPrevious() *aop.Session { + if x != nil { + return x.Previous + } + return nil +} + +func (x *ResetSessionReceipt) GetCurrent() *SessionRecord { + if x != nil { + return x.Current + } + return nil +} + +type ResetSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *ResetSessionResponse_Accepted + // *ResetSessionResponse_Rejected + Outcome isResetSessionResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *ResetSessionResponse) Reset() { + *x = ResetSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResetSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResetSessionResponse) ProtoMessage() {} + +func (x *ResetSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResetSessionResponse.ProtoReflect.Descriptor instead. +func (*ResetSessionResponse) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{7} +} + +func (x *ResetSessionResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *ResetSessionResponse) GetOutcome() isResetSessionResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *ResetSessionResponse) GetAccepted() *ResetSessionReceipt { + if x, ok := x.GetOutcome().(*ResetSessionResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *ResetSessionResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*ResetSessionResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isResetSessionResponse_Outcome interface { + isResetSessionResponse_Outcome() +} + +type ResetSessionResponse_Accepted struct { + Accepted *ResetSessionReceipt `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type ResetSessionResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*ResetSessionResponse_Accepted) isResetSessionResponse_Outcome() {} + +func (*ResetSessionResponse_Rejected) isResetSessionResponse_Outcome() {} + +type DeleteSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` +} + +func (x *DeleteSessionRequest) Reset() { + *x = DeleteSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSessionRequest) ProtoMessage() {} + +func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSessionRequest.ProtoReflect.Descriptor instead. +func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{8} +} + +func (x *DeleteSessionRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *DeleteSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type DeleteSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *DeleteSessionResponse_Accepted + // *DeleteSessionResponse_Rejected + Outcome isDeleteSessionResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *DeleteSessionResponse) Reset() { + *x = DeleteSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSessionResponse) ProtoMessage() {} + +func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSessionResponse.ProtoReflect.Descriptor instead. +func (*DeleteSessionResponse) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteSessionResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *DeleteSessionResponse) GetOutcome() isDeleteSessionResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *DeleteSessionResponse) GetAccepted() *aop.Session { + if x, ok := x.GetOutcome().(*DeleteSessionResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *DeleteSessionResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*DeleteSessionResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isDeleteSessionResponse_Outcome interface { + isDeleteSessionResponse_Outcome() +} + +type DeleteSessionResponse_Accepted struct { + Accepted *aop.Session `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type DeleteSessionResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*DeleteSessionResponse_Accepted) isDeleteSessionResponse_Outcome() {} + +func (*DeleteSessionResponse_Rejected) isDeleteSessionResponse_Outcome() {} + +type CommandSpec struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Aliases []string `protobuf:"bytes,2,rep,name=aliases,proto3" json:"aliases,omitempty"` + Usage string `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` +} + +func (x *CommandSpec) Reset() { + *x = CommandSpec{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandSpec) ProtoMessage() {} + +func (x *CommandSpec) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandSpec.ProtoReflect.Descriptor instead. +func (*CommandSpec) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{10} +} + +func (x *CommandSpec) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CommandSpec) GetAliases() []string { + if x != nil { + return x.Aliases + } + return nil +} + +func (x *CommandSpec) GetUsage() string { + if x != nil { + return x.Usage + } + return "" +} + +func (x *CommandSpec) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type ListCommandsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` +} + +func (x *ListCommandsRequest) Reset() { + *x = ListCommandsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListCommandsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCommandsRequest) ProtoMessage() {} + +func (x *ListCommandsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCommandsRequest.ProtoReflect.Descriptor instead. +func (*ListCommandsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{11} +} + +func (x *ListCommandsRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type ListCommandsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Commands []*CommandSpec `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` +} + +func (x *ListCommandsResponse) Reset() { + *x = ListCommandsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListCommandsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCommandsResponse) ProtoMessage() {} + +func (x *ListCommandsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCommandsResponse.ProtoReflect.Descriptor instead. +func (*ListCommandsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{12} +} + +func (x *ListCommandsResponse) GetCommands() []*CommandSpec { + if x != nil { + return x.Commands + } + return nil +} + +type ExecuteCommandRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Line string `protobuf:"bytes,3,opt,name=line,proto3" json:"line,omitempty"` +} + +func (x *ExecuteCommandRequest) Reset() { + *x = ExecuteCommandRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecuteCommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteCommandRequest) ProtoMessage() {} + +func (x *ExecuteCommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteCommandRequest.ProtoReflect.Descriptor instead. +func (*ExecuteCommandRequest) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{13} +} + +func (x *ExecuteCommandRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ExecuteCommandRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ExecuteCommandRequest) GetLine() string { + if x != nil { + return x.Line + } + return "" +} + +type CommandReceipt struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` +} + +func (x *CommandReceipt) Reset() { + *x = CommandReceipt{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandReceipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandReceipt) ProtoMessage() {} + +func (x *CommandReceipt) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandReceipt.ProtoReflect.Descriptor instead. +func (*CommandReceipt) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{14} +} + +func (x *CommandReceipt) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *CommandReceipt) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *CommandReceipt) GetState() string { + if x != nil { + return x.State + } + return "" +} + +type ExecuteCommandResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *ExecuteCommandResponse_Accepted + // *ExecuteCommandResponse_Rejected + Outcome isExecuteCommandResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *ExecuteCommandResponse) Reset() { + *x = ExecuteCommandResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecuteCommandResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteCommandResponse) ProtoMessage() {} + +func (x *ExecuteCommandResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteCommandResponse.ProtoReflect.Descriptor instead. +func (*ExecuteCommandResponse) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{15} +} + +func (x *ExecuteCommandResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *ExecuteCommandResponse) GetOutcome() isExecuteCommandResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *ExecuteCommandResponse) GetAccepted() *CommandReceipt { + if x, ok := x.GetOutcome().(*ExecuteCommandResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *ExecuteCommandResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*ExecuteCommandResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isExecuteCommandResponse_Outcome interface { + isExecuteCommandResponse_Outcome() +} + +type ExecuteCommandResponse_Accepted struct { + Accepted *CommandReceipt `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type ExecuteCommandResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*ExecuteCommandResponse_Accepted) isExecuteCommandResponse_Outcome() {} + +func (*ExecuteCommandResponse_Rejected) isExecuteCommandResponse_Outcome() {} + +type UploadSessionFileRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Filename string `protobuf:"bytes,3,opt,name=filename,proto3" json:"filename,omitempty"` + MediaType string `protobuf:"bytes,4,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` + Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *UploadSessionFileRequest) Reset() { + *x = UploadSessionFileRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UploadSessionFileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadSessionFileRequest) ProtoMessage() {} + +func (x *UploadSessionFileRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadSessionFileRequest.ProtoReflect.Descriptor instead. +func (*UploadSessionFileRequest) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{16} +} + +func (x *UploadSessionFileRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *UploadSessionFileRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *UploadSessionFileRequest) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *UploadSessionFileRequest) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +func (x *UploadSessionFileRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type UploadedFile struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` + MediaType string `protobuf:"bytes,4,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *UploadedFile) Reset() { + *x = UploadedFile{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UploadedFile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadedFile) ProtoMessage() {} + +func (x *UploadedFile) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadedFile.ProtoReflect.Descriptor instead. +func (*UploadedFile) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{17} +} + +func (x *UploadedFile) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *UploadedFile) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *UploadedFile) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *UploadedFile) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +type UploadSessionFileResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *UploadSessionFileResponse_Accepted + // *UploadSessionFileResponse_Rejected + Outcome isUploadSessionFileResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *UploadSessionFileResponse) Reset() { + *x = UploadSessionFileResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_chat_session_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UploadSessionFileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadSessionFileResponse) ProtoMessage() {} + +func (x *UploadSessionFileResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_chat_session_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadSessionFileResponse.ProtoReflect.Descriptor instead. +func (*UploadSessionFileResponse) Descriptor() ([]byte, []int) { + return file_aiscan_chat_session_proto_rawDescGZIP(), []int{18} +} + +func (x *UploadSessionFileResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *UploadSessionFileResponse) GetOutcome() isUploadSessionFileResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *UploadSessionFileResponse) GetAccepted() *UploadedFile { + if x, ok := x.GetOutcome().(*UploadSessionFileResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *UploadSessionFileResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*UploadSessionFileResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isUploadSessionFileResponse_Outcome interface { + isUploadSessionFileResponse_Outcome() +} + +type UploadSessionFileResponse_Accepted struct { + Accepted *UploadedFile `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type UploadSessionFileResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*UploadSessionFileResponse_Accepted) isUploadSessionFileResponse_Outcome() {} + +func (*UploadSessionFileResponse_Rejected) isUploadSessionFileResponse_Outcome() {} + +var File_aiscan_chat_session_proto protoreflect.FileDescriptor + +var file_aiscan_chat_session_proto_rawDesc = []byte{ + 0x0a, 0x19, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2f, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, + 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe7, 0x01, 0x0a, 0x0d, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x26, 0x0a, 0x07, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x61, + 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x39, 0x0a, + 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x64, 0x41, 0x74, 0x22, 0x75, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, + 0x74, 0x65, 0x72, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x61, 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x14, 0x0a, + 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x6c, 0x69, + 0x6d, 0x69, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x63, + 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x69, 0x6e, 0x63, + 0x6c, 0x75, 0x64, 0x65, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x22, 0x6f, 0x0a, 0x14, 0x4c, 0x69, + 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x36, 0x0a, 0x08, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, + 0x61, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x52, 0x08, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, + 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0x32, 0x0a, 0x11, 0x47, + 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, + 0x4a, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x63, 0x68, 0x61, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8f, 0x01, 0x0a, 0x13, + 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x6e, 0x65, 0x77, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6e, 0x65, 0x77, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x22, 0x75, 0x0a, + 0x13, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, + 0x65, 0x69, 0x70, 0x74, 0x12, 0x28, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x12, 0x34, + 0x0a, 0x07, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x63, 0x75, 0x72, + 0x72, 0x65, 0x6e, 0x74, 0x22, 0xae, 0x01, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, + 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x3e, 0x0a, 0x08, + 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, + 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, + 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, + 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, + 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, + 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x54, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, + 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x9b, 0x01, 0x0a, 0x15, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, + 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x73, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x61, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x61, + 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, 0x20, 0x0a, 0x0b, + 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x34, + 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x4c, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x08, + 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x73, 0x22, 0x69, 0x0a, 0x15, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x22, 0x68, 0x0a, + 0x0e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x12, + 0x21, 0x0a, 0x0c, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0xab, 0x01, 0x0a, 0x16, 0x45, 0x78, 0x65, 0x63, + 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, + 0x64, 0x12, 0x39, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, + 0x74, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, + 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, + 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, + 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, + 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0xa7, 0x01, 0x0a, 0x18, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, + 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, + 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, + 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, + 0x71, 0x0a, 0x0c, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x12, + 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, + 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, + 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, + 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, + 0x70, 0x65, 0x22, 0xac, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, + 0x37, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, + 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x08, + 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, + 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, + 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, + 0x65, 0x32, 0xf5, 0x04, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, + 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x63, 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0a, 0x47, 0x65, 0x74, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, + 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, + 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x21, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, + 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x0e, 0x45, 0x78, + 0x65, 0x63, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x22, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, + 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x23, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x45, + 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x11, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x25, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, + 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, + 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, + 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x3b, 0x63, 0x68, 0x61, + 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_chat_session_proto_rawDescOnce sync.Once + file_aiscan_chat_session_proto_rawDescData = file_aiscan_chat_session_proto_rawDesc +) + +func file_aiscan_chat_session_proto_rawDescGZIP() []byte { + file_aiscan_chat_session_proto_rawDescOnce.Do(func() { + file_aiscan_chat_session_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_chat_session_proto_rawDescData) + }) + return file_aiscan_chat_session_proto_rawDescData +} + +var file_aiscan_chat_session_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_aiscan_chat_session_proto_goTypes = []interface{}{ + (*SessionRecord)(nil), // 0: aiscan.chat.SessionRecord + (*ListSessionsRequest)(nil), // 1: aiscan.chat.ListSessionsRequest + (*ListSessionsResponse)(nil), // 2: aiscan.chat.ListSessionsResponse + (*GetSessionRequest)(nil), // 3: aiscan.chat.GetSessionRequest + (*GetSessionResponse)(nil), // 4: aiscan.chat.GetSessionResponse + (*ResetSessionRequest)(nil), // 5: aiscan.chat.ResetSessionRequest + (*ResetSessionReceipt)(nil), // 6: aiscan.chat.ResetSessionReceipt + (*ResetSessionResponse)(nil), // 7: aiscan.chat.ResetSessionResponse + (*DeleteSessionRequest)(nil), // 8: aiscan.chat.DeleteSessionRequest + (*DeleteSessionResponse)(nil), // 9: aiscan.chat.DeleteSessionResponse + (*CommandSpec)(nil), // 10: aiscan.chat.CommandSpec + (*ListCommandsRequest)(nil), // 11: aiscan.chat.ListCommandsRequest + (*ListCommandsResponse)(nil), // 12: aiscan.chat.ListCommandsResponse + (*ExecuteCommandRequest)(nil), // 13: aiscan.chat.ExecuteCommandRequest + (*CommandReceipt)(nil), // 14: aiscan.chat.CommandReceipt + (*ExecuteCommandResponse)(nil), // 15: aiscan.chat.ExecuteCommandResponse + (*UploadSessionFileRequest)(nil), // 16: aiscan.chat.UploadSessionFileRequest + (*UploadedFile)(nil), // 17: aiscan.chat.UploadedFile + (*UploadSessionFileResponse)(nil), // 18: aiscan.chat.UploadSessionFileResponse + (*aop.Session)(nil), // 19: aop.Session + (*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp + (*aop.Rejection)(nil), // 21: aop.Rejection +} +var file_aiscan_chat_session_proto_depIdxs = []int32{ + 19, // 0: aiscan.chat.SessionRecord.session:type_name -> aop.Session + 20, // 1: aiscan.chat.SessionRecord.created_at:type_name -> google.protobuf.Timestamp + 20, // 2: aiscan.chat.SessionRecord.updated_at:type_name -> google.protobuf.Timestamp + 0, // 3: aiscan.chat.ListSessionsResponse.sessions:type_name -> aiscan.chat.SessionRecord + 0, // 4: aiscan.chat.GetSessionResponse.session:type_name -> aiscan.chat.SessionRecord + 19, // 5: aiscan.chat.ResetSessionReceipt.previous:type_name -> aop.Session + 0, // 6: aiscan.chat.ResetSessionReceipt.current:type_name -> aiscan.chat.SessionRecord + 6, // 7: aiscan.chat.ResetSessionResponse.accepted:type_name -> aiscan.chat.ResetSessionReceipt + 21, // 8: aiscan.chat.ResetSessionResponse.rejected:type_name -> aop.Rejection + 19, // 9: aiscan.chat.DeleteSessionResponse.accepted:type_name -> aop.Session + 21, // 10: aiscan.chat.DeleteSessionResponse.rejected:type_name -> aop.Rejection + 10, // 11: aiscan.chat.ListCommandsResponse.commands:type_name -> aiscan.chat.CommandSpec + 14, // 12: aiscan.chat.ExecuteCommandResponse.accepted:type_name -> aiscan.chat.CommandReceipt + 21, // 13: aiscan.chat.ExecuteCommandResponse.rejected:type_name -> aop.Rejection + 17, // 14: aiscan.chat.UploadSessionFileResponse.accepted:type_name -> aiscan.chat.UploadedFile + 21, // 15: aiscan.chat.UploadSessionFileResponse.rejected:type_name -> aop.Rejection + 1, // 16: aiscan.chat.SessionService.ListSessions:input_type -> aiscan.chat.ListSessionsRequest + 3, // 17: aiscan.chat.SessionService.GetSession:input_type -> aiscan.chat.GetSessionRequest + 5, // 18: aiscan.chat.SessionService.ResetSession:input_type -> aiscan.chat.ResetSessionRequest + 8, // 19: aiscan.chat.SessionService.DeleteSession:input_type -> aiscan.chat.DeleteSessionRequest + 11, // 20: aiscan.chat.SessionService.ListCommands:input_type -> aiscan.chat.ListCommandsRequest + 13, // 21: aiscan.chat.SessionService.ExecuteCommand:input_type -> aiscan.chat.ExecuteCommandRequest + 16, // 22: aiscan.chat.SessionService.UploadSessionFile:input_type -> aiscan.chat.UploadSessionFileRequest + 2, // 23: aiscan.chat.SessionService.ListSessions:output_type -> aiscan.chat.ListSessionsResponse + 4, // 24: aiscan.chat.SessionService.GetSession:output_type -> aiscan.chat.GetSessionResponse + 7, // 25: aiscan.chat.SessionService.ResetSession:output_type -> aiscan.chat.ResetSessionResponse + 9, // 26: aiscan.chat.SessionService.DeleteSession:output_type -> aiscan.chat.DeleteSessionResponse + 12, // 27: aiscan.chat.SessionService.ListCommands:output_type -> aiscan.chat.ListCommandsResponse + 15, // 28: aiscan.chat.SessionService.ExecuteCommand:output_type -> aiscan.chat.ExecuteCommandResponse + 18, // 29: aiscan.chat.SessionService.UploadSessionFile:output_type -> aiscan.chat.UploadSessionFileResponse + 23, // [23:30] is the sub-list for method output_type + 16, // [16:23] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name +} + +func init() { file_aiscan_chat_session_proto_init() } +func file_aiscan_chat_session_proto_init() { + if File_aiscan_chat_session_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_chat_session_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SessionRecord); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListSessionsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListSessionsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResetSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResetSessionReceipt); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResetSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandSpec); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListCommandsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListCommandsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ExecuteCommandRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandReceipt); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ExecuteCommandResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UploadSessionFileRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UploadedFile); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_chat_session_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UploadSessionFileResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aiscan_chat_session_proto_msgTypes[7].OneofWrappers = []interface{}{ + (*ResetSessionResponse_Accepted)(nil), + (*ResetSessionResponse_Rejected)(nil), + } + file_aiscan_chat_session_proto_msgTypes[9].OneofWrappers = []interface{}{ + (*DeleteSessionResponse_Accepted)(nil), + (*DeleteSessionResponse_Rejected)(nil), + } + file_aiscan_chat_session_proto_msgTypes[15].OneofWrappers = []interface{}{ + (*ExecuteCommandResponse_Accepted)(nil), + (*ExecuteCommandResponse_Rejected)(nil), + } + file_aiscan_chat_session_proto_msgTypes[18].OneofWrappers = []interface{}{ + (*UploadSessionFileResponse_Accepted)(nil), + (*UploadSessionFileResponse_Rejected)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_chat_session_proto_rawDesc, + NumEnums: 0, + NumMessages: 19, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_chat_session_proto_goTypes, + DependencyIndexes: file_aiscan_chat_session_proto_depIdxs, + MessageInfos: file_aiscan_chat_session_proto_msgTypes, + }.Build() + File_aiscan_chat_session_proto = out.File + file_aiscan_chat_session_proto_rawDesc = nil + file_aiscan_chat_session_proto_goTypes = nil + file_aiscan_chat_session_proto_depIdxs = nil +} diff --git a/aop/aiscan/chat/session_grpc.pb.go b/aop/aiscan/chat/session_grpc.pb.go new file mode 100644 index 00000000..90bc91fb --- /dev/null +++ b/aop/aiscan/chat/session_grpc.pb.go @@ -0,0 +1,331 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v6.33.0 +// source: aiscan/chat/session.proto + +package chat + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + SessionService_ListSessions_FullMethodName = "/aiscan.chat.SessionService/ListSessions" + SessionService_GetSession_FullMethodName = "/aiscan.chat.SessionService/GetSession" + SessionService_ResetSession_FullMethodName = "/aiscan.chat.SessionService/ResetSession" + SessionService_DeleteSession_FullMethodName = "/aiscan.chat.SessionService/DeleteSession" + SessionService_ListCommands_FullMethodName = "/aiscan.chat.SessionService/ListCommands" + SessionService_ExecuteCommand_FullMethodName = "/aiscan.chat.SessionService/ExecuteCommand" + SessionService_UploadSessionFile_FullMethodName = "/aiscan.chat.SessionService/UploadSessionFile" +) + +// SessionServiceClient is the client API for SessionService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type SessionServiceClient interface { + ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) + GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) + ResetSession(ctx context.Context, in *ResetSessionRequest, opts ...grpc.CallOption) (*ResetSessionResponse, error) + DeleteSession(ctx context.Context, in *DeleteSessionRequest, opts ...grpc.CallOption) (*DeleteSessionResponse, error) + ListCommands(ctx context.Context, in *ListCommandsRequest, opts ...grpc.CallOption) (*ListCommandsResponse, error) + ExecuteCommand(ctx context.Context, in *ExecuteCommandRequest, opts ...grpc.CallOption) (*ExecuteCommandResponse, error) + UploadSessionFile(ctx context.Context, in *UploadSessionFileRequest, opts ...grpc.CallOption) (*UploadSessionFileResponse, error) +} + +type sessionServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSessionServiceClient(cc grpc.ClientConnInterface) SessionServiceClient { + return &sessionServiceClient{cc} +} + +func (c *sessionServiceClient) ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) { + out := new(ListSessionsResponse) + err := c.cc.Invoke(ctx, SessionService_ListSessions_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) { + out := new(GetSessionResponse) + err := c.cc.Invoke(ctx, SessionService_GetSession_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) ResetSession(ctx context.Context, in *ResetSessionRequest, opts ...grpc.CallOption) (*ResetSessionResponse, error) { + out := new(ResetSessionResponse) + err := c.cc.Invoke(ctx, SessionService_ResetSession_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) DeleteSession(ctx context.Context, in *DeleteSessionRequest, opts ...grpc.CallOption) (*DeleteSessionResponse, error) { + out := new(DeleteSessionResponse) + err := c.cc.Invoke(ctx, SessionService_DeleteSession_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) ListCommands(ctx context.Context, in *ListCommandsRequest, opts ...grpc.CallOption) (*ListCommandsResponse, error) { + out := new(ListCommandsResponse) + err := c.cc.Invoke(ctx, SessionService_ListCommands_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) ExecuteCommand(ctx context.Context, in *ExecuteCommandRequest, opts ...grpc.CallOption) (*ExecuteCommandResponse, error) { + out := new(ExecuteCommandResponse) + err := c.cc.Invoke(ctx, SessionService_ExecuteCommand_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) UploadSessionFile(ctx context.Context, in *UploadSessionFileRequest, opts ...grpc.CallOption) (*UploadSessionFileResponse, error) { + out := new(UploadSessionFileResponse) + err := c.cc.Invoke(ctx, SessionService_UploadSessionFile_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SessionServiceServer is the server API for SessionService service. +// All implementations must embed UnimplementedSessionServiceServer +// for forward compatibility +type SessionServiceServer interface { + ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) + GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) + ResetSession(context.Context, *ResetSessionRequest) (*ResetSessionResponse, error) + DeleteSession(context.Context, *DeleteSessionRequest) (*DeleteSessionResponse, error) + ListCommands(context.Context, *ListCommandsRequest) (*ListCommandsResponse, error) + ExecuteCommand(context.Context, *ExecuteCommandRequest) (*ExecuteCommandResponse, error) + UploadSessionFile(context.Context, *UploadSessionFileRequest) (*UploadSessionFileResponse, error) + mustEmbedUnimplementedSessionServiceServer() +} + +// UnimplementedSessionServiceServer must be embedded to have forward compatible implementations. +type UnimplementedSessionServiceServer struct { +} + +func (UnimplementedSessionServiceServer) ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListSessions not implemented") +} +func (UnimplementedSessionServiceServer) GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSession not implemented") +} +func (UnimplementedSessionServiceServer) ResetSession(context.Context, *ResetSessionRequest) (*ResetSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ResetSession not implemented") +} +func (UnimplementedSessionServiceServer) DeleteSession(context.Context, *DeleteSessionRequest) (*DeleteSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteSession not implemented") +} +func (UnimplementedSessionServiceServer) ListCommands(context.Context, *ListCommandsRequest) (*ListCommandsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListCommands not implemented") +} +func (UnimplementedSessionServiceServer) ExecuteCommand(context.Context, *ExecuteCommandRequest) (*ExecuteCommandResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ExecuteCommand not implemented") +} +func (UnimplementedSessionServiceServer) UploadSessionFile(context.Context, *UploadSessionFileRequest) (*UploadSessionFileResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UploadSessionFile not implemented") +} +func (UnimplementedSessionServiceServer) mustEmbedUnimplementedSessionServiceServer() {} + +// UnsafeSessionServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SessionServiceServer will +// result in compilation errors. +type UnsafeSessionServiceServer interface { + mustEmbedUnimplementedSessionServiceServer() +} + +func RegisterSessionServiceServer(s grpc.ServiceRegistrar, srv SessionServiceServer) { + s.RegisterService(&SessionService_ServiceDesc, srv) +} + +func _SessionService_ListSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSessionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).ListSessions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_ListSessions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).ListSessions(ctx, req.(*ListSessionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_GetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).GetSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_GetSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).GetSession(ctx, req.(*GetSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_ResetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResetSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).ResetSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_ResetSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).ResetSession(ctx, req.(*ResetSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_DeleteSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).DeleteSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_DeleteSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).DeleteSession(ctx, req.(*DeleteSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_ListCommands_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListCommandsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).ListCommands(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_ListCommands_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).ListCommands(ctx, req.(*ListCommandsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_ExecuteCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExecuteCommandRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).ExecuteCommand(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_ExecuteCommand_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).ExecuteCommand(ctx, req.(*ExecuteCommandRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_UploadSessionFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UploadSessionFileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).UploadSessionFile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_UploadSessionFile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).UploadSessionFile(ctx, req.(*UploadSessionFileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SessionService_ServiceDesc is the grpc.ServiceDesc for SessionService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SessionService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "aiscan.chat.SessionService", + HandlerType: (*SessionServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListSessions", + Handler: _SessionService_ListSessions_Handler, + }, + { + MethodName: "GetSession", + Handler: _SessionService_GetSession_Handler, + }, + { + MethodName: "ResetSession", + Handler: _SessionService_ResetSession_Handler, + }, + { + MethodName: "DeleteSession", + Handler: _SessionService_DeleteSession_Handler, + }, + { + MethodName: "ListCommands", + Handler: _SessionService_ListCommands_Handler, + }, + { + MethodName: "ExecuteCommand", + Handler: _SessionService_ExecuteCommand_Handler, + }, + { + MethodName: "UploadSessionFile", + Handler: _SessionService_UploadSessionFile_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "aiscan/chat/session.proto", +} diff --git a/aop/aiscan/client.go b/aop/aiscan/client.go new file mode 100644 index 00000000..c486e6b6 --- /dev/null +++ b/aop/aiscan/client.go @@ -0,0 +1,45 @@ +// Package aiscan provides stable client facades over AIScan's generated +// protobuf service groups. +package aiscan + +import ( + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" + "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + "github.com/chainreactors/aiscan/aop/aiscan/scan/scanconnect" + "github.com/chainreactors/aiscan/aop/aopconnect" + "google.golang.org/grpc" +) + +// Client groups the public ConnectRPC APIs while reusing one HTTP transport, +// base URL and option set. +type Client struct { + Chat aopconnect.ChatServiceClient + Sessions chatconnect.SessionServiceClient + Scans scanconnect.ScanServiceClient +} + +func NewClient(httpClient connect.HTTPClient, baseURL string, options ...connect.ClientOption) *Client { + return &Client{ + Chat: aopconnect.NewChatServiceClient(httpClient, baseURL, options...), + Sessions: chatconnect.NewSessionServiceClient(httpClient, baseURL, options...), + Scans: scanconnect.NewScanServiceClient(httpClient, baseURL, options...), + } +} + +// GRPCClient groups the same public APIs over one native gRPC connection. +type GRPCClient struct { + Chat aop.ChatServiceClient + Sessions chatpb.SessionServiceClient + Scans scanpb.ScanServiceClient +} + +func NewGRPCClient(connection grpc.ClientConnInterface) *GRPCClient { + return &GRPCClient{ + Chat: aop.NewChatServiceClient(connection), + Sessions: chatpb.NewSessionServiceClient(connection), + Scans: scanpb.NewScanServiceClient(connection), + } +} diff --git a/aop/aiscan/client_test.go b/aop/aiscan/client_test.go new file mode 100644 index 00000000..741fb336 --- /dev/null +++ b/aop/aiscan/client_test.go @@ -0,0 +1,15 @@ +package aiscan + +import ( + "net/http" + "testing" + + "connectrpc.com/connect" +) + +func TestNewClientInitializesAllPublicServiceGroups(t *testing.T) { + client := NewClient(http.DefaultClient, "http://127.0.0.1:8080", connect.WithProtoJSON()) + if client.Chat == nil || client.Sessions == nil || client.Scans == nil { + t.Fatalf("client groups = %+v", client) + } +} diff --git a/aop/aiscan/extensions/extensions.go b/aop/aiscan/extensions/extensions.go new file mode 100644 index 00000000..cfd09223 --- /dev/null +++ b/aop/aiscan/extensions/extensions.go @@ -0,0 +1,103 @@ +// Package extensions contains AIScan-owned typed AOP extension helpers. +// +// Stable AOP payloads live in the root aop package. Product-specific metadata +// is namespaced here so runtime and transport packages do not need handwritten +// JSON envelopes or duplicate DTOs. +package extensions + +import ( + "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" +) + +const ( + CommandNamespace = "command" + CompactNamespace = "compact" + DelegationNamespace = "delegation" + EvalNamespace = "eval" + WebNamespace = "io.chainreactors.aiscan.web" +) + +const ( + CompactStateStart = "compact_start" + CompactStateEnd = "compact_end" + CompactStateError = "compact_error" + + EvalStateStart = "eval_start" + EvalStateEnd = "eval_end" + EvalStateError = "eval_error" +) + +const ( + DelegationContextFork = "fork" + DelegationContextFresh = "fresh" + DelegationRunBackground = "background" + DelegationRunForeground = "foreground" +) + +type CommandDetail = transport.CommandDetail +type CompactDetail = transport.CompactDetail +type DelegationDetail = transport.DelegationDetail +type EvalControl = transport.EvalControl +type EvalDetail = transport.EvalDetail +type WebMessageExtension = transport.WebMessageExtension + +func GetCommandDetail(event *aop.Event) (CommandDetail, bool, error) { + value := new(CommandDetail) + ok, err := aop.ProtoExtension(event, CommandNamespace, value) + return *value, ok, err +} + +func SetCommandDetail(event *aop.Event, value CommandDetail) error { + return aop.SetProtoExtension(event, CommandNamespace, &value) +} + +func GetCompactDetail(event *aop.Event) (CompactDetail, bool, error) { + value := new(CompactDetail) + ok, err := aop.ProtoExtension(event, CompactNamespace, value) + return *value, ok, err +} + +func SetCompactDetail(event *aop.Event, value CompactDetail) error { + return aop.SetProtoExtension(event, CompactNamespace, &value) +} + +func GetDelegation(event *aop.Event) (DelegationDetail, bool, error) { + value := new(DelegationDetail) + ok, err := aop.ProtoExtension(event, DelegationNamespace, value) + return *value, ok, err +} + +func SetDelegation(event *aop.Event, value DelegationDetail) error { + return aop.SetProtoExtension(event, DelegationNamespace, &value) +} + +func GetEvalControl(event *aop.Event) (EvalControl, bool, error) { + value := new(EvalControl) + ok, err := aop.ProtoExtension(event, EvalNamespace, value) + return *value, ok, err +} + +func SetEvalControl(event *aop.Event, value EvalControl) error { + return aop.SetProtoExtension(event, EvalNamespace, &value) +} + +func GetEvalDetail(event *aop.Event) (EvalDetail, bool, error) { + value := new(EvalDetail) + ok, err := aop.ProtoExtension(event, EvalNamespace, value) + return *value, ok, err +} + +func SetEvalDetail(event *aop.Event, value EvalDetail) error { + return aop.SetProtoExtension(event, EvalNamespace, &value) +} + +func GetWebMessage(event *aop.Event) (WebMessageExtension, bool, error) { + value := new(WebMessageExtension) + ok, err := aop.ProtoExtension(event, WebNamespace, value) + return *value, ok, err +} + +func SetWebMessage(event *aop.Event, value WebMessageExtension) error { + return aop.SetProtoExtension(event, WebNamespace, &value) +} diff --git a/aop/aiscan/scan/scan.pb.go b/aop/aiscan/scan/scan.pb.go new file mode 100644 index 00000000..462e05ec --- /dev/null +++ b/aop/aiscan/scan/scan.pb.go @@ -0,0 +1,1945 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/scan/scan.proto + +package scan + +import ( + aop "github.com/chainreactors/aiscan/aop" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ScanStatus int32 + +const ( + ScanStatus_SCAN_STATUS_UNSPECIFIED ScanStatus = 0 + ScanStatus_SCAN_STATUS_QUEUED ScanStatus = 1 + ScanStatus_SCAN_STATUS_RUNNING ScanStatus = 2 + ScanStatus_SCAN_STATUS_COMPLETED ScanStatus = 3 + ScanStatus_SCAN_STATUS_FAILED ScanStatus = 4 + ScanStatus_SCAN_STATUS_CANCELED ScanStatus = 5 +) + +// Enum value maps for ScanStatus. +var ( + ScanStatus_name = map[int32]string{ + 0: "SCAN_STATUS_UNSPECIFIED", + 1: "SCAN_STATUS_QUEUED", + 2: "SCAN_STATUS_RUNNING", + 3: "SCAN_STATUS_COMPLETED", + 4: "SCAN_STATUS_FAILED", + 5: "SCAN_STATUS_CANCELED", + } + ScanStatus_value = map[string]int32{ + "SCAN_STATUS_UNSPECIFIED": 0, + "SCAN_STATUS_QUEUED": 1, + "SCAN_STATUS_RUNNING": 2, + "SCAN_STATUS_COMPLETED": 3, + "SCAN_STATUS_FAILED": 4, + "SCAN_STATUS_CANCELED": 5, + } +) + +func (x ScanStatus) Enum() *ScanStatus { + p := new(ScanStatus) + *p = x + return p +} + +func (x ScanStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ScanStatus) Descriptor() protoreflect.EnumDescriptor { + return file_aiscan_scan_scan_proto_enumTypes[0].Descriptor() +} + +func (ScanStatus) Type() protoreflect.EnumType { + return &file_aiscan_scan_scan_proto_enumTypes[0] +} + +func (x ScanStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ScanStatus.Descriptor instead. +func (ScanStatus) EnumDescriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{0} +} + +type ScanOptions struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Verify bool `protobuf:"varint,1,opt,name=verify,proto3" json:"verify,omitempty"` + Sniper bool `protobuf:"varint,2,opt,name=sniper,proto3" json:"sniper,omitempty"` + Deep bool `protobuf:"varint,3,opt,name=deep,proto3" json:"deep,omitempty"` +} + +func (x *ScanOptions) Reset() { + *x = ScanOptions{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanOptions) ProtoMessage() {} + +func (x *ScanOptions) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanOptions.ProtoReflect.Descriptor instead. +func (*ScanOptions) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{0} +} + +func (x *ScanOptions) GetVerify() bool { + if x != nil { + return x.Verify + } + return false +} + +func (x *ScanOptions) GetSniper() bool { + if x != nil { + return x.Sniper + } + return false +} + +func (x *ScanOptions) GetDeep() bool { + if x != nil { + return x.Deep + } + return false +} + +type Scan struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + Mode string `protobuf:"bytes,3,opt,name=mode,proto3" json:"mode,omitempty"` + Options *ScanOptions `protobuf:"bytes,4,opt,name=options,proto3" json:"options,omitempty"` + Status ScanStatus `protobuf:"varint,5,opt,name=status,proto3,enum=aiscan.scan.ScanStatus" json:"status,omitempty"` + Progress string `protobuf:"bytes,6,opt,name=progress,proto3" json:"progress,omitempty"` + Report string `protobuf:"bytes,7,opt,name=report,proto3" json:"report,omitempty"` + Result *aop.EncodedValue `protobuf:"bytes,8,opt,name=result,proto3" json:"result,omitempty"` + Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` +} + +func (x *Scan) Reset() { + *x = Scan{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Scan) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Scan) ProtoMessage() {} + +func (x *Scan) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Scan.ProtoReflect.Descriptor instead. +func (*Scan) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{1} +} + +func (x *Scan) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Scan) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *Scan) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *Scan) GetOptions() *ScanOptions { + if x != nil { + return x.Options + } + return nil +} + +func (x *Scan) GetStatus() ScanStatus { + if x != nil { + return x.Status + } + return ScanStatus_SCAN_STATUS_UNSPECIFIED +} + +func (x *Scan) GetProgress() string { + if x != nil { + return x.Progress + } + return "" +} + +func (x *Scan) GetReport() string { + if x != nil { + return x.Report + } + return "" +} + +func (x *Scan) GetResult() *aop.EncodedValue { + if x != nil { + return x.Result + } + return nil +} + +func (x *Scan) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *Scan) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *Scan) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +type SubmitScanRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + Mode string `protobuf:"bytes,3,opt,name=mode,proto3" json:"mode,omitempty"` + Options *ScanOptions `protobuf:"bytes,4,opt,name=options,proto3" json:"options,omitempty"` +} + +func (x *SubmitScanRequest) Reset() { + *x = SubmitScanRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubmitScanRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitScanRequest) ProtoMessage() {} + +func (x *SubmitScanRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitScanRequest.ProtoReflect.Descriptor instead. +func (*SubmitScanRequest) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{2} +} + +func (x *SubmitScanRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *SubmitScanRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *SubmitScanRequest) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *SubmitScanRequest) GetOptions() *ScanOptions { + if x != nil { + return x.Options + } + return nil +} + +type SubmitScanResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *SubmitScanResponse_Accepted + // *SubmitScanResponse_Rejected + Outcome isSubmitScanResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *SubmitScanResponse) Reset() { + *x = SubmitScanResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubmitScanResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitScanResponse) ProtoMessage() {} + +func (x *SubmitScanResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitScanResponse.ProtoReflect.Descriptor instead. +func (*SubmitScanResponse) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{3} +} + +func (x *SubmitScanResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *SubmitScanResponse) GetOutcome() isSubmitScanResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *SubmitScanResponse) GetAccepted() *Scan { + if x, ok := x.GetOutcome().(*SubmitScanResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *SubmitScanResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*SubmitScanResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isSubmitScanResponse_Outcome interface { + isSubmitScanResponse_Outcome() +} + +type SubmitScanResponse_Accepted struct { + Accepted *Scan `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type SubmitScanResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*SubmitScanResponse_Accepted) isSubmitScanResponse_Outcome() {} + +func (*SubmitScanResponse_Rejected) isSubmitScanResponse_Outcome() {} + +type GetScanRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScanId string `protobuf:"bytes,1,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` +} + +func (x *GetScanRequest) Reset() { + *x = GetScanRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetScanRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetScanRequest) ProtoMessage() {} + +func (x *GetScanRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetScanRequest.ProtoReflect.Descriptor instead. +func (*GetScanRequest) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{4} +} + +func (x *GetScanRequest) GetScanId() string { + if x != nil { + return x.ScanId + } + return "" +} + +type GetScanResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Scan *Scan `protobuf:"bytes,1,opt,name=scan,proto3" json:"scan,omitempty"` +} + +func (x *GetScanResponse) Reset() { + *x = GetScanResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetScanResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetScanResponse) ProtoMessage() {} + +func (x *GetScanResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetScanResponse.ProtoReflect.Descriptor instead. +func (*GetScanResponse) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{5} +} + +func (x *GetScanResponse) GetScan() *Scan { + if x != nil { + return x.Scan + } + return nil +} + +type ListScansRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ListScansRequest) Reset() { + *x = ListScansRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListScansRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListScansRequest) ProtoMessage() {} + +func (x *ListScansRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListScansRequest.ProtoReflect.Descriptor instead. +func (*ListScansRequest) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{6} +} + +type ListScansResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Scans []*Scan `protobuf:"bytes,1,rep,name=scans,proto3" json:"scans,omitempty"` +} + +func (x *ListScansResponse) Reset() { + *x = ListScansResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListScansResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListScansResponse) ProtoMessage() {} + +func (x *ListScansResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListScansResponse.ProtoReflect.Descriptor instead. +func (*ListScansResponse) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{7} +} + +func (x *ListScansResponse) GetScans() []*Scan { + if x != nil { + return x.Scans + } + return nil +} + +type CancelScanRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + ScanId string `protobuf:"bytes,2,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` +} + +func (x *CancelScanRequest) Reset() { + *x = CancelScanRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelScanRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelScanRequest) ProtoMessage() {} + +func (x *CancelScanRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelScanRequest.ProtoReflect.Descriptor instead. +func (*CancelScanRequest) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{8} +} + +func (x *CancelScanRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *CancelScanRequest) GetScanId() string { + if x != nil { + return x.ScanId + } + return "" +} + +type CancelScanResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *CancelScanResponse_Accepted + // *CancelScanResponse_Rejected + Outcome isCancelScanResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *CancelScanResponse) Reset() { + *x = CancelScanResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelScanResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelScanResponse) ProtoMessage() {} + +func (x *CancelScanResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelScanResponse.ProtoReflect.Descriptor instead. +func (*CancelScanResponse) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{9} +} + +func (x *CancelScanResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *CancelScanResponse) GetOutcome() isCancelScanResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *CancelScanResponse) GetAccepted() *Scan { + if x, ok := x.GetOutcome().(*CancelScanResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *CancelScanResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*CancelScanResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isCancelScanResponse_Outcome interface { + isCancelScanResponse_Outcome() +} + +type CancelScanResponse_Accepted struct { + Accepted *Scan `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type CancelScanResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*CancelScanResponse_Accepted) isCancelScanResponse_Outcome() {} + +func (*CancelScanResponse_Rejected) isCancelScanResponse_Outcome() {} + +type WatchScanEventsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScanId string `protobuf:"bytes,1,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` +} + +func (x *WatchScanEventsRequest) Reset() { + *x = WatchScanEventsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WatchScanEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchScanEventsRequest) ProtoMessage() {} + +func (x *WatchScanEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchScanEventsRequest.ProtoReflect.Descriptor instead. +func (*WatchScanEventsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{10} +} + +func (x *WatchScanEventsRequest) GetScanId() string { + if x != nil { + return x.ScanId + } + return "" +} + +type ScanProgress struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data string `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *ScanProgress) Reset() { + *x = ScanProgress{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanProgress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanProgress) ProtoMessage() {} + +func (x *ScanProgress) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanProgress.ProtoReflect.Descriptor instead. +func (*ScanProgress) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{11} +} + +func (x *ScanProgress) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +type ScanStats struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Values map[string]uint64 `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` +} + +func (x *ScanStats) Reset() { + *x = ScanStats{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanStats) ProtoMessage() {} + +func (x *ScanStats) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanStats.ProtoReflect.Descriptor instead. +func (*ScanStats) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{12} +} + +func (x *ScanStats) GetValues() map[string]uint64 { + if x != nil { + return x.Values + } + return nil +} + +type ScanCompleted struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Result *aop.EncodedValue `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` +} + +func (x *ScanCompleted) Reset() { + *x = ScanCompleted{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanCompleted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanCompleted) ProtoMessage() {} + +func (x *ScanCompleted) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanCompleted.ProtoReflect.Descriptor instead. +func (*ScanCompleted) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{13} +} + +func (x *ScanCompleted) GetResult() *aop.EncodedValue { + if x != nil { + return x.Result + } + return nil +} + +type ScanFailed struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Canceled bool `protobuf:"varint,2,opt,name=canceled,proto3" json:"canceled,omitempty"` +} + +func (x *ScanFailed) Reset() { + *x = ScanFailed{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanFailed) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanFailed) ProtoMessage() {} + +func (x *ScanFailed) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanFailed.ProtoReflect.Descriptor instead. +func (*ScanFailed) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{14} +} + +func (x *ScanFailed) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ScanFailed) GetCanceled() bool { + if x != nil { + return x.Canceled + } + return false +} + +// SessionScanEvent links a completed scan into an AOP session timeline without +// reintroducing a parallel web-only domain event envelope. +type SessionScanEvent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScanId string `protobuf:"bytes,1,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` + Status ScanStatus `protobuf:"varint,2,opt,name=status,proto3,enum=aiscan.scan.ScanStatus" json:"status,omitempty"` +} + +func (x *SessionScanEvent) Reset() { + *x = SessionScanEvent{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SessionScanEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionScanEvent) ProtoMessage() {} + +func (x *SessionScanEvent) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionScanEvent.ProtoReflect.Descriptor instead. +func (*SessionScanEvent) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{15} +} + +func (x *SessionScanEvent) GetScanId() string { + if x != nil { + return x.ScanId + } + return "" +} + +func (x *SessionScanEvent) GetStatus() ScanStatus { + if x != nil { + return x.Status + } + return ScanStatus_SCAN_STATUS_UNSPECIFIED +} + +type ScanEvent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScanId string `protobuf:"bytes,1,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` + Sequence uint64 `protobuf:"varint,2,opt,name=sequence,proto3" json:"sequence,omitempty"` + EmittedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=emitted_at,json=emittedAt,proto3" json:"emitted_at,omitempty"` + // Types that are assignable to Payload: + // + // *ScanEvent_Snapshot + // *ScanEvent_Status + // *ScanEvent_Progress + // *ScanEvent_Stats + // *ScanEvent_Completed + // *ScanEvent_Failed + Payload isScanEvent_Payload `protobuf_oneof:"payload"` +} + +func (x *ScanEvent) Reset() { + *x = ScanEvent{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanEvent) ProtoMessage() {} + +func (x *ScanEvent) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanEvent.ProtoReflect.Descriptor instead. +func (*ScanEvent) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{16} +} + +func (x *ScanEvent) GetScanId() string { + if x != nil { + return x.ScanId + } + return "" +} + +func (x *ScanEvent) GetSequence() uint64 { + if x != nil { + return x.Sequence + } + return 0 +} + +func (x *ScanEvent) GetEmittedAt() *timestamppb.Timestamp { + if x != nil { + return x.EmittedAt + } + return nil +} + +func (m *ScanEvent) GetPayload() isScanEvent_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *ScanEvent) GetSnapshot() *Scan { + if x, ok := x.GetPayload().(*ScanEvent_Snapshot); ok { + return x.Snapshot + } + return nil +} + +func (x *ScanEvent) GetStatus() ScanStatus { + if x, ok := x.GetPayload().(*ScanEvent_Status); ok { + return x.Status + } + return ScanStatus_SCAN_STATUS_UNSPECIFIED +} + +func (x *ScanEvent) GetProgress() *ScanProgress { + if x, ok := x.GetPayload().(*ScanEvent_Progress); ok { + return x.Progress + } + return nil +} + +func (x *ScanEvent) GetStats() *ScanStats { + if x, ok := x.GetPayload().(*ScanEvent_Stats); ok { + return x.Stats + } + return nil +} + +func (x *ScanEvent) GetCompleted() *ScanCompleted { + if x, ok := x.GetPayload().(*ScanEvent_Completed); ok { + return x.Completed + } + return nil +} + +func (x *ScanEvent) GetFailed() *ScanFailed { + if x, ok := x.GetPayload().(*ScanEvent_Failed); ok { + return x.Failed + } + return nil +} + +type isScanEvent_Payload interface { + isScanEvent_Payload() +} + +type ScanEvent_Snapshot struct { + Snapshot *Scan `protobuf:"bytes,10,opt,name=snapshot,proto3,oneof"` +} + +type ScanEvent_Status struct { + Status ScanStatus `protobuf:"varint,11,opt,name=status,proto3,enum=aiscan.scan.ScanStatus,oneof"` +} + +type ScanEvent_Progress struct { + Progress *ScanProgress `protobuf:"bytes,12,opt,name=progress,proto3,oneof"` +} + +type ScanEvent_Stats struct { + Stats *ScanStats `protobuf:"bytes,13,opt,name=stats,proto3,oneof"` +} + +type ScanEvent_Completed struct { + Completed *ScanCompleted `protobuf:"bytes,14,opt,name=completed,proto3,oneof"` +} + +type ScanEvent_Failed struct { + Failed *ScanFailed `protobuf:"bytes,15,opt,name=failed,proto3,oneof"` +} + +func (*ScanEvent_Snapshot) isScanEvent_Payload() {} + +func (*ScanEvent_Status) isScanEvent_Payload() {} + +func (*ScanEvent_Progress) isScanEvent_Payload() {} + +func (*ScanEvent_Stats) isScanEvent_Payload() {} + +func (*ScanEvent_Completed) isScanEvent_Payload() {} + +func (*ScanEvent_Failed) isScanEvent_Payload() {} + +type WatchScanEventsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Event *ScanEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` +} + +func (x *WatchScanEventsResponse) Reset() { + *x = WatchScanEventsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WatchScanEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchScanEventsResponse) ProtoMessage() {} + +func (x *WatchScanEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchScanEventsResponse.ProtoReflect.Descriptor instead. +func (*WatchScanEventsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{17} +} + +func (x *WatchScanEventsResponse) GetEvent() *ScanEvent { + if x != nil { + return x.Event + } + return nil +} + +type GetScanReportRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScanId string `protobuf:"bytes,1,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` + Language string `protobuf:"bytes,2,opt,name=language,proto3" json:"language,omitempty"` +} + +func (x *GetScanReportRequest) Reset() { + *x = GetScanReportRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetScanReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetScanReportRequest) ProtoMessage() {} + +func (x *GetScanReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetScanReportRequest.ProtoReflect.Descriptor instead. +func (*GetScanReportRequest) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{18} +} + +func (x *GetScanReportRequest) GetScanId() string { + if x != nil { + return x.ScanId + } + return "" +} + +func (x *GetScanReportRequest) GetLanguage() string { + if x != nil { + return x.Language + } + return "" +} + +type GetScanReportResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Markdown string `protobuf:"bytes,1,opt,name=markdown,proto3" json:"markdown,omitempty"` + MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *GetScanReportResponse) Reset() { + *x = GetScanReportResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_scan_scan_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetScanReportResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetScanReportResponse) ProtoMessage() {} + +func (x *GetScanReportResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_scan_scan_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetScanReportResponse.ProtoReflect.Descriptor instead. +func (*GetScanReportResponse) Descriptor() ([]byte, []int) { + return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{19} +} + +func (x *GetScanReportResponse) GetMarkdown() string { + if x != nil { + return x.Markdown + } + return "" +} + +func (x *GetScanReportResponse) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +var File_aiscan_scan_scan_proto protoreflect.FileDescriptor + +var file_aiscan_scan_scan_proto_rawDesc = []byte{ + 0x0a, 0x16, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x51, 0x0a, 0x0b, 0x53, 0x63, 0x61, 0x6e, 0x4f, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x16, + 0x0a, 0x06, 0x73, 0x6e, 0x69, 0x70, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, + 0x73, 0x6e, 0x69, 0x70, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x65, 0x70, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x65, 0x70, 0x22, 0x92, 0x03, 0x0a, 0x04, 0x53, + 0x63, 0x61, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6d, + 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, + 0x32, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x18, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, + 0x63, 0x61, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, + 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x72, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, + 0x92, 0x01, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, + 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, + 0x12, 0x32, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x9d, 0x01, 0x0a, 0x12, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, + 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x08, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x48, + 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, + 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, + 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, + 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x29, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, + 0x38, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x73, 0x63, 0x61, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, + 0x63, 0x61, 0x6e, 0x52, 0x04, 0x73, 0x63, 0x61, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x4c, 0x69, 0x73, + 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x3c, 0x0a, + 0x11, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x73, 0x63, 0x61, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x52, 0x05, 0x73, 0x63, 0x61, 0x6e, 0x73, 0x22, 0x4b, 0x0a, 0x11, 0x43, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, + 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, 0x9d, 0x01, 0x0a, 0x12, 0x43, 0x61, 0x6e, + 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2f, + 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, + 0x63, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, + 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, + 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x31, 0x0a, 0x16, 0x57, 0x61, 0x74, 0x63, + 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, 0x22, 0x0a, 0x0c, 0x53, + 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, + 0x82, 0x01, 0x0a, 0x09, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x3a, 0x0a, + 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, + 0x53, 0x74, 0x61, 0x74, 0x73, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3a, 0x0a, 0x0d, 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, + 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x22, 0x42, 0x0a, 0x0a, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x12, 0x18, + 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6e, 0x63, + 0x65, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x61, 0x6e, 0x63, + 0x65, 0x6c, 0x65, 0x64, 0x22, 0x5c, 0x0a, 0x10, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, + 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, + 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x22, 0xc2, 0x03, 0x0a, 0x09, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x71, + 0x75, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x08, 0x73, 0x65, 0x71, + 0x75, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x41, 0x74, + 0x12, 0x2f, 0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, + 0x74, 0x12, 0x31, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x12, 0x37, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, + 0x73, 0x48, 0x00, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2e, 0x0a, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x53, + 0x74, 0x61, 0x74, 0x73, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x12, 0x3a, 0x0a, + 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, + 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x09, + 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x06, 0x66, 0x61, 0x69, + 0x6c, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x61, 0x69, 0x6c, + 0x65, 0x64, 0x48, 0x00, 0x52, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, + 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x47, 0x0a, 0x17, 0x57, 0x61, 0x74, 0x63, 0x68, + 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, + 0x22, 0x4b, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, + 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x22, 0x52, 0x0a, + 0x15, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x61, 0x72, 0x6b, 0x64, 0x6f, + 0x77, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x61, 0x72, 0x6b, 0x64, 0x6f, + 0x77, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, + 0x65, 0x2a, 0xa7, 0x01, 0x0a, 0x0a, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x12, 0x1b, 0x0a, 0x17, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, + 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x16, 0x0a, + 0x12, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x51, 0x55, 0x45, + 0x55, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, + 0x41, 0x54, 0x55, 0x53, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x19, + 0x0a, 0x15, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x4f, + 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x43, 0x41, + 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, + 0x04, 0x12, 0x18, 0x0a, 0x14, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, + 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x32, 0xf5, 0x03, 0x0a, 0x0b, + 0x53, 0x63, 0x61, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4d, 0x0a, 0x0a, 0x53, + 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, + 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, + 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x07, 0x47, 0x65, + 0x74, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x1b, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x4a, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x12, 0x1d, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, + 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0a, + 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, + 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, + 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0f, 0x57, + 0x61, 0x74, 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x23, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x56, 0x0a, 0x0d, 0x47, + 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x21, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, + 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, + 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x3b, 0x73, 0x63, 0x61, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_scan_scan_proto_rawDescOnce sync.Once + file_aiscan_scan_scan_proto_rawDescData = file_aiscan_scan_scan_proto_rawDesc +) + +func file_aiscan_scan_scan_proto_rawDescGZIP() []byte { + file_aiscan_scan_scan_proto_rawDescOnce.Do(func() { + file_aiscan_scan_scan_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_scan_scan_proto_rawDescData) + }) + return file_aiscan_scan_scan_proto_rawDescData +} + +var file_aiscan_scan_scan_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_aiscan_scan_scan_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_aiscan_scan_scan_proto_goTypes = []interface{}{ + (ScanStatus)(0), // 0: aiscan.scan.ScanStatus + (*ScanOptions)(nil), // 1: aiscan.scan.ScanOptions + (*Scan)(nil), // 2: aiscan.scan.Scan + (*SubmitScanRequest)(nil), // 3: aiscan.scan.SubmitScanRequest + (*SubmitScanResponse)(nil), // 4: aiscan.scan.SubmitScanResponse + (*GetScanRequest)(nil), // 5: aiscan.scan.GetScanRequest + (*GetScanResponse)(nil), // 6: aiscan.scan.GetScanResponse + (*ListScansRequest)(nil), // 7: aiscan.scan.ListScansRequest + (*ListScansResponse)(nil), // 8: aiscan.scan.ListScansResponse + (*CancelScanRequest)(nil), // 9: aiscan.scan.CancelScanRequest + (*CancelScanResponse)(nil), // 10: aiscan.scan.CancelScanResponse + (*WatchScanEventsRequest)(nil), // 11: aiscan.scan.WatchScanEventsRequest + (*ScanProgress)(nil), // 12: aiscan.scan.ScanProgress + (*ScanStats)(nil), // 13: aiscan.scan.ScanStats + (*ScanCompleted)(nil), // 14: aiscan.scan.ScanCompleted + (*ScanFailed)(nil), // 15: aiscan.scan.ScanFailed + (*SessionScanEvent)(nil), // 16: aiscan.scan.SessionScanEvent + (*ScanEvent)(nil), // 17: aiscan.scan.ScanEvent + (*WatchScanEventsResponse)(nil), // 18: aiscan.scan.WatchScanEventsResponse + (*GetScanReportRequest)(nil), // 19: aiscan.scan.GetScanReportRequest + (*GetScanReportResponse)(nil), // 20: aiscan.scan.GetScanReportResponse + nil, // 21: aiscan.scan.ScanStats.ValuesEntry + (*aop.EncodedValue)(nil), // 22: aop.EncodedValue + (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp + (*aop.Rejection)(nil), // 24: aop.Rejection +} +var file_aiscan_scan_scan_proto_depIdxs = []int32{ + 1, // 0: aiscan.scan.Scan.options:type_name -> aiscan.scan.ScanOptions + 0, // 1: aiscan.scan.Scan.status:type_name -> aiscan.scan.ScanStatus + 22, // 2: aiscan.scan.Scan.result:type_name -> aop.EncodedValue + 23, // 3: aiscan.scan.Scan.created_at:type_name -> google.protobuf.Timestamp + 23, // 4: aiscan.scan.Scan.updated_at:type_name -> google.protobuf.Timestamp + 1, // 5: aiscan.scan.SubmitScanRequest.options:type_name -> aiscan.scan.ScanOptions + 2, // 6: aiscan.scan.SubmitScanResponse.accepted:type_name -> aiscan.scan.Scan + 24, // 7: aiscan.scan.SubmitScanResponse.rejected:type_name -> aop.Rejection + 2, // 8: aiscan.scan.GetScanResponse.scan:type_name -> aiscan.scan.Scan + 2, // 9: aiscan.scan.ListScansResponse.scans:type_name -> aiscan.scan.Scan + 2, // 10: aiscan.scan.CancelScanResponse.accepted:type_name -> aiscan.scan.Scan + 24, // 11: aiscan.scan.CancelScanResponse.rejected:type_name -> aop.Rejection + 21, // 12: aiscan.scan.ScanStats.values:type_name -> aiscan.scan.ScanStats.ValuesEntry + 22, // 13: aiscan.scan.ScanCompleted.result:type_name -> aop.EncodedValue + 0, // 14: aiscan.scan.SessionScanEvent.status:type_name -> aiscan.scan.ScanStatus + 23, // 15: aiscan.scan.ScanEvent.emitted_at:type_name -> google.protobuf.Timestamp + 2, // 16: aiscan.scan.ScanEvent.snapshot:type_name -> aiscan.scan.Scan + 0, // 17: aiscan.scan.ScanEvent.status:type_name -> aiscan.scan.ScanStatus + 12, // 18: aiscan.scan.ScanEvent.progress:type_name -> aiscan.scan.ScanProgress + 13, // 19: aiscan.scan.ScanEvent.stats:type_name -> aiscan.scan.ScanStats + 14, // 20: aiscan.scan.ScanEvent.completed:type_name -> aiscan.scan.ScanCompleted + 15, // 21: aiscan.scan.ScanEvent.failed:type_name -> aiscan.scan.ScanFailed + 17, // 22: aiscan.scan.WatchScanEventsResponse.event:type_name -> aiscan.scan.ScanEvent + 3, // 23: aiscan.scan.ScanService.SubmitScan:input_type -> aiscan.scan.SubmitScanRequest + 5, // 24: aiscan.scan.ScanService.GetScan:input_type -> aiscan.scan.GetScanRequest + 7, // 25: aiscan.scan.ScanService.ListScans:input_type -> aiscan.scan.ListScansRequest + 9, // 26: aiscan.scan.ScanService.CancelScan:input_type -> aiscan.scan.CancelScanRequest + 11, // 27: aiscan.scan.ScanService.WatchScanEvents:input_type -> aiscan.scan.WatchScanEventsRequest + 19, // 28: aiscan.scan.ScanService.GetScanReport:input_type -> aiscan.scan.GetScanReportRequest + 4, // 29: aiscan.scan.ScanService.SubmitScan:output_type -> aiscan.scan.SubmitScanResponse + 6, // 30: aiscan.scan.ScanService.GetScan:output_type -> aiscan.scan.GetScanResponse + 8, // 31: aiscan.scan.ScanService.ListScans:output_type -> aiscan.scan.ListScansResponse + 10, // 32: aiscan.scan.ScanService.CancelScan:output_type -> aiscan.scan.CancelScanResponse + 18, // 33: aiscan.scan.ScanService.WatchScanEvents:output_type -> aiscan.scan.WatchScanEventsResponse + 20, // 34: aiscan.scan.ScanService.GetScanReport:output_type -> aiscan.scan.GetScanReportResponse + 29, // [29:35] is the sub-list for method output_type + 23, // [23:29] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name +} + +func init() { file_aiscan_scan_scan_proto_init() } +func file_aiscan_scan_scan_proto_init() { + if File_aiscan_scan_scan_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_scan_scan_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanOptions); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Scan); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubmitScanRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubmitScanResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetScanRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetScanResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListScansRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListScansResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelScanRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelScanResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WatchScanEventsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanProgress); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanStats); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanCompleted); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanFailed); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SessionScanEvent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanEvent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WatchScanEventsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetScanReportRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_scan_scan_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetScanReportResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aiscan_scan_scan_proto_msgTypes[3].OneofWrappers = []interface{}{ + (*SubmitScanResponse_Accepted)(nil), + (*SubmitScanResponse_Rejected)(nil), + } + file_aiscan_scan_scan_proto_msgTypes[9].OneofWrappers = []interface{}{ + (*CancelScanResponse_Accepted)(nil), + (*CancelScanResponse_Rejected)(nil), + } + file_aiscan_scan_scan_proto_msgTypes[16].OneofWrappers = []interface{}{ + (*ScanEvent_Snapshot)(nil), + (*ScanEvent_Status)(nil), + (*ScanEvent_Progress)(nil), + (*ScanEvent_Stats)(nil), + (*ScanEvent_Completed)(nil), + (*ScanEvent_Failed)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_scan_scan_proto_rawDesc, + NumEnums: 1, + NumMessages: 21, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_scan_scan_proto_goTypes, + DependencyIndexes: file_aiscan_scan_scan_proto_depIdxs, + EnumInfos: file_aiscan_scan_scan_proto_enumTypes, + MessageInfos: file_aiscan_scan_scan_proto_msgTypes, + }.Build() + File_aiscan_scan_scan_proto = out.File + file_aiscan_scan_scan_proto_rawDesc = nil + file_aiscan_scan_scan_proto_goTypes = nil + file_aiscan_scan_scan_proto_depIdxs = nil +} diff --git a/aop/aiscan/scan/scan_grpc.pb.go b/aop/aiscan/scan/scan_grpc.pb.go new file mode 100644 index 00000000..6778440d --- /dev/null +++ b/aop/aiscan/scan/scan_grpc.pb.go @@ -0,0 +1,322 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v6.33.0 +// source: aiscan/scan/scan.proto + +package scan + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + ScanService_SubmitScan_FullMethodName = "/aiscan.scan.ScanService/SubmitScan" + ScanService_GetScan_FullMethodName = "/aiscan.scan.ScanService/GetScan" + ScanService_ListScans_FullMethodName = "/aiscan.scan.ScanService/ListScans" + ScanService_CancelScan_FullMethodName = "/aiscan.scan.ScanService/CancelScan" + ScanService_WatchScanEvents_FullMethodName = "/aiscan.scan.ScanService/WatchScanEvents" + ScanService_GetScanReport_FullMethodName = "/aiscan.scan.ScanService/GetScanReport" +) + +// ScanServiceClient is the client API for ScanService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ScanServiceClient interface { + SubmitScan(ctx context.Context, in *SubmitScanRequest, opts ...grpc.CallOption) (*SubmitScanResponse, error) + GetScan(ctx context.Context, in *GetScanRequest, opts ...grpc.CallOption) (*GetScanResponse, error) + ListScans(ctx context.Context, in *ListScansRequest, opts ...grpc.CallOption) (*ListScansResponse, error) + CancelScan(ctx context.Context, in *CancelScanRequest, opts ...grpc.CallOption) (*CancelScanResponse, error) + WatchScanEvents(ctx context.Context, in *WatchScanEventsRequest, opts ...grpc.CallOption) (ScanService_WatchScanEventsClient, error) + GetScanReport(ctx context.Context, in *GetScanReportRequest, opts ...grpc.CallOption) (*GetScanReportResponse, error) +} + +type scanServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewScanServiceClient(cc grpc.ClientConnInterface) ScanServiceClient { + return &scanServiceClient{cc} +} + +func (c *scanServiceClient) SubmitScan(ctx context.Context, in *SubmitScanRequest, opts ...grpc.CallOption) (*SubmitScanResponse, error) { + out := new(SubmitScanResponse) + err := c.cc.Invoke(ctx, ScanService_SubmitScan_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *scanServiceClient) GetScan(ctx context.Context, in *GetScanRequest, opts ...grpc.CallOption) (*GetScanResponse, error) { + out := new(GetScanResponse) + err := c.cc.Invoke(ctx, ScanService_GetScan_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *scanServiceClient) ListScans(ctx context.Context, in *ListScansRequest, opts ...grpc.CallOption) (*ListScansResponse, error) { + out := new(ListScansResponse) + err := c.cc.Invoke(ctx, ScanService_ListScans_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *scanServiceClient) CancelScan(ctx context.Context, in *CancelScanRequest, opts ...grpc.CallOption) (*CancelScanResponse, error) { + out := new(CancelScanResponse) + err := c.cc.Invoke(ctx, ScanService_CancelScan_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *scanServiceClient) WatchScanEvents(ctx context.Context, in *WatchScanEventsRequest, opts ...grpc.CallOption) (ScanService_WatchScanEventsClient, error) { + stream, err := c.cc.NewStream(ctx, &ScanService_ServiceDesc.Streams[0], ScanService_WatchScanEvents_FullMethodName, opts...) + if err != nil { + return nil, err + } + x := &scanServiceWatchScanEventsClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type ScanService_WatchScanEventsClient interface { + Recv() (*WatchScanEventsResponse, error) + grpc.ClientStream +} + +type scanServiceWatchScanEventsClient struct { + grpc.ClientStream +} + +func (x *scanServiceWatchScanEventsClient) Recv() (*WatchScanEventsResponse, error) { + m := new(WatchScanEventsResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +func (c *scanServiceClient) GetScanReport(ctx context.Context, in *GetScanReportRequest, opts ...grpc.CallOption) (*GetScanReportResponse, error) { + out := new(GetScanReportResponse) + err := c.cc.Invoke(ctx, ScanService_GetScanReport_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ScanServiceServer is the server API for ScanService service. +// All implementations must embed UnimplementedScanServiceServer +// for forward compatibility +type ScanServiceServer interface { + SubmitScan(context.Context, *SubmitScanRequest) (*SubmitScanResponse, error) + GetScan(context.Context, *GetScanRequest) (*GetScanResponse, error) + ListScans(context.Context, *ListScansRequest) (*ListScansResponse, error) + CancelScan(context.Context, *CancelScanRequest) (*CancelScanResponse, error) + WatchScanEvents(*WatchScanEventsRequest, ScanService_WatchScanEventsServer) error + GetScanReport(context.Context, *GetScanReportRequest) (*GetScanReportResponse, error) + mustEmbedUnimplementedScanServiceServer() +} + +// UnimplementedScanServiceServer must be embedded to have forward compatible implementations. +type UnimplementedScanServiceServer struct { +} + +func (UnimplementedScanServiceServer) SubmitScan(context.Context, *SubmitScanRequest) (*SubmitScanResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SubmitScan not implemented") +} +func (UnimplementedScanServiceServer) GetScan(context.Context, *GetScanRequest) (*GetScanResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetScan not implemented") +} +func (UnimplementedScanServiceServer) ListScans(context.Context, *ListScansRequest) (*ListScansResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListScans not implemented") +} +func (UnimplementedScanServiceServer) CancelScan(context.Context, *CancelScanRequest) (*CancelScanResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelScan not implemented") +} +func (UnimplementedScanServiceServer) WatchScanEvents(*WatchScanEventsRequest, ScanService_WatchScanEventsServer) error { + return status.Errorf(codes.Unimplemented, "method WatchScanEvents not implemented") +} +func (UnimplementedScanServiceServer) GetScanReport(context.Context, *GetScanReportRequest) (*GetScanReportResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetScanReport not implemented") +} +func (UnimplementedScanServiceServer) mustEmbedUnimplementedScanServiceServer() {} + +// UnsafeScanServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ScanServiceServer will +// result in compilation errors. +type UnsafeScanServiceServer interface { + mustEmbedUnimplementedScanServiceServer() +} + +func RegisterScanServiceServer(s grpc.ServiceRegistrar, srv ScanServiceServer) { + s.RegisterService(&ScanService_ServiceDesc, srv) +} + +func _ScanService_SubmitScan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitScanRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScanServiceServer).SubmitScan(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScanService_SubmitScan_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScanServiceServer).SubmitScan(ctx, req.(*SubmitScanRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ScanService_GetScan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetScanRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScanServiceServer).GetScan(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScanService_GetScan_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScanServiceServer).GetScan(ctx, req.(*GetScanRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ScanService_ListScans_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListScansRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScanServiceServer).ListScans(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScanService_ListScans_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScanServiceServer).ListScans(ctx, req.(*ListScansRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ScanService_CancelScan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelScanRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScanServiceServer).CancelScan(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScanService_CancelScan_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScanServiceServer).CancelScan(ctx, req.(*CancelScanRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ScanService_WatchScanEvents_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(WatchScanEventsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ScanServiceServer).WatchScanEvents(m, &scanServiceWatchScanEventsServer{stream}) +} + +type ScanService_WatchScanEventsServer interface { + Send(*WatchScanEventsResponse) error + grpc.ServerStream +} + +type scanServiceWatchScanEventsServer struct { + grpc.ServerStream +} + +func (x *scanServiceWatchScanEventsServer) Send(m *WatchScanEventsResponse) error { + return x.ServerStream.SendMsg(m) +} + +func _ScanService_GetScanReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetScanReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScanServiceServer).GetScanReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScanService_GetScanReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScanServiceServer).GetScanReport(ctx, req.(*GetScanReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ScanService_ServiceDesc is the grpc.ServiceDesc for ScanService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ScanService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "aiscan.scan.ScanService", + HandlerType: (*ScanServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SubmitScan", + Handler: _ScanService_SubmitScan_Handler, + }, + { + MethodName: "GetScan", + Handler: _ScanService_GetScan_Handler, + }, + { + MethodName: "ListScans", + Handler: _ScanService_ListScans_Handler, + }, + { + MethodName: "CancelScan", + Handler: _ScanService_CancelScan_Handler, + }, + { + MethodName: "GetScanReport", + Handler: _ScanService_GetScanReport_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "WatchScanEvents", + Handler: _ScanService_WatchScanEvents_Handler, + ServerStreams: true, + }, + }, + Metadata: "aiscan/scan/scan.proto", +} diff --git a/aop/aiscan/scan/scanconnect/scan.connect.go b/aop/aiscan/scan/scanconnect/scan.connect.go new file mode 100644 index 00000000..b221e4bf --- /dev/null +++ b/aop/aiscan/scan/scanconnect/scan.connect.go @@ -0,0 +1,250 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: aiscan/scan/scan.proto + +package scanconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + scan "github.com/chainreactors/aiscan/aop/aiscan/scan" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ScanServiceName is the fully-qualified name of the ScanService service. + ScanServiceName = "aiscan.scan.ScanService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ScanServiceSubmitScanProcedure is the fully-qualified name of the ScanService's SubmitScan RPC. + ScanServiceSubmitScanProcedure = "/aiscan.scan.ScanService/SubmitScan" + // ScanServiceGetScanProcedure is the fully-qualified name of the ScanService's GetScan RPC. + ScanServiceGetScanProcedure = "/aiscan.scan.ScanService/GetScan" + // ScanServiceListScansProcedure is the fully-qualified name of the ScanService's ListScans RPC. + ScanServiceListScansProcedure = "/aiscan.scan.ScanService/ListScans" + // ScanServiceCancelScanProcedure is the fully-qualified name of the ScanService's CancelScan RPC. + ScanServiceCancelScanProcedure = "/aiscan.scan.ScanService/CancelScan" + // ScanServiceWatchScanEventsProcedure is the fully-qualified name of the ScanService's + // WatchScanEvents RPC. + ScanServiceWatchScanEventsProcedure = "/aiscan.scan.ScanService/WatchScanEvents" + // ScanServiceGetScanReportProcedure is the fully-qualified name of the ScanService's GetScanReport + // RPC. + ScanServiceGetScanReportProcedure = "/aiscan.scan.ScanService/GetScanReport" +) + +// ScanServiceClient is a client for the aiscan.scan.ScanService service. +type ScanServiceClient interface { + SubmitScan(context.Context, *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) + GetScan(context.Context, *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) + ListScans(context.Context, *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) + CancelScan(context.Context, *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) + WatchScanEvents(context.Context, *connect.Request[scan.WatchScanEventsRequest]) (*connect.ServerStreamForClient[scan.WatchScanEventsResponse], error) + GetScanReport(context.Context, *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) +} + +// NewScanServiceClient constructs a client for the aiscan.scan.ScanService service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewScanServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ScanServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + scanServiceMethods := scan.File_aiscan_scan_scan_proto.Services().ByName("ScanService").Methods() + return &scanServiceClient{ + submitScan: connect.NewClient[scan.SubmitScanRequest, scan.SubmitScanResponse]( + httpClient, + baseURL+ScanServiceSubmitScanProcedure, + connect.WithSchema(scanServiceMethods.ByName("SubmitScan")), + connect.WithClientOptions(opts...), + ), + getScan: connect.NewClient[scan.GetScanRequest, scan.GetScanResponse]( + httpClient, + baseURL+ScanServiceGetScanProcedure, + connect.WithSchema(scanServiceMethods.ByName("GetScan")), + connect.WithClientOptions(opts...), + ), + listScans: connect.NewClient[scan.ListScansRequest, scan.ListScansResponse]( + httpClient, + baseURL+ScanServiceListScansProcedure, + connect.WithSchema(scanServiceMethods.ByName("ListScans")), + connect.WithClientOptions(opts...), + ), + cancelScan: connect.NewClient[scan.CancelScanRequest, scan.CancelScanResponse]( + httpClient, + baseURL+ScanServiceCancelScanProcedure, + connect.WithSchema(scanServiceMethods.ByName("CancelScan")), + connect.WithClientOptions(opts...), + ), + watchScanEvents: connect.NewClient[scan.WatchScanEventsRequest, scan.WatchScanEventsResponse]( + httpClient, + baseURL+ScanServiceWatchScanEventsProcedure, + connect.WithSchema(scanServiceMethods.ByName("WatchScanEvents")), + connect.WithClientOptions(opts...), + ), + getScanReport: connect.NewClient[scan.GetScanReportRequest, scan.GetScanReportResponse]( + httpClient, + baseURL+ScanServiceGetScanReportProcedure, + connect.WithSchema(scanServiceMethods.ByName("GetScanReport")), + connect.WithClientOptions(opts...), + ), + } +} + +// scanServiceClient implements ScanServiceClient. +type scanServiceClient struct { + submitScan *connect.Client[scan.SubmitScanRequest, scan.SubmitScanResponse] + getScan *connect.Client[scan.GetScanRequest, scan.GetScanResponse] + listScans *connect.Client[scan.ListScansRequest, scan.ListScansResponse] + cancelScan *connect.Client[scan.CancelScanRequest, scan.CancelScanResponse] + watchScanEvents *connect.Client[scan.WatchScanEventsRequest, scan.WatchScanEventsResponse] + getScanReport *connect.Client[scan.GetScanReportRequest, scan.GetScanReportResponse] +} + +// SubmitScan calls aiscan.scan.ScanService.SubmitScan. +func (c *scanServiceClient) SubmitScan(ctx context.Context, req *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) { + return c.submitScan.CallUnary(ctx, req) +} + +// GetScan calls aiscan.scan.ScanService.GetScan. +func (c *scanServiceClient) GetScan(ctx context.Context, req *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) { + return c.getScan.CallUnary(ctx, req) +} + +// ListScans calls aiscan.scan.ScanService.ListScans. +func (c *scanServiceClient) ListScans(ctx context.Context, req *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) { + return c.listScans.CallUnary(ctx, req) +} + +// CancelScan calls aiscan.scan.ScanService.CancelScan. +func (c *scanServiceClient) CancelScan(ctx context.Context, req *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) { + return c.cancelScan.CallUnary(ctx, req) +} + +// WatchScanEvents calls aiscan.scan.ScanService.WatchScanEvents. +func (c *scanServiceClient) WatchScanEvents(ctx context.Context, req *connect.Request[scan.WatchScanEventsRequest]) (*connect.ServerStreamForClient[scan.WatchScanEventsResponse], error) { + return c.watchScanEvents.CallServerStream(ctx, req) +} + +// GetScanReport calls aiscan.scan.ScanService.GetScanReport. +func (c *scanServiceClient) GetScanReport(ctx context.Context, req *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) { + return c.getScanReport.CallUnary(ctx, req) +} + +// ScanServiceHandler is an implementation of the aiscan.scan.ScanService service. +type ScanServiceHandler interface { + SubmitScan(context.Context, *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) + GetScan(context.Context, *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) + ListScans(context.Context, *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) + CancelScan(context.Context, *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) + WatchScanEvents(context.Context, *connect.Request[scan.WatchScanEventsRequest], *connect.ServerStream[scan.WatchScanEventsResponse]) error + GetScanReport(context.Context, *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) +} + +// NewScanServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewScanServiceHandler(svc ScanServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + scanServiceMethods := scan.File_aiscan_scan_scan_proto.Services().ByName("ScanService").Methods() + scanServiceSubmitScanHandler := connect.NewUnaryHandler( + ScanServiceSubmitScanProcedure, + svc.SubmitScan, + connect.WithSchema(scanServiceMethods.ByName("SubmitScan")), + connect.WithHandlerOptions(opts...), + ) + scanServiceGetScanHandler := connect.NewUnaryHandler( + ScanServiceGetScanProcedure, + svc.GetScan, + connect.WithSchema(scanServiceMethods.ByName("GetScan")), + connect.WithHandlerOptions(opts...), + ) + scanServiceListScansHandler := connect.NewUnaryHandler( + ScanServiceListScansProcedure, + svc.ListScans, + connect.WithSchema(scanServiceMethods.ByName("ListScans")), + connect.WithHandlerOptions(opts...), + ) + scanServiceCancelScanHandler := connect.NewUnaryHandler( + ScanServiceCancelScanProcedure, + svc.CancelScan, + connect.WithSchema(scanServiceMethods.ByName("CancelScan")), + connect.WithHandlerOptions(opts...), + ) + scanServiceWatchScanEventsHandler := connect.NewServerStreamHandler( + ScanServiceWatchScanEventsProcedure, + svc.WatchScanEvents, + connect.WithSchema(scanServiceMethods.ByName("WatchScanEvents")), + connect.WithHandlerOptions(opts...), + ) + scanServiceGetScanReportHandler := connect.NewUnaryHandler( + ScanServiceGetScanReportProcedure, + svc.GetScanReport, + connect.WithSchema(scanServiceMethods.ByName("GetScanReport")), + connect.WithHandlerOptions(opts...), + ) + return "/aiscan.scan.ScanService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ScanServiceSubmitScanProcedure: + scanServiceSubmitScanHandler.ServeHTTP(w, r) + case ScanServiceGetScanProcedure: + scanServiceGetScanHandler.ServeHTTP(w, r) + case ScanServiceListScansProcedure: + scanServiceListScansHandler.ServeHTTP(w, r) + case ScanServiceCancelScanProcedure: + scanServiceCancelScanHandler.ServeHTTP(w, r) + case ScanServiceWatchScanEventsProcedure: + scanServiceWatchScanEventsHandler.ServeHTTP(w, r) + case ScanServiceGetScanReportProcedure: + scanServiceGetScanReportHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedScanServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedScanServiceHandler struct{} + +func (UnimplementedScanServiceHandler) SubmitScan(context.Context, *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.SubmitScan is not implemented")) +} + +func (UnimplementedScanServiceHandler) GetScan(context.Context, *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.GetScan is not implemented")) +} + +func (UnimplementedScanServiceHandler) ListScans(context.Context, *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.ListScans is not implemented")) +} + +func (UnimplementedScanServiceHandler) CancelScan(context.Context, *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.CancelScan is not implemented")) +} + +func (UnimplementedScanServiceHandler) WatchScanEvents(context.Context, *connect.Request[scan.WatchScanEventsRequest], *connect.ServerStream[scan.WatchScanEventsResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.WatchScanEvents is not implemented")) +} + +func (UnimplementedScanServiceHandler) GetScanReport(context.Context, *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.GetScanReport is not implemented")) +} diff --git a/aop/aiscan/transport/agent.pb.go b/aop/aiscan/transport/agent.pb.go new file mode 100644 index 00000000..057487fd --- /dev/null +++ b/aop/aiscan/transport/agent.pb.go @@ -0,0 +1,1330 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/transport/agent.proto + +package transport + +import ( + aop "github.com/chainreactors/aiscan/aop" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AgentHello struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Authority string `protobuf:"bytes,3,opt,name=authority,proto3" json:"authority,omitempty"` + Commands []string `protobuf:"bytes,4,rep,name=commands,proto3" json:"commands,omitempty"` + CommandMenu []*CommandSpec `protobuf:"bytes,5,rep,name=command_menu,json=commandMenu,proto3" json:"command_menu,omitempty"` + Tools []*ToolDefinition `protobuf:"bytes,6,rep,name=tools,proto3" json:"tools,omitempty"` + Runtime *AgentRuntimeInfo `protobuf:"bytes,7,opt,name=runtime,proto3" json:"runtime,omitempty"` + Status *AgentStatus `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"` + Stats *AgentStats `protobuf:"bytes,9,opt,name=stats,proto3" json:"stats,omitempty"` +} + +func (x *AgentHello) Reset() { + *x = AgentHello{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentHello) ProtoMessage() {} + +func (x *AgentHello) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_agent_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentHello.ProtoReflect.Descriptor instead. +func (*AgentHello) Descriptor() ([]byte, []int) { + return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentHello) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentHello) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AgentHello) GetAuthority() string { + if x != nil { + return x.Authority + } + return "" +} + +func (x *AgentHello) GetCommands() []string { + if x != nil { + return x.Commands + } + return nil +} + +func (x *AgentHello) GetCommandMenu() []*CommandSpec { + if x != nil { + return x.CommandMenu + } + return nil +} + +func (x *AgentHello) GetTools() []*ToolDefinition { + if x != nil { + return x.Tools + } + return nil +} + +func (x *AgentHello) GetRuntime() *AgentRuntimeInfo { + if x != nil { + return x.Runtime + } + return nil +} + +func (x *AgentHello) GetStatus() *AgentStatus { + if x != nil { + return x.Status + } + return nil +} + +func (x *AgentHello) GetStats() *AgentStats { + if x != nil { + return x.Stats + } + return nil +} + +type ConnectionAccepted struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Capabilities []string `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty"` +} + +func (x *ConnectionAccepted) Reset() { + *x = ConnectionAccepted{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ConnectionAccepted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionAccepted) ProtoMessage() {} + +func (x *ConnectionAccepted) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_agent_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionAccepted.ProtoReflect.Descriptor instead. +func (*ConnectionAccepted) Descriptor() ([]byte, []int) { + return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *ConnectionAccepted) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *ConnectionAccepted) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ConnectionAccepted) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +type ToolCallRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + Call *aop.ToolCall `protobuf:"bytes,4,opt,name=call,proto3" json:"call,omitempty"` +} + +func (x *ToolCallRequest) Reset() { + *x = ToolCallRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ToolCallRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolCallRequest) ProtoMessage() {} + +func (x *ToolCallRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_agent_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ToolCallRequest.ProtoReflect.Descriptor instead. +func (*ToolCallRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *ToolCallRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *ToolCallRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ToolCallRequest) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *ToolCallRequest) GetCall() *aop.ToolCall { + if x != nil { + return x.Call + } + return nil +} + +type AgentFrame struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + FrameId string `protobuf:"bytes,1,opt,name=frame_id,json=frameId,proto3" json:"frame_id,omitempty"` + CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + // Types that are assignable to Payload: + // + // *AgentFrame_Hello + // *AgentFrame_OpenSession + // *AgentFrame_RunTurn + // *AgentFrame_CancelTurn + // *AgentFrame_CloseSession + // *AgentFrame_Event + // *AgentFrame_CommandResult + // *AgentFrame_FileResult + // *AgentFrame_ExecOutput + // *AgentFrame_ExecResult + // *AgentFrame_OperationError + // *AgentFrame_Status + // *AgentFrame_Stats + // *AgentFrame_ConfigReload + // *AgentFrame_Terminal + // *AgentFrame_ToolTelemetry + // *AgentFrame_ScoNodes + Payload isAgentFrame_Payload `protobuf_oneof:"payload"` +} + +func (x *AgentFrame) Reset() { + *x = AgentFrame{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentFrame) ProtoMessage() {} + +func (x *AgentFrame) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_agent_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentFrame.ProtoReflect.Descriptor instead. +func (*AgentFrame) Descriptor() ([]byte, []int) { + return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *AgentFrame) GetFrameId() string { + if x != nil { + return x.FrameId + } + return "" +} + +func (x *AgentFrame) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (m *AgentFrame) GetPayload() isAgentFrame_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *AgentFrame) GetHello() *AgentHello { + if x, ok := x.GetPayload().(*AgentFrame_Hello); ok { + return x.Hello + } + return nil +} + +func (x *AgentFrame) GetOpenSession() *aop.OpenSessionResponse { + if x, ok := x.GetPayload().(*AgentFrame_OpenSession); ok { + return x.OpenSession + } + return nil +} + +func (x *AgentFrame) GetRunTurn() *aop.RunTurnResponse { + if x, ok := x.GetPayload().(*AgentFrame_RunTurn); ok { + return x.RunTurn + } + return nil +} + +func (x *AgentFrame) GetCancelTurn() *aop.CancelTurnResponse { + if x, ok := x.GetPayload().(*AgentFrame_CancelTurn); ok { + return x.CancelTurn + } + return nil +} + +func (x *AgentFrame) GetCloseSession() *aop.CloseSessionResponse { + if x, ok := x.GetPayload().(*AgentFrame_CloseSession); ok { + return x.CloseSession + } + return nil +} + +func (x *AgentFrame) GetEvent() *aop.Event { + if x, ok := x.GetPayload().(*AgentFrame_Event); ok { + return x.Event + } + return nil +} + +func (x *AgentFrame) GetCommandResult() *CommandResult { + if x, ok := x.GetPayload().(*AgentFrame_CommandResult); ok { + return x.CommandResult + } + return nil +} + +func (x *AgentFrame) GetFileResult() *FileResult { + if x, ok := x.GetPayload().(*AgentFrame_FileResult); ok { + return x.FileResult + } + return nil +} + +func (x *AgentFrame) GetExecOutput() *ExecOutput { + if x, ok := x.GetPayload().(*AgentFrame_ExecOutput); ok { + return x.ExecOutput + } + return nil +} + +func (x *AgentFrame) GetExecResult() *ExecResult { + if x, ok := x.GetPayload().(*AgentFrame_ExecResult); ok { + return x.ExecResult + } + return nil +} + +func (x *AgentFrame) GetOperationError() *OperationError { + if x, ok := x.GetPayload().(*AgentFrame_OperationError); ok { + return x.OperationError + } + return nil +} + +func (x *AgentFrame) GetStatus() *AgentStatus { + if x, ok := x.GetPayload().(*AgentFrame_Status); ok { + return x.Status + } + return nil +} + +func (x *AgentFrame) GetStats() *AgentStats { + if x, ok := x.GetPayload().(*AgentFrame_Stats); ok { + return x.Stats + } + return nil +} + +func (x *AgentFrame) GetConfigReload() *ConfigReloadResult { + if x, ok := x.GetPayload().(*AgentFrame_ConfigReload); ok { + return x.ConfigReload + } + return nil +} + +func (x *AgentFrame) GetTerminal() *TerminalFrame { + if x, ok := x.GetPayload().(*AgentFrame_Terminal); ok { + return x.Terminal + } + return nil +} + +func (x *AgentFrame) GetToolTelemetry() *ToolTelemetry { + if x, ok := x.GetPayload().(*AgentFrame_ToolTelemetry); ok { + return x.ToolTelemetry + } + return nil +} + +func (x *AgentFrame) GetScoNodes() *ScoNodes { + if x, ok := x.GetPayload().(*AgentFrame_ScoNodes); ok { + return x.ScoNodes + } + return nil +} + +type isAgentFrame_Payload interface { + isAgentFrame_Payload() +} + +type AgentFrame_Hello struct { + Hello *AgentHello `protobuf:"bytes,10,opt,name=hello,proto3,oneof"` +} + +type AgentFrame_OpenSession struct { + OpenSession *aop.OpenSessionResponse `protobuf:"bytes,11,opt,name=open_session,json=openSession,proto3,oneof"` +} + +type AgentFrame_RunTurn struct { + RunTurn *aop.RunTurnResponse `protobuf:"bytes,12,opt,name=run_turn,json=runTurn,proto3,oneof"` +} + +type AgentFrame_CancelTurn struct { + CancelTurn *aop.CancelTurnResponse `protobuf:"bytes,13,opt,name=cancel_turn,json=cancelTurn,proto3,oneof"` +} + +type AgentFrame_CloseSession struct { + CloseSession *aop.CloseSessionResponse `protobuf:"bytes,14,opt,name=close_session,json=closeSession,proto3,oneof"` +} + +type AgentFrame_Event struct { + Event *aop.Event `protobuf:"bytes,15,opt,name=event,proto3,oneof"` +} + +type AgentFrame_CommandResult struct { + CommandResult *CommandResult `protobuf:"bytes,16,opt,name=command_result,json=commandResult,proto3,oneof"` +} + +type AgentFrame_FileResult struct { + FileResult *FileResult `protobuf:"bytes,17,opt,name=file_result,json=fileResult,proto3,oneof"` +} + +type AgentFrame_ExecOutput struct { + ExecOutput *ExecOutput `protobuf:"bytes,18,opt,name=exec_output,json=execOutput,proto3,oneof"` +} + +type AgentFrame_ExecResult struct { + ExecResult *ExecResult `protobuf:"bytes,19,opt,name=exec_result,json=execResult,proto3,oneof"` +} + +type AgentFrame_OperationError struct { + OperationError *OperationError `protobuf:"bytes,20,opt,name=operation_error,json=operationError,proto3,oneof"` +} + +type AgentFrame_Status struct { + Status *AgentStatus `protobuf:"bytes,21,opt,name=status,proto3,oneof"` +} + +type AgentFrame_Stats struct { + Stats *AgentStats `protobuf:"bytes,22,opt,name=stats,proto3,oneof"` +} + +type AgentFrame_ConfigReload struct { + ConfigReload *ConfigReloadResult `protobuf:"bytes,23,opt,name=config_reload,json=configReload,proto3,oneof"` +} + +type AgentFrame_Terminal struct { + Terminal *TerminalFrame `protobuf:"bytes,24,opt,name=terminal,proto3,oneof"` +} + +type AgentFrame_ToolTelemetry struct { + ToolTelemetry *ToolTelemetry `protobuf:"bytes,25,opt,name=tool_telemetry,json=toolTelemetry,proto3,oneof"` +} + +type AgentFrame_ScoNodes struct { + ScoNodes *ScoNodes `protobuf:"bytes,26,opt,name=sco_nodes,json=scoNodes,proto3,oneof"` +} + +func (*AgentFrame_Hello) isAgentFrame_Payload() {} + +func (*AgentFrame_OpenSession) isAgentFrame_Payload() {} + +func (*AgentFrame_RunTurn) isAgentFrame_Payload() {} + +func (*AgentFrame_CancelTurn) isAgentFrame_Payload() {} + +func (*AgentFrame_CloseSession) isAgentFrame_Payload() {} + +func (*AgentFrame_Event) isAgentFrame_Payload() {} + +func (*AgentFrame_CommandResult) isAgentFrame_Payload() {} + +func (*AgentFrame_FileResult) isAgentFrame_Payload() {} + +func (*AgentFrame_ExecOutput) isAgentFrame_Payload() {} + +func (*AgentFrame_ExecResult) isAgentFrame_Payload() {} + +func (*AgentFrame_OperationError) isAgentFrame_Payload() {} + +func (*AgentFrame_Status) isAgentFrame_Payload() {} + +func (*AgentFrame_Stats) isAgentFrame_Payload() {} + +func (*AgentFrame_ConfigReload) isAgentFrame_Payload() {} + +func (*AgentFrame_Terminal) isAgentFrame_Payload() {} + +func (*AgentFrame_ToolTelemetry) isAgentFrame_Payload() {} + +func (*AgentFrame_ScoNodes) isAgentFrame_Payload() {} + +type ServerFrame struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + FrameId string `protobuf:"bytes,1,opt,name=frame_id,json=frameId,proto3" json:"frame_id,omitempty"` + CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + // Types that are assignable to Payload: + // + // *ServerFrame_Accepted + // *ServerFrame_OpenSession + // *ServerFrame_RunTurn + // *ServerFrame_CancelTurn + // *ServerFrame_CloseSession + // *ServerFrame_Command + // *ServerFrame_ToolCall + // *ServerFrame_FileRead + // *ServerFrame_FileWrite + // *ServerFrame_FileList + // *ServerFrame_FileMkdir + // *ServerFrame_FileUpload + // *ServerFrame_Exec + // *ServerFrame_CancelOperation + // *ServerFrame_ReloadConfig + // *ServerFrame_Terminal + // *ServerFrame_Extension + Payload isServerFrame_Payload `protobuf_oneof:"payload"` +} + +func (x *ServerFrame) Reset() { + *x = ServerFrame{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ServerFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerFrame) ProtoMessage() {} + +func (x *ServerFrame) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_agent_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerFrame.ProtoReflect.Descriptor instead. +func (*ServerFrame) Descriptor() ([]byte, []int) { + return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ServerFrame) GetFrameId() string { + if x != nil { + return x.FrameId + } + return "" +} + +func (x *ServerFrame) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (m *ServerFrame) GetPayload() isServerFrame_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *ServerFrame) GetAccepted() *ConnectionAccepted { + if x, ok := x.GetPayload().(*ServerFrame_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *ServerFrame) GetOpenSession() *aop.OpenSessionRequest { + if x, ok := x.GetPayload().(*ServerFrame_OpenSession); ok { + return x.OpenSession + } + return nil +} + +func (x *ServerFrame) GetRunTurn() *aop.RunTurnRequest { + if x, ok := x.GetPayload().(*ServerFrame_RunTurn); ok { + return x.RunTurn + } + return nil +} + +func (x *ServerFrame) GetCancelTurn() *aop.CancelTurnRequest { + if x, ok := x.GetPayload().(*ServerFrame_CancelTurn); ok { + return x.CancelTurn + } + return nil +} + +func (x *ServerFrame) GetCloseSession() *aop.CloseSessionRequest { + if x, ok := x.GetPayload().(*ServerFrame_CloseSession); ok { + return x.CloseSession + } + return nil +} + +func (x *ServerFrame) GetCommand() *CommandRequest { + if x, ok := x.GetPayload().(*ServerFrame_Command); ok { + return x.Command + } + return nil +} + +func (x *ServerFrame) GetToolCall() *ToolCallRequest { + if x, ok := x.GetPayload().(*ServerFrame_ToolCall); ok { + return x.ToolCall + } + return nil +} + +func (x *ServerFrame) GetFileRead() *FileReadRequest { + if x, ok := x.GetPayload().(*ServerFrame_FileRead); ok { + return x.FileRead + } + return nil +} + +func (x *ServerFrame) GetFileWrite() *FileWriteRequest { + if x, ok := x.GetPayload().(*ServerFrame_FileWrite); ok { + return x.FileWrite + } + return nil +} + +func (x *ServerFrame) GetFileList() *FileListRequest { + if x, ok := x.GetPayload().(*ServerFrame_FileList); ok { + return x.FileList + } + return nil +} + +func (x *ServerFrame) GetFileMkdir() *FileMkdirRequest { + if x, ok := x.GetPayload().(*ServerFrame_FileMkdir); ok { + return x.FileMkdir + } + return nil +} + +func (x *ServerFrame) GetFileUpload() *FileUploadRequest { + if x, ok := x.GetPayload().(*ServerFrame_FileUpload); ok { + return x.FileUpload + } + return nil +} + +func (x *ServerFrame) GetExec() *ExecRequest { + if x, ok := x.GetPayload().(*ServerFrame_Exec); ok { + return x.Exec + } + return nil +} + +func (x *ServerFrame) GetCancelOperation() *CancelOperation { + if x, ok := x.GetPayload().(*ServerFrame_CancelOperation); ok { + return x.CancelOperation + } + return nil +} + +func (x *ServerFrame) GetReloadConfig() *ReloadConfig { + if x, ok := x.GetPayload().(*ServerFrame_ReloadConfig); ok { + return x.ReloadConfig + } + return nil +} + +func (x *ServerFrame) GetTerminal() *TerminalFrame { + if x, ok := x.GetPayload().(*ServerFrame_Terminal); ok { + return x.Terminal + } + return nil +} + +func (x *ServerFrame) GetExtension() *aop.Extension { + if x, ok := x.GetPayload().(*ServerFrame_Extension); ok { + return x.Extension + } + return nil +} + +type isServerFrame_Payload interface { + isServerFrame_Payload() +} + +type ServerFrame_Accepted struct { + Accepted *ConnectionAccepted `protobuf:"bytes,10,opt,name=accepted,proto3,oneof"` +} + +type ServerFrame_OpenSession struct { + OpenSession *aop.OpenSessionRequest `protobuf:"bytes,11,opt,name=open_session,json=openSession,proto3,oneof"` +} + +type ServerFrame_RunTurn struct { + RunTurn *aop.RunTurnRequest `protobuf:"bytes,12,opt,name=run_turn,json=runTurn,proto3,oneof"` +} + +type ServerFrame_CancelTurn struct { + CancelTurn *aop.CancelTurnRequest `protobuf:"bytes,13,opt,name=cancel_turn,json=cancelTurn,proto3,oneof"` +} + +type ServerFrame_CloseSession struct { + CloseSession *aop.CloseSessionRequest `protobuf:"bytes,14,opt,name=close_session,json=closeSession,proto3,oneof"` +} + +type ServerFrame_Command struct { + Command *CommandRequest `protobuf:"bytes,15,opt,name=command,proto3,oneof"` +} + +type ServerFrame_ToolCall struct { + ToolCall *ToolCallRequest `protobuf:"bytes,16,opt,name=tool_call,json=toolCall,proto3,oneof"` +} + +type ServerFrame_FileRead struct { + FileRead *FileReadRequest `protobuf:"bytes,17,opt,name=file_read,json=fileRead,proto3,oneof"` +} + +type ServerFrame_FileWrite struct { + FileWrite *FileWriteRequest `protobuf:"bytes,18,opt,name=file_write,json=fileWrite,proto3,oneof"` +} + +type ServerFrame_FileList struct { + FileList *FileListRequest `protobuf:"bytes,19,opt,name=file_list,json=fileList,proto3,oneof"` +} + +type ServerFrame_FileMkdir struct { + FileMkdir *FileMkdirRequest `protobuf:"bytes,20,opt,name=file_mkdir,json=fileMkdir,proto3,oneof"` +} + +type ServerFrame_FileUpload struct { + FileUpload *FileUploadRequest `protobuf:"bytes,21,opt,name=file_upload,json=fileUpload,proto3,oneof"` +} + +type ServerFrame_Exec struct { + Exec *ExecRequest `protobuf:"bytes,22,opt,name=exec,proto3,oneof"` +} + +type ServerFrame_CancelOperation struct { + CancelOperation *CancelOperation `protobuf:"bytes,23,opt,name=cancel_operation,json=cancelOperation,proto3,oneof"` +} + +type ServerFrame_ReloadConfig struct { + ReloadConfig *ReloadConfig `protobuf:"bytes,24,opt,name=reload_config,json=reloadConfig,proto3,oneof"` +} + +type ServerFrame_Terminal struct { + Terminal *TerminalFrame `protobuf:"bytes,25,opt,name=terminal,proto3,oneof"` +} + +type ServerFrame_Extension struct { + Extension *aop.Extension `protobuf:"bytes,26,opt,name=extension,proto3,oneof"` +} + +func (*ServerFrame_Accepted) isServerFrame_Payload() {} + +func (*ServerFrame_OpenSession) isServerFrame_Payload() {} + +func (*ServerFrame_RunTurn) isServerFrame_Payload() {} + +func (*ServerFrame_CancelTurn) isServerFrame_Payload() {} + +func (*ServerFrame_CloseSession) isServerFrame_Payload() {} + +func (*ServerFrame_Command) isServerFrame_Payload() {} + +func (*ServerFrame_ToolCall) isServerFrame_Payload() {} + +func (*ServerFrame_FileRead) isServerFrame_Payload() {} + +func (*ServerFrame_FileWrite) isServerFrame_Payload() {} + +func (*ServerFrame_FileList) isServerFrame_Payload() {} + +func (*ServerFrame_FileMkdir) isServerFrame_Payload() {} + +func (*ServerFrame_FileUpload) isServerFrame_Payload() {} + +func (*ServerFrame_Exec) isServerFrame_Payload() {} + +func (*ServerFrame_CancelOperation) isServerFrame_Payload() {} + +func (*ServerFrame_ReloadConfig) isServerFrame_Payload() {} + +func (*ServerFrame_Terminal) isServerFrame_Payload() {} + +func (*ServerFrame_Extension) isServerFrame_Payload() {} + +var File_aiscan_transport_agent_proto protoreflect.FileDescriptor + +var file_aiscan_transport_agent_proto_rawDesc = []byte{ + 0x0a, 0x1c, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, + 0x72, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, + 0x1a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, + 0x72, 0x74, 0x2f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x1a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, + 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x76, + 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x98, 0x03, 0x0a, 0x0a, 0x41, + 0x67, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x73, 0x12, 0x40, 0x0a, 0x0c, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x6d, 0x65, + 0x6e, 0x75, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x4d, 0x65, 0x6e, 0x75, 0x12, 0x36, 0x0a, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, + 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x66, 0x69, 0x6e, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x12, 0x3c, 0x0a, 0x07, + 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, + 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x49, 0x6e, 0x66, + 0x6f, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x35, 0x0a, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x12, 0x32, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, + 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x05, + 0x73, 0x74, 0x61, 0x74, 0x73, 0x22, 0x67, 0x0a, 0x12, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, + 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x85, + 0x01, 0x0a, 0x0f, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x75, + 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, + 0x6e, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, + 0x52, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x22, 0xfd, 0x08, 0x0a, 0x0a, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x49, 0x64, + 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x34, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x48, + 0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x3d, 0x0a, + 0x0c, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, + 0x0b, 0x6f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x0a, 0x08, + 0x72, 0x75, 0x6e, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x12, + 0x3a, 0x0a, 0x0b, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0d, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, + 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, + 0x0a, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x40, 0x0a, 0x0d, 0x63, + 0x6c, 0x6f, 0x73, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0e, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, + 0x0c, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, + 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x12, 0x48, 0x0a, 0x0e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x72, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0d, 0x63, 0x6f, + 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x3f, 0x0a, 0x0b, 0x66, + 0x69, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, + 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, + 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x3f, 0x0a, 0x0b, + 0x65, 0x78, 0x65, 0x63, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x12, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x48, + 0x00, 0x52, 0x0a, 0x65, 0x78, 0x65, 0x63, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x3f, 0x0a, + 0x0b, 0x65, 0x78, 0x65, 0x63, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x13, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, + 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x48, 0x00, 0x52, 0x0a, 0x65, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x4b, + 0x0a, 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x4f, 0x70, 0x65, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x48, 0x00, 0x52, 0x0e, 0x6f, 0x70, 0x65, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x37, 0x0a, 0x06, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, + 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x16, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, + 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, + 0x73, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x12, 0x4b, 0x0a, 0x0d, 0x63, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x72, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x17, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x24, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x6c, 0x6f, 0x61, + 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x3d, 0x0a, 0x08, 0x74, 0x65, 0x72, 0x6d, 0x69, + 0x6e, 0x61, 0x6c, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x65, 0x72, + 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x48, 0x00, 0x52, 0x08, 0x74, 0x65, + 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x12, 0x48, 0x0a, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x74, + 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x18, 0x19, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x48, + 0x00, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, + 0x12, 0x39, 0x0a, 0x09, 0x73, 0x63, 0x6f, 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x1a, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, + 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x53, 0x63, 0x6f, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x48, + 0x00, 0x52, 0x08, 0x73, 0x63, 0x6f, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x9b, 0x09, 0x0a, 0x0b, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x49, + 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6f, 0x72, 0x72, 0x65, + 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x42, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, + 0x70, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x3c, 0x0a, 0x0c, + 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x6f, + 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x72, 0x75, + 0x6e, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x48, 0x00, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x39, 0x0a, 0x0b, + 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, + 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6e, + 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x3f, 0x0a, 0x0d, 0x63, 0x6c, 0x6f, 0x73, 0x65, + 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0c, 0x63, 0x6c, 0x6f, 0x73, + 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x07, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x40, 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, + 0x61, 0x6c, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x6f, 0x6f, + 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, + 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x40, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65, + 0x5f, 0x72, 0x65, 0x61, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, + 0x69, 0x6c, 0x65, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, + 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x61, 0x64, 0x12, 0x43, 0x0a, 0x0a, 0x66, 0x69, + 0x6c, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, + 0x40, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x13, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, + 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x4c, 0x69, 0x73, + 0x74, 0x12, 0x43, 0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x6b, 0x64, 0x69, 0x72, 0x18, + 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, + 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x4d, 0x6b, 0x64, + 0x69, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x66, 0x69, 0x6c, + 0x65, 0x4d, 0x6b, 0x64, 0x69, 0x72, 0x12, 0x46, 0x0a, 0x0b, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x75, + 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, + 0x69, 0x6c, 0x65, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x48, 0x00, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x33, + 0x0a, 0x04, 0x65, 0x78, 0x65, 0x63, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, + 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x04, 0x65, + 0x78, 0x65, 0x63, 0x12, 0x4e, 0x0a, 0x10, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x6f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, + 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x48, 0x00, 0x52, 0x0f, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x45, 0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x63, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x52, 0x65, + 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x48, 0x00, 0x52, 0x0c, 0x72, 0x65, + 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x74, 0x65, + 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x19, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, + 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x48, 0x00, 0x52, + 0x08, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x12, 0x2e, 0x0a, 0x09, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x09, + 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, + 0x6c, 0x6f, 0x61, 0x64, 0x32, 0x63, 0x0a, 0x15, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x54, 0x72, 0x61, + 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4a, 0x0a, + 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x1a, 0x1d, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x46, 0x72, 0x61, 0x6d, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, + 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, + 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, + 0x74, 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_transport_agent_proto_rawDescOnce sync.Once + file_aiscan_transport_agent_proto_rawDescData = file_aiscan_transport_agent_proto_rawDesc +) + +func file_aiscan_transport_agent_proto_rawDescGZIP() []byte { + file_aiscan_transport_agent_proto_rawDescOnce.Do(func() { + file_aiscan_transport_agent_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_agent_proto_rawDescData) + }) + return file_aiscan_transport_agent_proto_rawDescData +} + +var file_aiscan_transport_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_aiscan_transport_agent_proto_goTypes = []interface{}{ + (*AgentHello)(nil), // 0: aiscan.transport.AgentHello + (*ConnectionAccepted)(nil), // 1: aiscan.transport.ConnectionAccepted + (*ToolCallRequest)(nil), // 2: aiscan.transport.ToolCallRequest + (*AgentFrame)(nil), // 3: aiscan.transport.AgentFrame + (*ServerFrame)(nil), // 4: aiscan.transport.ServerFrame + (*CommandSpec)(nil), // 5: aiscan.transport.CommandSpec + (*ToolDefinition)(nil), // 6: aiscan.transport.ToolDefinition + (*AgentRuntimeInfo)(nil), // 7: aiscan.transport.AgentRuntimeInfo + (*AgentStatus)(nil), // 8: aiscan.transport.AgentStatus + (*AgentStats)(nil), // 9: aiscan.transport.AgentStats + (*aop.ToolCall)(nil), // 10: aop.ToolCall + (*aop.OpenSessionResponse)(nil), // 11: aop.OpenSessionResponse + (*aop.RunTurnResponse)(nil), // 12: aop.RunTurnResponse + (*aop.CancelTurnResponse)(nil), // 13: aop.CancelTurnResponse + (*aop.CloseSessionResponse)(nil), // 14: aop.CloseSessionResponse + (*aop.Event)(nil), // 15: aop.Event + (*CommandResult)(nil), // 16: aiscan.transport.CommandResult + (*FileResult)(nil), // 17: aiscan.transport.FileResult + (*ExecOutput)(nil), // 18: aiscan.transport.ExecOutput + (*ExecResult)(nil), // 19: aiscan.transport.ExecResult + (*OperationError)(nil), // 20: aiscan.transport.OperationError + (*ConfigReloadResult)(nil), // 21: aiscan.transport.ConfigReloadResult + (*TerminalFrame)(nil), // 22: aiscan.transport.TerminalFrame + (*ToolTelemetry)(nil), // 23: aiscan.transport.ToolTelemetry + (*ScoNodes)(nil), // 24: aiscan.transport.ScoNodes + (*aop.OpenSessionRequest)(nil), // 25: aop.OpenSessionRequest + (*aop.RunTurnRequest)(nil), // 26: aop.RunTurnRequest + (*aop.CancelTurnRequest)(nil), // 27: aop.CancelTurnRequest + (*aop.CloseSessionRequest)(nil), // 28: aop.CloseSessionRequest + (*CommandRequest)(nil), // 29: aiscan.transport.CommandRequest + (*FileReadRequest)(nil), // 30: aiscan.transport.FileReadRequest + (*FileWriteRequest)(nil), // 31: aiscan.transport.FileWriteRequest + (*FileListRequest)(nil), // 32: aiscan.transport.FileListRequest + (*FileMkdirRequest)(nil), // 33: aiscan.transport.FileMkdirRequest + (*FileUploadRequest)(nil), // 34: aiscan.transport.FileUploadRequest + (*ExecRequest)(nil), // 35: aiscan.transport.ExecRequest + (*CancelOperation)(nil), // 36: aiscan.transport.CancelOperation + (*ReloadConfig)(nil), // 37: aiscan.transport.ReloadConfig + (*aop.Extension)(nil), // 38: aop.Extension +} +var file_aiscan_transport_agent_proto_depIdxs = []int32{ + 5, // 0: aiscan.transport.AgentHello.command_menu:type_name -> aiscan.transport.CommandSpec + 6, // 1: aiscan.transport.AgentHello.tools:type_name -> aiscan.transport.ToolDefinition + 7, // 2: aiscan.transport.AgentHello.runtime:type_name -> aiscan.transport.AgentRuntimeInfo + 8, // 3: aiscan.transport.AgentHello.status:type_name -> aiscan.transport.AgentStatus + 9, // 4: aiscan.transport.AgentHello.stats:type_name -> aiscan.transport.AgentStats + 10, // 5: aiscan.transport.ToolCallRequest.call:type_name -> aop.ToolCall + 0, // 6: aiscan.transport.AgentFrame.hello:type_name -> aiscan.transport.AgentHello + 11, // 7: aiscan.transport.AgentFrame.open_session:type_name -> aop.OpenSessionResponse + 12, // 8: aiscan.transport.AgentFrame.run_turn:type_name -> aop.RunTurnResponse + 13, // 9: aiscan.transport.AgentFrame.cancel_turn:type_name -> aop.CancelTurnResponse + 14, // 10: aiscan.transport.AgentFrame.close_session:type_name -> aop.CloseSessionResponse + 15, // 11: aiscan.transport.AgentFrame.event:type_name -> aop.Event + 16, // 12: aiscan.transport.AgentFrame.command_result:type_name -> aiscan.transport.CommandResult + 17, // 13: aiscan.transport.AgentFrame.file_result:type_name -> aiscan.transport.FileResult + 18, // 14: aiscan.transport.AgentFrame.exec_output:type_name -> aiscan.transport.ExecOutput + 19, // 15: aiscan.transport.AgentFrame.exec_result:type_name -> aiscan.transport.ExecResult + 20, // 16: aiscan.transport.AgentFrame.operation_error:type_name -> aiscan.transport.OperationError + 8, // 17: aiscan.transport.AgentFrame.status:type_name -> aiscan.transport.AgentStatus + 9, // 18: aiscan.transport.AgentFrame.stats:type_name -> aiscan.transport.AgentStats + 21, // 19: aiscan.transport.AgentFrame.config_reload:type_name -> aiscan.transport.ConfigReloadResult + 22, // 20: aiscan.transport.AgentFrame.terminal:type_name -> aiscan.transport.TerminalFrame + 23, // 21: aiscan.transport.AgentFrame.tool_telemetry:type_name -> aiscan.transport.ToolTelemetry + 24, // 22: aiscan.transport.AgentFrame.sco_nodes:type_name -> aiscan.transport.ScoNodes + 1, // 23: aiscan.transport.ServerFrame.accepted:type_name -> aiscan.transport.ConnectionAccepted + 25, // 24: aiscan.transport.ServerFrame.open_session:type_name -> aop.OpenSessionRequest + 26, // 25: aiscan.transport.ServerFrame.run_turn:type_name -> aop.RunTurnRequest + 27, // 26: aiscan.transport.ServerFrame.cancel_turn:type_name -> aop.CancelTurnRequest + 28, // 27: aiscan.transport.ServerFrame.close_session:type_name -> aop.CloseSessionRequest + 29, // 28: aiscan.transport.ServerFrame.command:type_name -> aiscan.transport.CommandRequest + 2, // 29: aiscan.transport.ServerFrame.tool_call:type_name -> aiscan.transport.ToolCallRequest + 30, // 30: aiscan.transport.ServerFrame.file_read:type_name -> aiscan.transport.FileReadRequest + 31, // 31: aiscan.transport.ServerFrame.file_write:type_name -> aiscan.transport.FileWriteRequest + 32, // 32: aiscan.transport.ServerFrame.file_list:type_name -> aiscan.transport.FileListRequest + 33, // 33: aiscan.transport.ServerFrame.file_mkdir:type_name -> aiscan.transport.FileMkdirRequest + 34, // 34: aiscan.transport.ServerFrame.file_upload:type_name -> aiscan.transport.FileUploadRequest + 35, // 35: aiscan.transport.ServerFrame.exec:type_name -> aiscan.transport.ExecRequest + 36, // 36: aiscan.transport.ServerFrame.cancel_operation:type_name -> aiscan.transport.CancelOperation + 37, // 37: aiscan.transport.ServerFrame.reload_config:type_name -> aiscan.transport.ReloadConfig + 22, // 38: aiscan.transport.ServerFrame.terminal:type_name -> aiscan.transport.TerminalFrame + 38, // 39: aiscan.transport.ServerFrame.extension:type_name -> aop.Extension + 3, // 40: aiscan.transport.AgentTransportService.Connect:input_type -> aiscan.transport.AgentFrame + 4, // 41: aiscan.transport.AgentTransportService.Connect:output_type -> aiscan.transport.ServerFrame + 41, // [41:42] is the sub-list for method output_type + 40, // [40:41] is the sub-list for method input_type + 40, // [40:40] is the sub-list for extension type_name + 40, // [40:40] is the sub-list for extension extendee + 0, // [0:40] is the sub-list for field type_name +} + +func init() { file_aiscan_transport_agent_proto_init() } +func file_aiscan_transport_agent_proto_init() { + if File_aiscan_transport_agent_proto != nil { + return + } + file_aiscan_transport_operation_proto_init() + file_aiscan_transport_telemetry_proto_init() + file_aiscan_transport_terminal_proto_init() + if !protoimpl.UnsafeEnabled { + file_aiscan_transport_agent_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentHello); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_agent_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ConnectionAccepted); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_agent_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ToolCallRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_agent_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentFrame); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_agent_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ServerFrame); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aiscan_transport_agent_proto_msgTypes[3].OneofWrappers = []interface{}{ + (*AgentFrame_Hello)(nil), + (*AgentFrame_OpenSession)(nil), + (*AgentFrame_RunTurn)(nil), + (*AgentFrame_CancelTurn)(nil), + (*AgentFrame_CloseSession)(nil), + (*AgentFrame_Event)(nil), + (*AgentFrame_CommandResult)(nil), + (*AgentFrame_FileResult)(nil), + (*AgentFrame_ExecOutput)(nil), + (*AgentFrame_ExecResult)(nil), + (*AgentFrame_OperationError)(nil), + (*AgentFrame_Status)(nil), + (*AgentFrame_Stats)(nil), + (*AgentFrame_ConfigReload)(nil), + (*AgentFrame_Terminal)(nil), + (*AgentFrame_ToolTelemetry)(nil), + (*AgentFrame_ScoNodes)(nil), + } + file_aiscan_transport_agent_proto_msgTypes[4].OneofWrappers = []interface{}{ + (*ServerFrame_Accepted)(nil), + (*ServerFrame_OpenSession)(nil), + (*ServerFrame_RunTurn)(nil), + (*ServerFrame_CancelTurn)(nil), + (*ServerFrame_CloseSession)(nil), + (*ServerFrame_Command)(nil), + (*ServerFrame_ToolCall)(nil), + (*ServerFrame_FileRead)(nil), + (*ServerFrame_FileWrite)(nil), + (*ServerFrame_FileList)(nil), + (*ServerFrame_FileMkdir)(nil), + (*ServerFrame_FileUpload)(nil), + (*ServerFrame_Exec)(nil), + (*ServerFrame_CancelOperation)(nil), + (*ServerFrame_ReloadConfig)(nil), + (*ServerFrame_Terminal)(nil), + (*ServerFrame_Extension)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_transport_agent_proto_rawDesc, + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_transport_agent_proto_goTypes, + DependencyIndexes: file_aiscan_transport_agent_proto_depIdxs, + MessageInfos: file_aiscan_transport_agent_proto_msgTypes, + }.Build() + File_aiscan_transport_agent_proto = out.File + file_aiscan_transport_agent_proto_rawDesc = nil + file_aiscan_transport_agent_proto_goTypes = nil + file_aiscan_transport_agent_proto_depIdxs = nil +} diff --git a/aop/aiscan/transport/agent_grpc.pb.go b/aop/aiscan/transport/agent_grpc.pb.go new file mode 100644 index 00000000..1c5d8ffb --- /dev/null +++ b/aop/aiscan/transport/agent_grpc.pb.go @@ -0,0 +1,141 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v6.33.0 +// source: aiscan/transport/agent.proto + +package transport + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + AgentTransportService_Connect_FullMethodName = "/aiscan.transport.AgentTransportService/Connect" +) + +// AgentTransportServiceClient is the client API for AgentTransportService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AgentTransportServiceClient interface { + Connect(ctx context.Context, opts ...grpc.CallOption) (AgentTransportService_ConnectClient, error) +} + +type agentTransportServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAgentTransportServiceClient(cc grpc.ClientConnInterface) AgentTransportServiceClient { + return &agentTransportServiceClient{cc} +} + +func (c *agentTransportServiceClient) Connect(ctx context.Context, opts ...grpc.CallOption) (AgentTransportService_ConnectClient, error) { + stream, err := c.cc.NewStream(ctx, &AgentTransportService_ServiceDesc.Streams[0], AgentTransportService_Connect_FullMethodName, opts...) + if err != nil { + return nil, err + } + x := &agentTransportServiceConnectClient{stream} + return x, nil +} + +type AgentTransportService_ConnectClient interface { + Send(*AgentFrame) error + Recv() (*ServerFrame, error) + grpc.ClientStream +} + +type agentTransportServiceConnectClient struct { + grpc.ClientStream +} + +func (x *agentTransportServiceConnectClient) Send(m *AgentFrame) error { + return x.ClientStream.SendMsg(m) +} + +func (x *agentTransportServiceConnectClient) Recv() (*ServerFrame, error) { + m := new(ServerFrame) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// AgentTransportServiceServer is the server API for AgentTransportService service. +// All implementations must embed UnimplementedAgentTransportServiceServer +// for forward compatibility +type AgentTransportServiceServer interface { + Connect(AgentTransportService_ConnectServer) error + mustEmbedUnimplementedAgentTransportServiceServer() +} + +// UnimplementedAgentTransportServiceServer must be embedded to have forward compatible implementations. +type UnimplementedAgentTransportServiceServer struct { +} + +func (UnimplementedAgentTransportServiceServer) Connect(AgentTransportService_ConnectServer) error { + return status.Errorf(codes.Unimplemented, "method Connect not implemented") +} +func (UnimplementedAgentTransportServiceServer) mustEmbedUnimplementedAgentTransportServiceServer() {} + +// UnsafeAgentTransportServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AgentTransportServiceServer will +// result in compilation errors. +type UnsafeAgentTransportServiceServer interface { + mustEmbedUnimplementedAgentTransportServiceServer() +} + +func RegisterAgentTransportServiceServer(s grpc.ServiceRegistrar, srv AgentTransportServiceServer) { + s.RegisterService(&AgentTransportService_ServiceDesc, srv) +} + +func _AgentTransportService_Connect_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(AgentTransportServiceServer).Connect(&agentTransportServiceConnectServer{stream}) +} + +type AgentTransportService_ConnectServer interface { + Send(*ServerFrame) error + Recv() (*AgentFrame, error) + grpc.ServerStream +} + +type agentTransportServiceConnectServer struct { + grpc.ServerStream +} + +func (x *agentTransportServiceConnectServer) Send(m *ServerFrame) error { + return x.ServerStream.SendMsg(m) +} + +func (x *agentTransportServiceConnectServer) Recv() (*AgentFrame, error) { + m := new(AgentFrame) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// AgentTransportService_ServiceDesc is the grpc.ServiceDesc for AgentTransportService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AgentTransportService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "aiscan.transport.AgentTransportService", + HandlerType: (*AgentTransportServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Connect", + Handler: _AgentTransportService_Connect_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "aiscan/transport/agent.proto", +} diff --git a/aop/aiscan/transport/extensions.pb.go b/aop/aiscan/transport/extensions.pb.go new file mode 100644 index 00000000..c4704c80 --- /dev/null +++ b/aop/aiscan/transport/extensions.pb.go @@ -0,0 +1,790 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/transport/extensions.proto + +package transport + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CommandDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Line string `protobuf:"bytes,1,opt,name=line,proto3" json:"line,omitempty"` + Presentation string `protobuf:"bytes,2,opt,name=presentation,proto3" json:"presentation,omitempty"` +} + +func (x *CommandDetail) Reset() { + *x = CommandDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandDetail) ProtoMessage() {} + +func (x *CommandDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandDetail.ProtoReflect.Descriptor instead. +func (*CommandDetail) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{0} +} + +func (x *CommandDetail) GetLine() string { + if x != nil { + return x.Line + } + return "" +} + +func (x *CommandDetail) GetPresentation() string { + if x != nil { + return x.Presentation + } + return "" +} + +type CompactDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + KeptMessages uint64 `protobuf:"varint,2,opt,name=kept_messages,json=keptMessages,proto3" json:"kept_messages,omitempty"` + TokensAfter uint64 `protobuf:"varint,3,opt,name=tokens_after,json=tokensAfter,proto3" json:"tokens_after,omitempty"` + TokensBefore uint64 `protobuf:"varint,4,opt,name=tokens_before,json=tokensBefore,proto3" json:"tokens_before,omitempty"` +} + +func (x *CompactDetail) Reset() { + *x = CompactDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CompactDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompactDetail) ProtoMessage() {} + +func (x *CompactDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CompactDetail.ProtoReflect.Descriptor instead. +func (*CompactDetail) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{1} +} + +func (x *CompactDetail) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *CompactDetail) GetKeptMessages() uint64 { + if x != nil { + return x.KeptMessages + } + return 0 +} + +func (x *CompactDetail) GetTokensAfter() uint64 { + if x != nil { + return x.TokensAfter + } + return 0 +} + +func (x *CompactDetail) GetTokensBefore() uint64 { + if x != nil { + return x.TokensBefore + } + return 0 +} + +type DelegationDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + AgentName string `protobuf:"bytes,2,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` + AgentType string `protobuf:"bytes,3,opt,name=agent_type,json=agentType,proto3" json:"agent_type,omitempty"` + ContextMode string `protobuf:"bytes,4,opt,name=context_mode,json=contextMode,proto3" json:"context_mode,omitempty"` + RunMode string `protobuf:"bytes,5,opt,name=run_mode,json=runMode,proto3" json:"run_mode,omitempty"` + Task string `protobuf:"bytes,6,opt,name=task,proto3" json:"task,omitempty"` +} + +func (x *DelegationDetail) Reset() { + *x = DelegationDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DelegationDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DelegationDetail) ProtoMessage() {} + +func (x *DelegationDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DelegationDetail.ProtoReflect.Descriptor instead. +func (*DelegationDetail) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{2} +} + +func (x *DelegationDetail) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *DelegationDetail) GetAgentName() string { + if x != nil { + return x.AgentName + } + return "" +} + +func (x *DelegationDetail) GetAgentType() string { + if x != nil { + return x.AgentType + } + return "" +} + +func (x *DelegationDetail) GetContextMode() string { + if x != nil { + return x.ContextMode + } + return "" +} + +func (x *DelegationDetail) GetRunMode() string { + if x != nil { + return x.RunMode + } + return "" +} + +func (x *DelegationDetail) GetTask() string { + if x != nil { + return x.Task + } + return "" +} + +type EvalControl struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Criteria string `protobuf:"bytes,1,opt,name=criteria,proto3" json:"criteria,omitempty"` + MaxRounds uint32 `protobuf:"varint,2,opt,name=max_rounds,json=maxRounds,proto3" json:"max_rounds,omitempty"` +} + +func (x *EvalControl) Reset() { + *x = EvalControl{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EvalControl) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EvalControl) ProtoMessage() {} + +func (x *EvalControl) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EvalControl.ProtoReflect.Descriptor instead. +func (*EvalControl) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{3} +} + +func (x *EvalControl) GetCriteria() string { + if x != nil { + return x.Criteria + } + return "" +} + +func (x *EvalControl) GetMaxRounds() uint32 { + if x != nil { + return x.MaxRounds + } + return 0 +} + +type EvalDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + MaxRounds uint32 `protobuf:"varint,2,opt,name=max_rounds,json=maxRounds,proto3" json:"max_rounds,omitempty"` + Pass bool `protobuf:"varint,3,opt,name=pass,proto3" json:"pass,omitempty"` + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` + Round uint32 `protobuf:"varint,5,opt,name=round,proto3" json:"round,omitempty"` +} + +func (x *EvalDetail) Reset() { + *x = EvalDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EvalDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EvalDetail) ProtoMessage() {} + +func (x *EvalDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EvalDetail.ProtoReflect.Descriptor instead. +func (*EvalDetail) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{4} +} + +func (x *EvalDetail) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *EvalDetail) GetMaxRounds() uint32 { + if x != nil { + return x.MaxRounds + } + return 0 +} + +func (x *EvalDetail) GetPass() bool { + if x != nil { + return x.Pass + } + return false +} + +func (x *EvalDetail) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *EvalDetail) GetRound() uint32 { + if x != nil { + return x.Round + } + return 0 +} + +type BudgetWarning struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ContextTokens uint64 `protobuf:"varint,1,opt,name=context_tokens,json=contextTokens,proto3" json:"context_tokens,omitempty"` + TokenBudget uint64 `protobuf:"varint,2,opt,name=token_budget,json=tokenBudget,proto3" json:"token_budget,omitempty"` +} + +func (x *BudgetWarning) Reset() { + *x = BudgetWarning{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BudgetWarning) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BudgetWarning) ProtoMessage() {} + +func (x *BudgetWarning) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BudgetWarning.ProtoReflect.Descriptor instead. +func (*BudgetWarning) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{5} +} + +func (x *BudgetWarning) GetContextTokens() uint64 { + if x != nil { + return x.ContextTokens + } + return 0 +} + +func (x *BudgetWarning) GetTokenBudget() uint64 { + if x != nil { + return x.TokenBudget + } + return 0 +} + +type LLMRequestDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` + Messages uint32 `protobuf:"varint,2,opt,name=messages,proto3" json:"messages,omitempty"` + MaxTokens uint32 `protobuf:"varint,3,opt,name=max_tokens,json=maxTokens,proto3" json:"max_tokens,omitempty"` + Stream bool `protobuf:"varint,4,opt,name=stream,proto3" json:"stream,omitempty"` +} + +func (x *LLMRequestDetail) Reset() { + *x = LLMRequestDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMRequestDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMRequestDetail) ProtoMessage() {} + +func (x *LLMRequestDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMRequestDetail.ProtoReflect.Descriptor instead. +func (*LLMRequestDetail) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{6} +} + +func (x *LLMRequestDetail) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *LLMRequestDetail) GetMessages() uint32 { + if x != nil { + return x.Messages + } + return 0 +} + +func (x *LLMRequestDetail) GetMaxTokens() uint32 { + if x != nil { + return x.MaxTokens + } + return 0 +} + +func (x *LLMRequestDetail) GetStream() bool { + if x != nil { + return x.Stream + } + return false +} + +type WebMessageExtension struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Metadata []byte `protobuf:"bytes,2,opt,name=metadata,proto3" json:"metadata,omitempty"` + Params *structpb.Struct `protobuf:"bytes,3,opt,name=params,proto3" json:"params,omitempty"` +} + +func (x *WebMessageExtension) Reset() { + *x = WebMessageExtension{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_extensions_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WebMessageExtension) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebMessageExtension) ProtoMessage() {} + +func (x *WebMessageExtension) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_extensions_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WebMessageExtension.ProtoReflect.Descriptor instead. +func (*WebMessageExtension) Descriptor() ([]byte, []int) { + return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{7} +} + +func (x *WebMessageExtension) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *WebMessageExtension) GetMetadata() []byte { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *WebMessageExtension) GetParams() *structpb.Struct { + if x != nil { + return x.Params + } + return nil +} + +var File_aiscan_transport_extensions_proto protoreflect.FileDescriptor + +var file_aiscan_transport_extensions_proto_rawDesc = []byte{ + 0x0a, 0x21, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, + 0x72, 0x74, 0x2f, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, + 0x73, 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x22, 0x47, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x44, 0x65, + 0x74, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x70, 0x72, 0x65, 0x73, + 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, + 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x92, 0x01, 0x0a, + 0x0d, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, + 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x6b, 0x65, 0x70, 0x74, 0x5f, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x6b, 0x65, 0x70, + 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x0d, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x62, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x65, 0x66, 0x6f, 0x72, + 0x65, 0x22, 0xbd, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, + 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x61, 0x6d, 0x65, + 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, + 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x4d, 0x6f, + 0x64, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x75, 0x6e, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x74, 0x61, 0x73, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x73, + 0x6b, 0x22, 0x48, 0x0a, 0x0b, 0x45, 0x76, 0x61, 0x6c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, 0x12, 0x1d, 0x0a, 0x0a, + 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x09, 0x6d, 0x61, 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x0a, + 0x45, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x12, + 0x12, 0x0a, 0x04, 0x70, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x70, + 0x61, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x72, + 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x72, 0x6f, 0x75, 0x6e, + 0x64, 0x22, 0x59, 0x0a, 0x0d, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x57, 0x61, 0x72, 0x6e, 0x69, + 0x6e, 0x67, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x74, + 0x65, 0x78, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x5f, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x22, 0x7b, 0x0a, 0x10, + 0x4c, 0x4c, 0x4d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, + 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x22, 0x7d, 0x0a, 0x13, 0x57, 0x65, 0x62, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2f, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, + 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, + 0x52, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, + 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_aiscan_transport_extensions_proto_rawDescOnce sync.Once + file_aiscan_transport_extensions_proto_rawDescData = file_aiscan_transport_extensions_proto_rawDesc +) + +func file_aiscan_transport_extensions_proto_rawDescGZIP() []byte { + file_aiscan_transport_extensions_proto_rawDescOnce.Do(func() { + file_aiscan_transport_extensions_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_extensions_proto_rawDescData) + }) + return file_aiscan_transport_extensions_proto_rawDescData +} + +var file_aiscan_transport_extensions_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_aiscan_transport_extensions_proto_goTypes = []interface{}{ + (*CommandDetail)(nil), // 0: aiscan.transport.CommandDetail + (*CompactDetail)(nil), // 1: aiscan.transport.CompactDetail + (*DelegationDetail)(nil), // 2: aiscan.transport.DelegationDetail + (*EvalControl)(nil), // 3: aiscan.transport.EvalControl + (*EvalDetail)(nil), // 4: aiscan.transport.EvalDetail + (*BudgetWarning)(nil), // 5: aiscan.transport.BudgetWarning + (*LLMRequestDetail)(nil), // 6: aiscan.transport.LLMRequestDetail + (*WebMessageExtension)(nil), // 7: aiscan.transport.WebMessageExtension + (*structpb.Struct)(nil), // 8: google.protobuf.Struct +} +var file_aiscan_transport_extensions_proto_depIdxs = []int32{ + 8, // 0: aiscan.transport.WebMessageExtension.params:type_name -> google.protobuf.Struct + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_aiscan_transport_extensions_proto_init() } +func file_aiscan_transport_extensions_proto_init() { + if File_aiscan_transport_extensions_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_transport_extensions_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_extensions_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CompactDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_extensions_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DelegationDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_extensions_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EvalControl); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_extensions_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EvalDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_extensions_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BudgetWarning); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_extensions_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMRequestDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_extensions_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WebMessageExtension); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_transport_extensions_proto_rawDesc, + NumEnums: 0, + NumMessages: 8, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_transport_extensions_proto_goTypes, + DependencyIndexes: file_aiscan_transport_extensions_proto_depIdxs, + MessageInfos: file_aiscan_transport_extensions_proto_msgTypes, + }.Build() + File_aiscan_transport_extensions_proto = out.File + file_aiscan_transport_extensions_proto_rawDesc = nil + file_aiscan_transport_extensions_proto_goTypes = nil + file_aiscan_transport_extensions_proto_depIdxs = nil +} diff --git a/aop/aiscan/transport/operation.pb.go b/aop/aiscan/transport/operation.pb.go new file mode 100644 index 00000000..69110f20 --- /dev/null +++ b/aop/aiscan/transport/operation.pb.go @@ -0,0 +1,1556 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/transport/operation.proto + +package transport + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ExecStream int32 + +const ( + ExecStream_EXEC_STREAM_UNSPECIFIED ExecStream = 0 + ExecStream_EXEC_STREAM_STDOUT ExecStream = 1 + ExecStream_EXEC_STREAM_STDERR ExecStream = 2 +) + +// Enum value maps for ExecStream. +var ( + ExecStream_name = map[int32]string{ + 0: "EXEC_STREAM_UNSPECIFIED", + 1: "EXEC_STREAM_STDOUT", + 2: "EXEC_STREAM_STDERR", + } + ExecStream_value = map[string]int32{ + "EXEC_STREAM_UNSPECIFIED": 0, + "EXEC_STREAM_STDOUT": 1, + "EXEC_STREAM_STDERR": 2, + } +) + +func (x ExecStream) Enum() *ExecStream { + p := new(ExecStream) + *p = x + return p +} + +func (x ExecStream) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExecStream) Descriptor() protoreflect.EnumDescriptor { + return file_aiscan_transport_operation_proto_enumTypes[0].Descriptor() +} + +func (ExecStream) Type() protoreflect.EnumType { + return &file_aiscan_transport_operation_proto_enumTypes[0] +} + +func (x ExecStream) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExecStream.Descriptor instead. +func (ExecStream) EnumDescriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{0} +} + +type CommandRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Line string `protobuf:"bytes,3,opt,name=line,proto3" json:"line,omitempty"` +} + +func (x *CommandRequest) Reset() { + *x = CommandRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandRequest) ProtoMessage() {} + +func (x *CommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandRequest.ProtoReflect.Descriptor instead. +func (*CommandRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{0} +} + +func (x *CommandRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *CommandRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *CommandRequest) GetLine() string { + if x != nil { + return x.Line + } + return "" +} + +// RunOptions carries AIScan-only turn behavior in the +// io.chainreactors.aiscan.run AOP extension. +type RunOptions struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + EvalCriteria string `protobuf:"bytes,1,opt,name=eval_criteria,json=evalCriteria,proto3" json:"eval_criteria,omitempty"` + EvalMaxRounds uint32 `protobuf:"varint,2,opt,name=eval_max_rounds,json=evalMaxRounds,proto3" json:"eval_max_rounds,omitempty"` +} + +func (x *RunOptions) Reset() { + *x = RunOptions{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RunOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunOptions) ProtoMessage() {} + +func (x *RunOptions) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunOptions.ProtoReflect.Descriptor instead. +func (*RunOptions) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{1} +} + +func (x *RunOptions) GetEvalCriteria() string { + if x != nil { + return x.EvalCriteria + } + return "" +} + +func (x *RunOptions) GetEvalMaxRounds() uint32 { + if x != nil { + return x.EvalMaxRounds + } + return 0 +} + +type CommandResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Result []byte `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` + MediaType string `protobuf:"bytes,3,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *CommandResult) Reset() { + *x = CommandResult{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandResult) ProtoMessage() {} + +func (x *CommandResult) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandResult.ProtoReflect.Descriptor instead. +func (*CommandResult) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{2} +} + +func (x *CommandResult) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *CommandResult) GetResult() []byte { + if x != nil { + return x.Result + } + return nil +} + +func (x *CommandResult) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +type FileReadRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *FileReadRequest) Reset() { + *x = FileReadRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FileReadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileReadRequest) ProtoMessage() {} + +func (x *FileReadRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileReadRequest.ProtoReflect.Descriptor instead. +func (*FileReadRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{3} +} + +func (x *FileReadRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *FileReadRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type FileWriteRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *FileWriteRequest) Reset() { + *x = FileWriteRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FileWriteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileWriteRequest) ProtoMessage() {} + +func (x *FileWriteRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileWriteRequest.ProtoReflect.Descriptor instead. +func (*FileWriteRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{4} +} + +func (x *FileWriteRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *FileWriteRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FileWriteRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type FileListRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *FileListRequest) Reset() { + *x = FileListRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FileListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileListRequest) ProtoMessage() {} + +func (x *FileListRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileListRequest.ProtoReflect.Descriptor instead. +func (*FileListRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{5} +} + +func (x *FileListRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *FileListRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type FileMkdirRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *FileMkdirRequest) Reset() { + *x = FileMkdirRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FileMkdirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileMkdirRequest) ProtoMessage() {} + +func (x *FileMkdirRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileMkdirRequest.ProtoReflect.Descriptor instead. +func (*FileMkdirRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{6} +} + +func (x *FileMkdirRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *FileMkdirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type FileUploadRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Filename string `protobuf:"bytes,3,opt,name=filename,proto3" json:"filename,omitempty"` + MediaType string `protobuf:"bytes,4,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` + Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *FileUploadRequest) Reset() { + *x = FileUploadRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FileUploadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileUploadRequest) ProtoMessage() {} + +func (x *FileUploadRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileUploadRequest.ProtoReflect.Descriptor instead. +func (*FileUploadRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{7} +} + +func (x *FileUploadRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *FileUploadRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *FileUploadRequest) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *FileUploadRequest) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +func (x *FileUploadRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type FileEntry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + IsDirectory bool `protobuf:"varint,2,opt,name=is_directory,json=isDirectory,proto3" json:"is_directory,omitempty"` + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` +} + +func (x *FileEntry) Reset() { + *x = FileEntry{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FileEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileEntry) ProtoMessage() {} + +func (x *FileEntry) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileEntry.ProtoReflect.Descriptor instead. +func (*FileEntry) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{8} +} + +func (x *FileEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FileEntry) GetIsDirectory() bool { + if x != nil { + return x.IsDirectory + } + return false +} + +func (x *FileEntry) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +type FileResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Filename string `protobuf:"bytes,3,opt,name=filename,proto3" json:"filename,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` + Entries []*FileEntry `protobuf:"bytes,6,rep,name=entries,proto3" json:"entries,omitempty"` +} + +func (x *FileResult) Reset() { + *x = FileResult{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FileResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileResult) ProtoMessage() {} + +func (x *FileResult) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileResult.ProtoReflect.Descriptor instead. +func (*FileResult) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{9} +} + +func (x *FileResult) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *FileResult) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FileResult) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *FileResult) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *FileResult) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *FileResult) GetEntries() []*FileEntry { + if x != nil { + return x.Entries + } + return nil +} + +type ExecRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` + Cwd string `protobuf:"bytes,3,opt,name=cwd,proto3" json:"cwd,omitempty"` + TimeoutSeconds uint32 `protobuf:"varint,4,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + Env map[string]string `protobuf:"bytes,5,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *ExecRequest) Reset() { + *x = ExecRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRequest) ProtoMessage() {} + +func (x *ExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead. +func (*ExecRequest) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{10} +} + +func (x *ExecRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *ExecRequest) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *ExecRequest) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +func (x *ExecRequest) GetTimeoutSeconds() uint32 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + +func (x *ExecRequest) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + +type ExecOutput struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Stream ExecStream `protobuf:"varint,2,opt,name=stream,proto3,enum=aiscan.transport.ExecStream" json:"stream,omitempty"` + Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *ExecOutput) Reset() { + *x = ExecOutput{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecOutput) ProtoMessage() {} + +func (x *ExecOutput) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecOutput.ProtoReflect.Descriptor instead. +func (*ExecOutput) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{11} +} + +func (x *ExecOutput) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *ExecOutput) GetStream() ExecStream { + if x != nil { + return x.Stream + } + return ExecStream_EXEC_STREAM_UNSPECIFIED +} + +func (x *ExecOutput) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type ExecResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + ExitCode int32 `protobuf:"varint,2,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` + KillCause string `protobuf:"bytes,4,opt,name=kill_cause,json=killCause,proto3" json:"kill_cause,omitempty"` +} + +func (x *ExecResult) Reset() { + *x = ExecResult{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecResult) ProtoMessage() {} + +func (x *ExecResult) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecResult.ProtoReflect.Descriptor instead. +func (*ExecResult) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{12} +} + +func (x *ExecResult) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *ExecResult) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ExecResult) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ExecResult) GetKillCause() string { + if x != nil { + return x.KillCause + } + return "" +} + +type CancelOperation struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` +} + +func (x *CancelOperation) Reset() { + *x = CancelOperation{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelOperation) ProtoMessage() {} + +func (x *CancelOperation) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelOperation.ProtoReflect.Descriptor instead. +func (*CancelOperation) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{13} +} + +func (x *CancelOperation) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +type OperationError struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + Retryable bool `protobuf:"varint,4,opt,name=retryable,proto3" json:"retryable,omitempty"` +} + +func (x *OperationError) Reset() { + *x = OperationError{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OperationError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OperationError) ProtoMessage() {} + +func (x *OperationError) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OperationError.ProtoReflect.Descriptor instead. +func (*OperationError) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{14} +} + +func (x *OperationError) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *OperationError) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *OperationError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *OperationError) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +type ReloadConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ReloadConfig) Reset() { + *x = ReloadConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReloadConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReloadConfig) ProtoMessage() {} + +func (x *ReloadConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReloadConfig.ProtoReflect.Descriptor instead. +func (*ReloadConfig) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{15} +} + +type ConfigReloadResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` + Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ConfigReloadResult) Reset() { + *x = ConfigReloadResult{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_operation_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ConfigReloadResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigReloadResult) ProtoMessage() {} + +func (x *ConfigReloadResult) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_operation_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigReloadResult.ProtoReflect.Descriptor instead. +func (*ConfigReloadResult) Descriptor() ([]byte, []int) { + return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{16} +} + +func (x *ConfigReloadResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *ConfigReloadResult) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ConfigReloadResult) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *ConfigReloadResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +var File_aiscan_transport_operation_proto protoreflect.FileDescriptor + +var file_aiscan_transport_operation_proto_rawDesc = []byte{ + 0x0a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, + 0x72, 0x74, 0x2f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x70, 0x6f, 0x72, 0x74, 0x22, 0x5c, 0x0a, 0x0e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, + 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, + 0x6e, 0x65, 0x22, 0x59, 0x0a, 0x0a, 0x52, 0x75, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x76, 0x61, 0x6c, 0x5f, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, + 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x76, 0x61, 0x6c, 0x43, 0x72, 0x69, + 0x74, 0x65, 0x72, 0x69, 0x61, 0x12, 0x26, 0x0a, 0x0f, 0x65, 0x76, 0x61, 0x6c, 0x5f, 0x6d, 0x61, + 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, + 0x65, 0x76, 0x61, 0x6c, 0x4d, 0x61, 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x22, 0x5f, 0x0a, + 0x0d, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x17, + 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, + 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x22, 0x3e, + 0x0a, 0x0f, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, + 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x53, + 0x0a, 0x10, 0x46, 0x69, 0x6c, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, + 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, + 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x22, 0x3e, 0x0a, 0x0f, 0x46, 0x69, 0x6c, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, + 0x61, 0x74, 0x68, 0x22, 0x3f, 0x0a, 0x10, 0x46, 0x69, 0x6c, 0x65, 0x4d, 0x6b, 0x64, 0x69, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, + 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x70, 0x61, 0x74, 0x68, 0x22, 0x9a, 0x01, 0x0a, 0x11, 0x46, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x6c, + 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, + 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, + 0x6b, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, + 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, + 0x61, 0x22, 0x56, 0x0a, 0x09, 0x46, 0x69, 0x6c, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x73, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x44, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x22, 0xb4, 0x01, 0x0a, 0x0a, 0x46, 0x69, + 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, + 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x35, 0x0a, 0x07, 0x65, 0x6e, 0x74, + 0x72, 0x69, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, + 0x6c, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, + 0x22, 0xed, 0x01, 0x0a, 0x0b, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x77, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x63, 0x77, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, + 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, + 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x38, + 0x0a, 0x03, 0x65, 0x6e, 0x76, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, + 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x1a, 0x36, 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0x6f, 0x0a, 0x0a, 0x45, 0x78, 0x65, 0x63, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x17, + 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x53, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x12, 0x0a, + 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, + 0x61, 0x22, 0x77, 0x0a, 0x0a, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, + 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, + 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x69, + 0x74, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, + 0x69, 0x6c, 0x6c, 0x5f, 0x63, 0x61, 0x75, 0x73, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6b, 0x69, 0x6c, 0x6c, 0x43, 0x61, 0x75, 0x73, 0x65, 0x22, 0x2a, 0x0a, 0x0f, 0x43, 0x61, + 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x17, 0x0a, + 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x22, 0x75, 0x0a, 0x0e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, + 0x64, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x22, 0x0e, 0x0a, + 0x0c, 0x52, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x6c, 0x0a, + 0x12, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x02, 0x6f, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, + 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2a, 0x59, 0x0a, 0x0a, 0x45, + 0x78, 0x65, 0x63, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x1b, 0x0a, 0x17, 0x45, 0x58, 0x45, + 0x43, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, + 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x45, 0x58, 0x45, 0x43, 0x5f, 0x53, + 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x53, 0x54, 0x44, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x12, 0x16, + 0x0a, 0x12, 0x45, 0x58, 0x45, 0x43, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x53, 0x54, + 0x44, 0x45, 0x52, 0x52, 0x10, 0x02, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, + 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x3b, 0x74, + 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_transport_operation_proto_rawDescOnce sync.Once + file_aiscan_transport_operation_proto_rawDescData = file_aiscan_transport_operation_proto_rawDesc +) + +func file_aiscan_transport_operation_proto_rawDescGZIP() []byte { + file_aiscan_transport_operation_proto_rawDescOnce.Do(func() { + file_aiscan_transport_operation_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_operation_proto_rawDescData) + }) + return file_aiscan_transport_operation_proto_rawDescData +} + +var file_aiscan_transport_operation_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_aiscan_transport_operation_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_aiscan_transport_operation_proto_goTypes = []interface{}{ + (ExecStream)(0), // 0: aiscan.transport.ExecStream + (*CommandRequest)(nil), // 1: aiscan.transport.CommandRequest + (*RunOptions)(nil), // 2: aiscan.transport.RunOptions + (*CommandResult)(nil), // 3: aiscan.transport.CommandResult + (*FileReadRequest)(nil), // 4: aiscan.transport.FileReadRequest + (*FileWriteRequest)(nil), // 5: aiscan.transport.FileWriteRequest + (*FileListRequest)(nil), // 6: aiscan.transport.FileListRequest + (*FileMkdirRequest)(nil), // 7: aiscan.transport.FileMkdirRequest + (*FileUploadRequest)(nil), // 8: aiscan.transport.FileUploadRequest + (*FileEntry)(nil), // 9: aiscan.transport.FileEntry + (*FileResult)(nil), // 10: aiscan.transport.FileResult + (*ExecRequest)(nil), // 11: aiscan.transport.ExecRequest + (*ExecOutput)(nil), // 12: aiscan.transport.ExecOutput + (*ExecResult)(nil), // 13: aiscan.transport.ExecResult + (*CancelOperation)(nil), // 14: aiscan.transport.CancelOperation + (*OperationError)(nil), // 15: aiscan.transport.OperationError + (*ReloadConfig)(nil), // 16: aiscan.transport.ReloadConfig + (*ConfigReloadResult)(nil), // 17: aiscan.transport.ConfigReloadResult + nil, // 18: aiscan.transport.ExecRequest.EnvEntry +} +var file_aiscan_transport_operation_proto_depIdxs = []int32{ + 9, // 0: aiscan.transport.FileResult.entries:type_name -> aiscan.transport.FileEntry + 18, // 1: aiscan.transport.ExecRequest.env:type_name -> aiscan.transport.ExecRequest.EnvEntry + 0, // 2: aiscan.transport.ExecOutput.stream:type_name -> aiscan.transport.ExecStream + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_aiscan_transport_operation_proto_init() } +func file_aiscan_transport_operation_proto_init() { + if File_aiscan_transport_operation_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_transport_operation_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RunOptions); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FileReadRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FileWriteRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FileListRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FileMkdirRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FileUploadRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FileEntry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FileResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ExecRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ExecOutput); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ExecResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelOperation); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OperationError); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReloadConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_operation_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ConfigReloadResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_transport_operation_proto_rawDesc, + NumEnums: 1, + NumMessages: 18, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_transport_operation_proto_goTypes, + DependencyIndexes: file_aiscan_transport_operation_proto_depIdxs, + EnumInfos: file_aiscan_transport_operation_proto_enumTypes, + MessageInfos: file_aiscan_transport_operation_proto_msgTypes, + }.Build() + File_aiscan_transport_operation_proto = out.File + file_aiscan_transport_operation_proto_rawDesc = nil + file_aiscan_transport_operation_proto_goTypes = nil + file_aiscan_transport_operation_proto_depIdxs = nil +} diff --git a/aop/aiscan/transport/telemetry.pb.go b/aop/aiscan/transport/telemetry.pb.go new file mode 100644 index 00000000..3b4ede9a --- /dev/null +++ b/aop/aiscan/transport/telemetry.pb.go @@ -0,0 +1,861 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/transport/telemetry.proto + +package transport + +import ( + aop "github.com/chainreactors/aiscan/aop" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AgentRuntimeInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + WorkingDir string `protobuf:"bytes,3,opt,name=working_dir,json=workingDir,proto3" json:"working_dir,omitempty"` + Os string `protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"` + Arch string `protobuf:"bytes,5,opt,name=arch,proto3" json:"arch,omitempty"` + Pid int32 `protobuf:"varint,6,opt,name=pid,proto3" json:"pid,omitempty"` + Capabilities []string `protobuf:"bytes,7,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + Metadata *aop.EncodedValue `protobuf:"bytes,8,opt,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *AgentRuntimeInfo) Reset() { + *x = AgentRuntimeInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentRuntimeInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentRuntimeInfo) ProtoMessage() {} + +func (x *AgentRuntimeInfo) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentRuntimeInfo.ProtoReflect.Descriptor instead. +func (*AgentRuntimeInfo) Descriptor() ([]byte, []int) { + return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentRuntimeInfo) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *AgentRuntimeInfo) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *AgentRuntimeInfo) GetWorkingDir() string { + if x != nil { + return x.WorkingDir + } + return "" +} + +func (x *AgentRuntimeInfo) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *AgentRuntimeInfo) GetArch() string { + if x != nil { + return x.Arch + } + return "" +} + +func (x *AgentRuntimeInfo) GetPid() int32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *AgentRuntimeInfo) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *AgentRuntimeInfo) GetMetadata() *aop.EncodedValue { + if x != nil { + return x.Metadata + } + return nil +} + +type AgentStatus struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` + Space string `protobuf:"bytes,3,opt,name=space,proto3" json:"space,omitempty"` + Bound bool `protobuf:"varint,4,opt,name=bound,proto3" json:"bound,omitempty"` + ConfigError string `protobuf:"bytes,5,opt,name=config_error,json=configError,proto3" json:"config_error,omitempty"` +} + +func (x *AgentStatus) Reset() { + *x = AgentStatus{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentStatus) ProtoMessage() {} + +func (x *AgentStatus) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentStatus.ProtoReflect.Descriptor instead. +func (*AgentStatus) Descriptor() ([]byte, []int) { + return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{1} +} + +func (x *AgentStatus) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *AgentStatus) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *AgentStatus) GetSpace() string { + if x != nil { + return x.Space + } + return "" +} + +func (x *AgentStatus) GetBound() bool { + if x != nil { + return x.Bound + } + return false +} + +func (x *AgentStatus) GetConfigError() string { + if x != nil { + return x.ConfigError + } + return "" +} + +type AgentStats struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Turns uint64 `protobuf:"varint,1,opt,name=turns,proto3" json:"turns,omitempty"` + ToolCalls uint64 `protobuf:"varint,2,opt,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"` + RunningTools uint64 `protobuf:"varint,3,opt,name=running_tools,json=runningTools,proto3" json:"running_tools,omitempty"` + InputTokens uint64 `protobuf:"varint,4,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + OutputTokens uint64 `protobuf:"varint,5,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + TotalTokens uint64 `protobuf:"varint,6,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + CacheReadTokens uint64 `protobuf:"varint,7,opt,name=cache_read_tokens,json=cacheReadTokens,proto3" json:"cache_read_tokens,omitempty"` + CacheWriteTokens uint64 `protobuf:"varint,8,opt,name=cache_write_tokens,json=cacheWriteTokens,proto3" json:"cache_write_tokens,omitempty"` + Assets uint64 `protobuf:"varint,9,opt,name=assets,proto3" json:"assets,omitempty"` + Loots uint64 `protobuf:"varint,10,opt,name=loots,proto3" json:"loots,omitempty"` + LastEvent string `protobuf:"bytes,11,opt,name=last_event,json=lastEvent,proto3" json:"last_event,omitempty"` +} + +func (x *AgentStats) Reset() { + *x = AgentStats{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentStats) ProtoMessage() {} + +func (x *AgentStats) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentStats.ProtoReflect.Descriptor instead. +func (*AgentStats) Descriptor() ([]byte, []int) { + return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{2} +} + +func (x *AgentStats) GetTurns() uint64 { + if x != nil { + return x.Turns + } + return 0 +} + +func (x *AgentStats) GetToolCalls() uint64 { + if x != nil { + return x.ToolCalls + } + return 0 +} + +func (x *AgentStats) GetRunningTools() uint64 { + if x != nil { + return x.RunningTools + } + return 0 +} + +func (x *AgentStats) GetInputTokens() uint64 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *AgentStats) GetOutputTokens() uint64 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *AgentStats) GetTotalTokens() uint64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *AgentStats) GetCacheReadTokens() uint64 { + if x != nil { + return x.CacheReadTokens + } + return 0 +} + +func (x *AgentStats) GetCacheWriteTokens() uint64 { + if x != nil { + return x.CacheWriteTokens + } + return 0 +} + +func (x *AgentStats) GetAssets() uint64 { + if x != nil { + return x.Assets + } + return 0 +} + +func (x *AgentStats) GetLoots() uint64 { + if x != nil { + return x.Loots + } + return 0 +} + +func (x *AgentStats) GetLastEvent() string { + if x != nil { + return x.LastEvent + } + return "" +} + +type ToolDefinition struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + InputSchema *aop.EncodedValue `protobuf:"bytes,4,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` +} + +func (x *ToolDefinition) Reset() { + *x = ToolDefinition{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ToolDefinition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolDefinition) ProtoMessage() {} + +func (x *ToolDefinition) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ToolDefinition.ProtoReflect.Descriptor instead. +func (*ToolDefinition) Descriptor() ([]byte, []int) { + return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{3} +} + +func (x *ToolDefinition) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ToolDefinition) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolDefinition) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ToolDefinition) GetInputSchema() *aop.EncodedValue { + if x != nil { + return x.InputSchema + } + return nil +} + +type CommandSpec struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Aliases []string `protobuf:"bytes,2,rep,name=aliases,proto3" json:"aliases,omitempty"` + Usage string `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` +} + +func (x *CommandSpec) Reset() { + *x = CommandSpec{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandSpec) ProtoMessage() {} + +func (x *CommandSpec) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandSpec.ProtoReflect.Descriptor instead. +func (*CommandSpec) Descriptor() ([]byte, []int) { + return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{4} +} + +func (x *CommandSpec) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CommandSpec) GetAliases() []string { + if x != nil { + return x.Aliases + } + return nil +} + +func (x *CommandSpec) GetUsage() string { + if x != nil { + return x.Usage + } + return "" +} + +func (x *CommandSpec) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type ToolTelemetry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Tool string `protobuf:"bytes,1,opt,name=tool,proto3" json:"tool,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` + Data *aop.EncodedValue `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` + CallId string `protobuf:"bytes,5,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *ToolTelemetry) Reset() { + *x = ToolTelemetry{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ToolTelemetry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolTelemetry) ProtoMessage() {} + +func (x *ToolTelemetry) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ToolTelemetry.ProtoReflect.Descriptor instead. +func (*ToolTelemetry) Descriptor() ([]byte, []int) { + return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{5} +} + +func (x *ToolTelemetry) GetTool() string { + if x != nil { + return x.Tool + } + return "" +} + +func (x *ToolTelemetry) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *ToolTelemetry) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *ToolTelemetry) GetData() *aop.EncodedValue { + if x != nil { + return x.Data + } + return nil +} + +func (x *ToolTelemetry) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ToolTelemetry) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +type ScoNodes struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + Nodes [][]byte `protobuf:"bytes,2,rep,name=nodes,proto3" json:"nodes,omitempty"` +} + +func (x *ScoNodes) Reset() { + *x = ScoNodes{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScoNodes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScoNodes) ProtoMessage() {} + +func (x *ScoNodes) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_telemetry_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScoNodes.ProtoReflect.Descriptor instead. +func (*ScoNodes) Descriptor() ([]byte, []int) { + return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{6} +} + +func (x *ScoNodes) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ScoNodes) GetNodes() [][]byte { + if x != nil { + return x.Nodes + } + return nil +} + +var File_aiscan_transport_telemetry_proto protoreflect.FileDescriptor + +var file_aiscan_transport_telemetry_proto_rawDesc = []byte{ + 0x0a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, + 0x72, 0x74, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf4, 0x01, 0x0a, 0x10, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x52, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x68, + 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, + 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x64, + 0x69, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, + 0x67, 0x44, 0x69, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x6f, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x63, 0x68, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, + 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x2d, + 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x8e, 0x01, + 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1a, 0x0a, + 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, + 0x14, 0x0a, 0x05, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xf8, + 0x02, 0x0a, 0x0a, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x14, 0x0a, + 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x74, 0x75, + 0x72, 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, + 0x6c, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x6f, + 0x6f, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x75, 0x6e, 0x6e, 0x69, + 0x6e, 0x67, 0x54, 0x6f, 0x6f, 0x6c, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x69, + 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, + 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0f, 0x63, + 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x2c, + 0x0a, 0x12, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x04, 0x52, 0x10, 0x63, 0x61, 0x63, 0x68, + 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, + 0x61, 0x73, 0x73, 0x65, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x61, 0x73, + 0x73, 0x65, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x6f, 0x6f, 0x74, 0x73, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x05, 0x6c, 0x6f, 0x6f, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x61, + 0x73, 0x74, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x6c, 0x61, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x90, 0x01, 0x0a, 0x0e, 0x54, 0x6f, + 0x6f, 0x6c, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, + 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x22, 0x73, 0x0a, 0x0b, + 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x07, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x22, 0xc9, 0x01, 0x0a, 0x0d, 0x54, 0x6f, 0x6f, 0x6c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, + 0x74, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x74, + 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, + 0x67, 0x65, 0x74, 0x12, 0x25, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, + 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, + 0x6c, 0x49, 0x64, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x39, 0x0a, + 0x08, 0x53, 0x63, 0x6f, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x6c, + 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, + 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0c, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, + 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_aiscan_transport_telemetry_proto_rawDescOnce sync.Once + file_aiscan_transport_telemetry_proto_rawDescData = file_aiscan_transport_telemetry_proto_rawDesc +) + +func file_aiscan_transport_telemetry_proto_rawDescGZIP() []byte { + file_aiscan_transport_telemetry_proto_rawDescOnce.Do(func() { + file_aiscan_transport_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_telemetry_proto_rawDescData) + }) + return file_aiscan_transport_telemetry_proto_rawDescData +} + +var file_aiscan_transport_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_aiscan_transport_telemetry_proto_goTypes = []interface{}{ + (*AgentRuntimeInfo)(nil), // 0: aiscan.transport.AgentRuntimeInfo + (*AgentStatus)(nil), // 1: aiscan.transport.AgentStatus + (*AgentStats)(nil), // 2: aiscan.transport.AgentStats + (*ToolDefinition)(nil), // 3: aiscan.transport.ToolDefinition + (*CommandSpec)(nil), // 4: aiscan.transport.CommandSpec + (*ToolTelemetry)(nil), // 5: aiscan.transport.ToolTelemetry + (*ScoNodes)(nil), // 6: aiscan.transport.ScoNodes + (*aop.EncodedValue)(nil), // 7: aop.EncodedValue + (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp +} +var file_aiscan_transport_telemetry_proto_depIdxs = []int32{ + 7, // 0: aiscan.transport.AgentRuntimeInfo.metadata:type_name -> aop.EncodedValue + 7, // 1: aiscan.transport.ToolDefinition.input_schema:type_name -> aop.EncodedValue + 7, // 2: aiscan.transport.ToolTelemetry.data:type_name -> aop.EncodedValue + 8, // 3: aiscan.transport.ToolTelemetry.timestamp:type_name -> google.protobuf.Timestamp + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_aiscan_transport_telemetry_proto_init() } +func file_aiscan_transport_telemetry_proto_init() { + if File_aiscan_transport_telemetry_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_transport_telemetry_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentRuntimeInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_telemetry_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentStatus); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_telemetry_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentStats); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_telemetry_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ToolDefinition); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_telemetry_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandSpec); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_telemetry_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ToolTelemetry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_telemetry_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScoNodes); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_transport_telemetry_proto_rawDesc, + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_transport_telemetry_proto_goTypes, + DependencyIndexes: file_aiscan_transport_telemetry_proto_depIdxs, + MessageInfos: file_aiscan_transport_telemetry_proto_msgTypes, + }.Build() + File_aiscan_transport_telemetry_proto = out.File + file_aiscan_transport_telemetry_proto_rawDesc = nil + file_aiscan_transport_telemetry_proto_goTypes = nil + file_aiscan_transport_telemetry_proto_depIdxs = nil +} diff --git a/aop/aiscan/transport/terminal.pb.go b/aop/aiscan/transport/terminal.pb.go new file mode 100644 index 00000000..c3b5e063 --- /dev/null +++ b/aop/aiscan/transport/terminal.pb.go @@ -0,0 +1,506 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/transport/terminal.proto + +package transport + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type TerminalInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Command string `protobuf:"bytes,4,opt,name=command,proto3" json:"command,omitempty"` + Pid int32 `protobuf:"varint,5,opt,name=pid,proto3" json:"pid,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + LastActivityAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_activity_at,json=lastActivityAt,proto3" json:"last_activity_at,omitempty"` + EndedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + ActivitySeq int64 `protobuf:"varint,9,opt,name=activity_seq,json=activitySeq,proto3" json:"activity_seq,omitempty"` + OutputBytes int64 `protobuf:"varint,10,opt,name=output_bytes,json=outputBytes,proto3" json:"output_bytes,omitempty"` + ExitCode int32 `protobuf:"varint,11,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + State string `protobuf:"bytes,12,opt,name=state,proto3" json:"state,omitempty"` + KillCause string `protobuf:"bytes,13,opt,name=kill_cause,json=killCause,proto3" json:"kill_cause,omitempty"` +} + +func (x *TerminalInfo) Reset() { + *x = TerminalInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_terminal_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TerminalInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminalInfo) ProtoMessage() {} + +func (x *TerminalInfo) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_terminal_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminalInfo.ProtoReflect.Descriptor instead. +func (*TerminalInfo) Descriptor() ([]byte, []int) { + return file_aiscan_transport_terminal_proto_rawDescGZIP(), []int{0} +} + +func (x *TerminalInfo) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *TerminalInfo) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *TerminalInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TerminalInfo) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *TerminalInfo) GetPid() int32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *TerminalInfo) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +func (x *TerminalInfo) GetLastActivityAt() *timestamppb.Timestamp { + if x != nil { + return x.LastActivityAt + } + return nil +} + +func (x *TerminalInfo) GetEndedAt() *timestamppb.Timestamp { + if x != nil { + return x.EndedAt + } + return nil +} + +func (x *TerminalInfo) GetActivitySeq() int64 { + if x != nil { + return x.ActivitySeq + } + return 0 +} + +func (x *TerminalInfo) GetOutputBytes() int64 { + if x != nil { + return x.OutputBytes + } + return 0 +} + +func (x *TerminalInfo) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *TerminalInfo) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *TerminalInfo) GetKillCause() string { + if x != nil { + return x.KillCause + } + return "" +} + +type TerminalFrame struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + StreamId string `protobuf:"bytes,2,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + SessionId string `protobuf:"bytes,3,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Kind string `protobuf:"bytes,4,opt,name=kind,proto3" json:"kind,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + Command string `protobuf:"bytes,6,opt,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,7,rep,name=args,proto3" json:"args,omitempty"` + Data []byte `protobuf:"bytes,8,opt,name=data,proto3" json:"data,omitempty"` + Cols int32 `protobuf:"varint,9,opt,name=cols,proto3" json:"cols,omitempty"` + Rows int32 `protobuf:"varint,10,opt,name=rows,proto3" json:"rows,omitempty"` + Bytes int32 `protobuf:"varint,11,opt,name=bytes,proto3" json:"bytes,omitempty"` + Offset int64 `protobuf:"varint,12,opt,name=offset,proto3" json:"offset,omitempty"` + Singleton bool `protobuf:"varint,13,opt,name=singleton,proto3" json:"singleton,omitempty"` + Error string `protobuf:"bytes,14,opt,name=error,proto3" json:"error,omitempty"` + State string `protobuf:"bytes,15,opt,name=state,proto3" json:"state,omitempty"` + ExitCode int32 `protobuf:"varint,16,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Session *TerminalInfo `protobuf:"bytes,17,opt,name=session,proto3" json:"session,omitempty"` + Sessions []*TerminalInfo `protobuf:"bytes,18,rep,name=sessions,proto3" json:"sessions,omitempty"` +} + +func (x *TerminalFrame) Reset() { + *x = TerminalFrame{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_transport_terminal_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TerminalFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminalFrame) ProtoMessage() {} + +func (x *TerminalFrame) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_transport_terminal_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminalFrame.ProtoReflect.Descriptor instead. +func (*TerminalFrame) Descriptor() ([]byte, []int) { + return file_aiscan_transport_terminal_proto_rawDescGZIP(), []int{1} +} + +func (x *TerminalFrame) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *TerminalFrame) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *TerminalFrame) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *TerminalFrame) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *TerminalFrame) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TerminalFrame) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *TerminalFrame) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *TerminalFrame) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *TerminalFrame) GetCols() int32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *TerminalFrame) GetRows() int32 { + if x != nil { + return x.Rows + } + return 0 +} + +func (x *TerminalFrame) GetBytes() int32 { + if x != nil { + return x.Bytes + } + return 0 +} + +func (x *TerminalFrame) GetOffset() int64 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *TerminalFrame) GetSingleton() bool { + if x != nil { + return x.Singleton + } + return false +} + +func (x *TerminalFrame) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *TerminalFrame) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *TerminalFrame) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *TerminalFrame) GetSession() *TerminalInfo { + if x != nil { + return x.Session + } + return nil +} + +func (x *TerminalFrame) GetSessions() []*TerminalInfo { + if x != nil { + return x.Sessions + } + return nil +} + +var File_aiscan_transport_terminal_proto protoreflect.FileDescriptor + +var file_aiscan_transport_terminal_proto_rawDesc = []byte{ + 0x0a, 0x1f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, + 0x72, 0x74, 0x2f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, + 0x6f, 0x72, 0x74, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc2, 0x03, 0x0a, 0x0c, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, + 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x65, 0x64, 0x41, 0x74, 0x12, 0x44, 0x0a, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x61, 0x63, 0x74, + 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x61, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0e, 0x6c, 0x61, 0x73, 0x74, + 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, + 0x64, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, + 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x73, 0x65, + 0x71, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, + 0x79, 0x53, 0x65, 0x71, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x62, + 0x79, 0x74, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6f, 0x75, 0x74, 0x70, + 0x75, 0x74, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, + 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, + 0x43, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, 0x69, + 0x6c, 0x6c, 0x5f, 0x63, 0x61, 0x75, 0x73, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x6b, 0x69, 0x6c, 0x6c, 0x43, 0x61, 0x75, 0x73, 0x65, 0x22, 0xfc, 0x03, 0x0a, 0x0d, 0x54, 0x65, + 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, + 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, + 0x73, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x77, + 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x12, 0x14, 0x0a, + 0x05, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x62, 0x79, + 0x74, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, + 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x74, 0x6f, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, + 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x74, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, + 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, + 0x64, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, + 0x64, 0x65, 0x12, 0x38, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x11, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, + 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x49, + 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x08, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, + 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_aiscan_transport_terminal_proto_rawDescOnce sync.Once + file_aiscan_transport_terminal_proto_rawDescData = file_aiscan_transport_terminal_proto_rawDesc +) + +func file_aiscan_transport_terminal_proto_rawDescGZIP() []byte { + file_aiscan_transport_terminal_proto_rawDescOnce.Do(func() { + file_aiscan_transport_terminal_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_terminal_proto_rawDescData) + }) + return file_aiscan_transport_terminal_proto_rawDescData +} + +var file_aiscan_transport_terminal_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_aiscan_transport_terminal_proto_goTypes = []interface{}{ + (*TerminalInfo)(nil), // 0: aiscan.transport.TerminalInfo + (*TerminalFrame)(nil), // 1: aiscan.transport.TerminalFrame + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp +} +var file_aiscan_transport_terminal_proto_depIdxs = []int32{ + 2, // 0: aiscan.transport.TerminalInfo.started_at:type_name -> google.protobuf.Timestamp + 2, // 1: aiscan.transport.TerminalInfo.last_activity_at:type_name -> google.protobuf.Timestamp + 2, // 2: aiscan.transport.TerminalInfo.ended_at:type_name -> google.protobuf.Timestamp + 0, // 3: aiscan.transport.TerminalFrame.session:type_name -> aiscan.transport.TerminalInfo + 0, // 4: aiscan.transport.TerminalFrame.sessions:type_name -> aiscan.transport.TerminalInfo + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_aiscan_transport_terminal_proto_init() } +func file_aiscan_transport_terminal_proto_init() { + if File_aiscan_transport_terminal_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_transport_terminal_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TerminalInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_transport_terminal_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TerminalFrame); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_transport_terminal_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_transport_terminal_proto_goTypes, + DependencyIndexes: file_aiscan_transport_terminal_proto_depIdxs, + MessageInfos: file_aiscan_transport_terminal_proto_msgTypes, + }.Build() + File_aiscan_transport_terminal_proto = out.File + file_aiscan_transport_terminal_proto_rawDesc = nil + file_aiscan_transport_terminal_proto_goTypes = nil + file_aiscan_transport_terminal_proto_depIdxs = nil +} diff --git a/aop/aopconnect/chat.connect.go b/aop/aopconnect/chat.connect.go new file mode 100644 index 00000000..2f974be8 --- /dev/null +++ b/aop/aopconnect/chat.connect.go @@ -0,0 +1,249 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: aop/chat.proto + +package aopconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + aop "github.com/chainreactors/aiscan/aop" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ChatServiceName is the fully-qualified name of the ChatService service. + ChatServiceName = "aop.ChatService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ChatServiceOpenSessionProcedure is the fully-qualified name of the ChatService's OpenSession RPC. + ChatServiceOpenSessionProcedure = "/aop.ChatService/OpenSession" + // ChatServiceRunTurnProcedure is the fully-qualified name of the ChatService's RunTurn RPC. + ChatServiceRunTurnProcedure = "/aop.ChatService/RunTurn" + // ChatServiceCancelTurnProcedure is the fully-qualified name of the ChatService's CancelTurn RPC. + ChatServiceCancelTurnProcedure = "/aop.ChatService/CancelTurn" + // ChatServiceCloseSessionProcedure is the fully-qualified name of the ChatService's CloseSession + // RPC. + ChatServiceCloseSessionProcedure = "/aop.ChatService/CloseSession" + // ChatServiceWatchEventsProcedure is the fully-qualified name of the ChatService's WatchEvents RPC. + ChatServiceWatchEventsProcedure = "/aop.ChatService/WatchEvents" + // ChatServiceListEventsProcedure is the fully-qualified name of the ChatService's ListEvents RPC. + ChatServiceListEventsProcedure = "/aop.ChatService/ListEvents" +) + +// ChatServiceClient is a client for the aop.ChatService service. +type ChatServiceClient interface { + OpenSession(context.Context, *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) + RunTurn(context.Context, *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) + CancelTurn(context.Context, *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) + CloseSession(context.Context, *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) + WatchEvents(context.Context, *connect.Request[aop.WatchEventsRequest]) (*connect.ServerStreamForClient[aop.WatchEventsResponse], error) + ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) +} + +// NewChatServiceClient constructs a client for the aop.ChatService service. By default, it uses the +// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewChatServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ChatServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + chatServiceMethods := aop.File_aop_chat_proto.Services().ByName("ChatService").Methods() + return &chatServiceClient{ + openSession: connect.NewClient[aop.OpenSessionRequest, aop.OpenSessionResponse]( + httpClient, + baseURL+ChatServiceOpenSessionProcedure, + connect.WithSchema(chatServiceMethods.ByName("OpenSession")), + connect.WithClientOptions(opts...), + ), + runTurn: connect.NewClient[aop.RunTurnRequest, aop.RunTurnResponse]( + httpClient, + baseURL+ChatServiceRunTurnProcedure, + connect.WithSchema(chatServiceMethods.ByName("RunTurn")), + connect.WithClientOptions(opts...), + ), + cancelTurn: connect.NewClient[aop.CancelTurnRequest, aop.CancelTurnResponse]( + httpClient, + baseURL+ChatServiceCancelTurnProcedure, + connect.WithSchema(chatServiceMethods.ByName("CancelTurn")), + connect.WithClientOptions(opts...), + ), + closeSession: connect.NewClient[aop.CloseSessionRequest, aop.CloseSessionResponse]( + httpClient, + baseURL+ChatServiceCloseSessionProcedure, + connect.WithSchema(chatServiceMethods.ByName("CloseSession")), + connect.WithClientOptions(opts...), + ), + watchEvents: connect.NewClient[aop.WatchEventsRequest, aop.WatchEventsResponse]( + httpClient, + baseURL+ChatServiceWatchEventsProcedure, + connect.WithSchema(chatServiceMethods.ByName("WatchEvents")), + connect.WithClientOptions(opts...), + ), + listEvents: connect.NewClient[aop.ListEventsRequest, aop.ListEventsResponse]( + httpClient, + baseURL+ChatServiceListEventsProcedure, + connect.WithSchema(chatServiceMethods.ByName("ListEvents")), + connect.WithClientOptions(opts...), + ), + } +} + +// chatServiceClient implements ChatServiceClient. +type chatServiceClient struct { + openSession *connect.Client[aop.OpenSessionRequest, aop.OpenSessionResponse] + runTurn *connect.Client[aop.RunTurnRequest, aop.RunTurnResponse] + cancelTurn *connect.Client[aop.CancelTurnRequest, aop.CancelTurnResponse] + closeSession *connect.Client[aop.CloseSessionRequest, aop.CloseSessionResponse] + watchEvents *connect.Client[aop.WatchEventsRequest, aop.WatchEventsResponse] + listEvents *connect.Client[aop.ListEventsRequest, aop.ListEventsResponse] +} + +// OpenSession calls aop.ChatService.OpenSession. +func (c *chatServiceClient) OpenSession(ctx context.Context, req *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { + return c.openSession.CallUnary(ctx, req) +} + +// RunTurn calls aop.ChatService.RunTurn. +func (c *chatServiceClient) RunTurn(ctx context.Context, req *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { + return c.runTurn.CallUnary(ctx, req) +} + +// CancelTurn calls aop.ChatService.CancelTurn. +func (c *chatServiceClient) CancelTurn(ctx context.Context, req *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) { + return c.cancelTurn.CallUnary(ctx, req) +} + +// CloseSession calls aop.ChatService.CloseSession. +func (c *chatServiceClient) CloseSession(ctx context.Context, req *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) { + return c.closeSession.CallUnary(ctx, req) +} + +// WatchEvents calls aop.ChatService.WatchEvents. +func (c *chatServiceClient) WatchEvents(ctx context.Context, req *connect.Request[aop.WatchEventsRequest]) (*connect.ServerStreamForClient[aop.WatchEventsResponse], error) { + return c.watchEvents.CallServerStream(ctx, req) +} + +// ListEvents calls aop.ChatService.ListEvents. +func (c *chatServiceClient) ListEvents(ctx context.Context, req *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { + return c.listEvents.CallUnary(ctx, req) +} + +// ChatServiceHandler is an implementation of the aop.ChatService service. +type ChatServiceHandler interface { + OpenSession(context.Context, *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) + RunTurn(context.Context, *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) + CancelTurn(context.Context, *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) + CloseSession(context.Context, *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) + WatchEvents(context.Context, *connect.Request[aop.WatchEventsRequest], *connect.ServerStream[aop.WatchEventsResponse]) error + ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) +} + +// NewChatServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewChatServiceHandler(svc ChatServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + chatServiceMethods := aop.File_aop_chat_proto.Services().ByName("ChatService").Methods() + chatServiceOpenSessionHandler := connect.NewUnaryHandler( + ChatServiceOpenSessionProcedure, + svc.OpenSession, + connect.WithSchema(chatServiceMethods.ByName("OpenSession")), + connect.WithHandlerOptions(opts...), + ) + chatServiceRunTurnHandler := connect.NewUnaryHandler( + ChatServiceRunTurnProcedure, + svc.RunTurn, + connect.WithSchema(chatServiceMethods.ByName("RunTurn")), + connect.WithHandlerOptions(opts...), + ) + chatServiceCancelTurnHandler := connect.NewUnaryHandler( + ChatServiceCancelTurnProcedure, + svc.CancelTurn, + connect.WithSchema(chatServiceMethods.ByName("CancelTurn")), + connect.WithHandlerOptions(opts...), + ) + chatServiceCloseSessionHandler := connect.NewUnaryHandler( + ChatServiceCloseSessionProcedure, + svc.CloseSession, + connect.WithSchema(chatServiceMethods.ByName("CloseSession")), + connect.WithHandlerOptions(opts...), + ) + chatServiceWatchEventsHandler := connect.NewServerStreamHandler( + ChatServiceWatchEventsProcedure, + svc.WatchEvents, + connect.WithSchema(chatServiceMethods.ByName("WatchEvents")), + connect.WithHandlerOptions(opts...), + ) + chatServiceListEventsHandler := connect.NewUnaryHandler( + ChatServiceListEventsProcedure, + svc.ListEvents, + connect.WithSchema(chatServiceMethods.ByName("ListEvents")), + connect.WithHandlerOptions(opts...), + ) + return "/aop.ChatService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ChatServiceOpenSessionProcedure: + chatServiceOpenSessionHandler.ServeHTTP(w, r) + case ChatServiceRunTurnProcedure: + chatServiceRunTurnHandler.ServeHTTP(w, r) + case ChatServiceCancelTurnProcedure: + chatServiceCancelTurnHandler.ServeHTTP(w, r) + case ChatServiceCloseSessionProcedure: + chatServiceCloseSessionHandler.ServeHTTP(w, r) + case ChatServiceWatchEventsProcedure: + chatServiceWatchEventsHandler.ServeHTTP(w, r) + case ChatServiceListEventsProcedure: + chatServiceListEventsHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedChatServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedChatServiceHandler struct{} + +func (UnimplementedChatServiceHandler) OpenSession(context.Context, *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.OpenSession is not implemented")) +} + +func (UnimplementedChatServiceHandler) RunTurn(context.Context, *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.RunTurn is not implemented")) +} + +func (UnimplementedChatServiceHandler) CancelTurn(context.Context, *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.CancelTurn is not implemented")) +} + +func (UnimplementedChatServiceHandler) CloseSession(context.Context, *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.CloseSession is not implemented")) +} + +func (UnimplementedChatServiceHandler) WatchEvents(context.Context, *connect.Request[aop.WatchEventsRequest], *connect.ServerStream[aop.WatchEventsResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.WatchEvents is not implemented")) +} + +func (UnimplementedChatServiceHandler) ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.ListEvents is not implemented")) +} diff --git a/aop/chat.pb.go b/aop/chat.pb.go new file mode 100644 index 00000000..6ce2eef8 --- /dev/null +++ b/aop/chat.pb.go @@ -0,0 +1,1661 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/chat.proto + +package aop + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Rejection struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` +} + +func (x *Rejection) Reset() { + *x = Rejection{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Rejection) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Rejection) ProtoMessage() {} + +func (x *Rejection) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Rejection.ProtoReflect.Descriptor instead. +func (*Rejection) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{0} +} + +func (x *Rejection) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *Rejection) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *Rejection) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +func (x *Rejection) GetDetail() *EncodedValue { + if x != nil { + return x.Detail + } + return nil +} + +type Session struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + Participant string `protobuf:"bytes,3,opt,name=participant,proto3" json:"participant,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` +} + +func (x *Session) Reset() { + *x = Session{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Session) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Session) ProtoMessage() {} + +func (x *Session) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Session.ProtoReflect.Descriptor instead. +func (*Session) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{1} +} + +func (x *Session) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Session) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Session) GetParticipant() string { + if x != nil { + return x.Participant + } + return "" +} + +func (x *Session) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +type OpenSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Participant string `protobuf:"bytes,3,opt,name=participant,proto3" json:"participant,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + ParentSessionId string `protobuf:"bytes,5,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` + ParentToolCallId string `protobuf:"bytes,6,opt,name=parent_tool_call_id,json=parentToolCallId,proto3" json:"parent_tool_call_id,omitempty"` + Extensions []*Extension `protobuf:"bytes,7,rep,name=extensions,proto3" json:"extensions,omitempty"` +} + +func (x *OpenSessionRequest) Reset() { + *x = OpenSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpenSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenSessionRequest) ProtoMessage() {} + +func (x *OpenSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenSessionRequest.ProtoReflect.Descriptor instead. +func (*OpenSessionRequest) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{2} +} + +func (x *OpenSessionRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *OpenSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *OpenSessionRequest) GetParticipant() string { + if x != nil { + return x.Participant + } + return "" +} + +func (x *OpenSessionRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *OpenSessionRequest) GetParentSessionId() string { + if x != nil { + return x.ParentSessionId + } + return "" +} + +func (x *OpenSessionRequest) GetParentToolCallId() string { + if x != nil { + return x.ParentToolCallId + } + return "" +} + +func (x *OpenSessionRequest) GetExtensions() []*Extension { + if x != nil { + return x.Extensions + } + return nil +} + +type OpenSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *OpenSessionResponse_Accepted + // *OpenSessionResponse_Rejected + Outcome isOpenSessionResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *OpenSessionResponse) Reset() { + *x = OpenSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpenSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenSessionResponse) ProtoMessage() {} + +func (x *OpenSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenSessionResponse.ProtoReflect.Descriptor instead. +func (*OpenSessionResponse) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{3} +} + +func (x *OpenSessionResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *OpenSessionResponse) GetOutcome() isOpenSessionResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *OpenSessionResponse) GetAccepted() *Session { + if x, ok := x.GetOutcome().(*OpenSessionResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *OpenSessionResponse) GetRejected() *Rejection { + if x, ok := x.GetOutcome().(*OpenSessionResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isOpenSessionResponse_Outcome interface { + isOpenSessionResponse_Outcome() +} + +type OpenSessionResponse_Accepted struct { + Accepted *Session `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type OpenSessionResponse_Rejected struct { + Rejected *Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*OpenSessionResponse_Accepted) isOpenSessionResponse_Outcome() {} + +func (*OpenSessionResponse_Rejected) isOpenSessionResponse_Outcome() {} + +type RunTurnRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + Input *Message `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` + ContinueSession bool `protobuf:"varint,5,opt,name=continue_session,json=continueSession,proto3" json:"continue_session,omitempty"` + MaxTurns uint32 `protobuf:"varint,6,opt,name=max_turns,json=maxTurns,proto3" json:"max_turns,omitempty"` + Extensions []*Extension `protobuf:"bytes,7,rep,name=extensions,proto3" json:"extensions,omitempty"` +} + +func (x *RunTurnRequest) Reset() { + *x = RunTurnRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RunTurnRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunTurnRequest) ProtoMessage() {} + +func (x *RunTurnRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunTurnRequest.ProtoReflect.Descriptor instead. +func (*RunTurnRequest) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{4} +} + +func (x *RunTurnRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *RunTurnRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *RunTurnRequest) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *RunTurnRequest) GetInput() *Message { + if x != nil { + return x.Input + } + return nil +} + +func (x *RunTurnRequest) GetContinueSession() bool { + if x != nil { + return x.ContinueSession + } + return false +} + +func (x *RunTurnRequest) GetMaxTurns() uint32 { + if x != nil { + return x.MaxTurns + } + return 0 +} + +func (x *RunTurnRequest) GetExtensions() []*Extension { + if x != nil { + return x.Extensions + } + return nil +} + +type TurnReceipt struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + TurnId string `protobuf:"bytes,2,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` +} + +func (x *TurnReceipt) Reset() { + *x = TurnReceipt{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TurnReceipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TurnReceipt) ProtoMessage() {} + +func (x *TurnReceipt) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TurnReceipt.ProtoReflect.Descriptor instead. +func (*TurnReceipt) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{5} +} + +func (x *TurnReceipt) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *TurnReceipt) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *TurnReceipt) GetState() string { + if x != nil { + return x.State + } + return "" +} + +type RunTurnResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *RunTurnResponse_Accepted + // *RunTurnResponse_Rejected + Outcome isRunTurnResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *RunTurnResponse) Reset() { + *x = RunTurnResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RunTurnResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunTurnResponse) ProtoMessage() {} + +func (x *RunTurnResponse) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunTurnResponse.ProtoReflect.Descriptor instead. +func (*RunTurnResponse) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{6} +} + +func (x *RunTurnResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *RunTurnResponse) GetOutcome() isRunTurnResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *RunTurnResponse) GetAccepted() *TurnReceipt { + if x, ok := x.GetOutcome().(*RunTurnResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *RunTurnResponse) GetRejected() *Rejection { + if x, ok := x.GetOutcome().(*RunTurnResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isRunTurnResponse_Outcome interface { + isRunTurnResponse_Outcome() +} + +type RunTurnResponse_Accepted struct { + Accepted *TurnReceipt `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type RunTurnResponse_Rejected struct { + Rejected *Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*RunTurnResponse_Accepted) isRunTurnResponse_Outcome() {} + +func (*RunTurnResponse_Rejected) isRunTurnResponse_Outcome() {} + +type CancelTurnRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *CancelTurnRequest) Reset() { + *x = CancelTurnRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelTurnRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelTurnRequest) ProtoMessage() {} + +func (x *CancelTurnRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelTurnRequest.ProtoReflect.Descriptor instead. +func (*CancelTurnRequest) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{7} +} + +func (x *CancelTurnRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *CancelTurnRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *CancelTurnRequest) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *CancelTurnRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type CancelTurnResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *CancelTurnResponse_Accepted + // *CancelTurnResponse_Rejected + Outcome isCancelTurnResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *CancelTurnResponse) Reset() { + *x = CancelTurnResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelTurnResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelTurnResponse) ProtoMessage() {} + +func (x *CancelTurnResponse) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelTurnResponse.ProtoReflect.Descriptor instead. +func (*CancelTurnResponse) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{8} +} + +func (x *CancelTurnResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *CancelTurnResponse) GetOutcome() isCancelTurnResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *CancelTurnResponse) GetAccepted() *TurnReceipt { + if x, ok := x.GetOutcome().(*CancelTurnResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *CancelTurnResponse) GetRejected() *Rejection { + if x, ok := x.GetOutcome().(*CancelTurnResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isCancelTurnResponse_Outcome interface { + isCancelTurnResponse_Outcome() +} + +type CancelTurnResponse_Accepted struct { + Accepted *TurnReceipt `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type CancelTurnResponse_Rejected struct { + Rejected *Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*CancelTurnResponse_Accepted) isCancelTurnResponse_Outcome() {} + +func (*CancelTurnResponse_Rejected) isCancelTurnResponse_Outcome() {} + +type CloseSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *CloseSessionRequest) Reset() { + *x = CloseSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CloseSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseSessionRequest) ProtoMessage() {} + +func (x *CloseSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseSessionRequest.ProtoReflect.Descriptor instead. +func (*CloseSessionRequest) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{9} +} + +func (x *CloseSessionRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *CloseSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *CloseSessionRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type CloseSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *CloseSessionResponse_Accepted + // *CloseSessionResponse_Rejected + Outcome isCloseSessionResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *CloseSessionResponse) Reset() { + *x = CloseSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CloseSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseSessionResponse) ProtoMessage() {} + +func (x *CloseSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseSessionResponse.ProtoReflect.Descriptor instead. +func (*CloseSessionResponse) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{10} +} + +func (x *CloseSessionResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *CloseSessionResponse) GetOutcome() isCloseSessionResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *CloseSessionResponse) GetAccepted() *Session { + if x, ok := x.GetOutcome().(*CloseSessionResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *CloseSessionResponse) GetRejected() *Rejection { + if x, ok := x.GetOutcome().(*CloseSessionResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isCloseSessionResponse_Outcome interface { + isCloseSessionResponse_Outcome() +} + +type CloseSessionResponse_Accepted struct { + Accepted *Session `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type CloseSessionResponse_Rejected struct { + Rejected *Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*CloseSessionResponse_Accepted) isCloseSessionResponse_Outcome() {} + +func (*CloseSessionResponse_Rejected) isCloseSessionResponse_Outcome() {} + +type WatchEventsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + AfterCursor string `protobuf:"bytes,2,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` +} + +func (x *WatchEventsRequest) Reset() { + *x = WatchEventsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WatchEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchEventsRequest) ProtoMessage() {} + +func (x *WatchEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchEventsRequest.ProtoReflect.Descriptor instead. +func (*WatchEventsRequest) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{11} +} + +func (x *WatchEventsRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *WatchEventsRequest) GetAfterCursor() string { + if x != nil { + return x.AfterCursor + } + return "" +} + +type EventDelivery struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Cursor string `protobuf:"bytes,1,opt,name=cursor,proto3" json:"cursor,omitempty"` + Event *Event `protobuf:"bytes,2,opt,name=event,proto3" json:"event,omitempty"` +} + +func (x *EventDelivery) Reset() { + *x = EventDelivery{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EventDelivery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EventDelivery) ProtoMessage() {} + +func (x *EventDelivery) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EventDelivery.ProtoReflect.Descriptor instead. +func (*EventDelivery) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{12} +} + +func (x *EventDelivery) GetCursor() string { + if x != nil { + return x.Cursor + } + return "" +} + +func (x *EventDelivery) GetEvent() *Event { + if x != nil { + return x.Event + } + return nil +} + +type WatchEventsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Delivery *EventDelivery `protobuf:"bytes,1,opt,name=delivery,proto3" json:"delivery,omitempty"` +} + +func (x *WatchEventsResponse) Reset() { + *x = WatchEventsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WatchEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchEventsResponse) ProtoMessage() {} + +func (x *WatchEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchEventsResponse.ProtoReflect.Descriptor instead. +func (*WatchEventsResponse) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{13} +} + +func (x *WatchEventsResponse) GetDelivery() *EventDelivery { + if x != nil { + return x.Delivery + } + return nil +} + +type ListEventsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + AfterCursor string `protobuf:"bytes,2,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` + Limit uint32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` +} + +func (x *ListEventsRequest) Reset() { + *x = ListEventsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEventsRequest) ProtoMessage() {} + +func (x *ListEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEventsRequest.ProtoReflect.Descriptor instead. +func (*ListEventsRequest) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{14} +} + +func (x *ListEventsRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ListEventsRequest) GetAfterCursor() string { + if x != nil { + return x.AfterCursor + } + return "" +} + +func (x *ListEventsRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +type ListEventsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Events []*EventDelivery `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` +} + +func (x *ListEventsResponse) Reset() { + *x = ListEventsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_chat_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEventsResponse) ProtoMessage() {} + +func (x *ListEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aop_chat_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEventsResponse.ProtoReflect.Descriptor instead. +func (*ListEventsResponse) Descriptor() ([]byte, []int) { + return file_aop_chat_proto_rawDescGZIP(), []int{15} +} + +func (x *ListEventsResponse) GetEvents() []*EventDelivery { + if x != nil { + return x.Events + } + return nil +} + +func (x *ListEventsResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +var File_aop_chat_proto protoreflect.FileDescriptor + +var file_aop_chat_proto_rawDesc = []byte{ + 0x0a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x03, 0x61, 0x6f, 0x70, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x76, + 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x82, 0x01, 0x0a, 0x09, 0x52, + 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, + 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, + 0x61, 0x62, 0x6c, 0x65, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, + 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x22, + 0x67, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, + 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x22, 0x95, 0x02, 0x0a, 0x12, 0x4f, 0x70, 0x65, + 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, + 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x20, 0x0a, + 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x12, + 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x2d, 0x0a, 0x13, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, + 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, + 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, + 0x12, 0x2e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x22, 0x99, 0x01, 0x0a, 0x13, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, + 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x83, 0x02, 0x0a, + 0x0e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, + 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, + 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x6f, + 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x75, 0x72, + 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x54, 0x75, 0x72, + 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x78, 0x74, + 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, + 0x6e, 0x73, 0x22, 0x5b, 0x0a, 0x0b, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, + 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, + 0x12, 0x17, 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, + 0x99, 0x01, 0x0a, 0x0f, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x75, 0x72, 0x6e, 0x52, + 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, + 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x82, 0x01, 0x0a, 0x11, + 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, + 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, + 0x17, 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, + 0x22, 0x9c, 0x01, 0x0a, 0x12, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, + 0x75, 0x72, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, + 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, + 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, + 0x6b, 0x0a, 0x13, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x9a, 0x01, 0x0a, + 0x14, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, + 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x56, 0x0a, 0x12, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x21, + 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, + 0x72, 0x22, 0x49, 0x0a, 0x0d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, + 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x20, 0x0a, 0x05, 0x65, 0x76, + 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x45, 0x0a, 0x13, + 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x08, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x52, 0x08, 0x64, 0x65, 0x6c, 0x69, 0x76, + 0x65, 0x72, 0x79, 0x22, 0x6b, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, + 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, + 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, + 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x22, 0x61, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, + 0x6e, 0x74, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, + 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, + 0x73, 0x6f, 0x72, 0x32, 0x8c, 0x03, 0x0a, 0x0b, 0x43, 0x68, 0x61, 0x74, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x6f, + 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, + 0x12, 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, + 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x43, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, + 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x6c, + 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x42, 0x0a, 0x0b, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x17, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x57, 0x61, + 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x30, 0x01, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x12, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_chat_proto_rawDescOnce sync.Once + file_aop_chat_proto_rawDescData = file_aop_chat_proto_rawDesc +) + +func file_aop_chat_proto_rawDescGZIP() []byte { + file_aop_chat_proto_rawDescOnce.Do(func() { + file_aop_chat_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_chat_proto_rawDescData) + }) + return file_aop_chat_proto_rawDescData +} + +var file_aop_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_aop_chat_proto_goTypes = []interface{}{ + (*Rejection)(nil), // 0: aop.Rejection + (*Session)(nil), // 1: aop.Session + (*OpenSessionRequest)(nil), // 2: aop.OpenSessionRequest + (*OpenSessionResponse)(nil), // 3: aop.OpenSessionResponse + (*RunTurnRequest)(nil), // 4: aop.RunTurnRequest + (*TurnReceipt)(nil), // 5: aop.TurnReceipt + (*RunTurnResponse)(nil), // 6: aop.RunTurnResponse + (*CancelTurnRequest)(nil), // 7: aop.CancelTurnRequest + (*CancelTurnResponse)(nil), // 8: aop.CancelTurnResponse + (*CloseSessionRequest)(nil), // 9: aop.CloseSessionRequest + (*CloseSessionResponse)(nil), // 10: aop.CloseSessionResponse + (*WatchEventsRequest)(nil), // 11: aop.WatchEventsRequest + (*EventDelivery)(nil), // 12: aop.EventDelivery + (*WatchEventsResponse)(nil), // 13: aop.WatchEventsResponse + (*ListEventsRequest)(nil), // 14: aop.ListEventsRequest + (*ListEventsResponse)(nil), // 15: aop.ListEventsResponse + (*EncodedValue)(nil), // 16: aop.EncodedValue + (*Extension)(nil), // 17: aop.Extension + (*Message)(nil), // 18: aop.Message + (*Event)(nil), // 19: aop.Event +} +var file_aop_chat_proto_depIdxs = []int32{ + 16, // 0: aop.Rejection.detail:type_name -> aop.EncodedValue + 17, // 1: aop.OpenSessionRequest.extensions:type_name -> aop.Extension + 1, // 2: aop.OpenSessionResponse.accepted:type_name -> aop.Session + 0, // 3: aop.OpenSessionResponse.rejected:type_name -> aop.Rejection + 18, // 4: aop.RunTurnRequest.input:type_name -> aop.Message + 17, // 5: aop.RunTurnRequest.extensions:type_name -> aop.Extension + 5, // 6: aop.RunTurnResponse.accepted:type_name -> aop.TurnReceipt + 0, // 7: aop.RunTurnResponse.rejected:type_name -> aop.Rejection + 5, // 8: aop.CancelTurnResponse.accepted:type_name -> aop.TurnReceipt + 0, // 9: aop.CancelTurnResponse.rejected:type_name -> aop.Rejection + 1, // 10: aop.CloseSessionResponse.accepted:type_name -> aop.Session + 0, // 11: aop.CloseSessionResponse.rejected:type_name -> aop.Rejection + 19, // 12: aop.EventDelivery.event:type_name -> aop.Event + 12, // 13: aop.WatchEventsResponse.delivery:type_name -> aop.EventDelivery + 12, // 14: aop.ListEventsResponse.events:type_name -> aop.EventDelivery + 2, // 15: aop.ChatService.OpenSession:input_type -> aop.OpenSessionRequest + 4, // 16: aop.ChatService.RunTurn:input_type -> aop.RunTurnRequest + 7, // 17: aop.ChatService.CancelTurn:input_type -> aop.CancelTurnRequest + 9, // 18: aop.ChatService.CloseSession:input_type -> aop.CloseSessionRequest + 11, // 19: aop.ChatService.WatchEvents:input_type -> aop.WatchEventsRequest + 14, // 20: aop.ChatService.ListEvents:input_type -> aop.ListEventsRequest + 3, // 21: aop.ChatService.OpenSession:output_type -> aop.OpenSessionResponse + 6, // 22: aop.ChatService.RunTurn:output_type -> aop.RunTurnResponse + 8, // 23: aop.ChatService.CancelTurn:output_type -> aop.CancelTurnResponse + 10, // 24: aop.ChatService.CloseSession:output_type -> aop.CloseSessionResponse + 13, // 25: aop.ChatService.WatchEvents:output_type -> aop.WatchEventsResponse + 15, // 26: aop.ChatService.ListEvents:output_type -> aop.ListEventsResponse + 21, // [21:27] is the sub-list for method output_type + 15, // [15:21] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name +} + +func init() { file_aop_chat_proto_init() } +func file_aop_chat_proto_init() { + if File_aop_chat_proto != nil { + return + } + file_aop_content_proto_init() + file_aop_event_proto_init() + file_aop_value_proto_init() + if !protoimpl.UnsafeEnabled { + file_aop_chat_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Rejection); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Session); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OpenSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OpenSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RunTurnRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TurnReceipt); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RunTurnResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelTurnRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelTurnResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CloseSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CloseSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WatchEventsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EventDelivery); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WatchEventsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListEventsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_chat_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListEventsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_chat_proto_msgTypes[3].OneofWrappers = []interface{}{ + (*OpenSessionResponse_Accepted)(nil), + (*OpenSessionResponse_Rejected)(nil), + } + file_aop_chat_proto_msgTypes[6].OneofWrappers = []interface{}{ + (*RunTurnResponse_Accepted)(nil), + (*RunTurnResponse_Rejected)(nil), + } + file_aop_chat_proto_msgTypes[8].OneofWrappers = []interface{}{ + (*CancelTurnResponse_Accepted)(nil), + (*CancelTurnResponse_Rejected)(nil), + } + file_aop_chat_proto_msgTypes[10].OneofWrappers = []interface{}{ + (*CloseSessionResponse_Accepted)(nil), + (*CloseSessionResponse_Rejected)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_chat_proto_rawDesc, + NumEnums: 0, + NumMessages: 16, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aop_chat_proto_goTypes, + DependencyIndexes: file_aop_chat_proto_depIdxs, + MessageInfos: file_aop_chat_proto_msgTypes, + }.Build() + File_aop_chat_proto = out.File + file_aop_chat_proto_rawDesc = nil + file_aop_chat_proto_goTypes = nil + file_aop_chat_proto_depIdxs = nil +} diff --git a/aop/chat_grpc.pb.go b/aop/chat_grpc.pb.go new file mode 100644 index 00000000..c738e370 --- /dev/null +++ b/aop/chat_grpc.pb.go @@ -0,0 +1,322 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v6.33.0 +// source: aop/chat.proto + +package aop + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + ChatService_OpenSession_FullMethodName = "/aop.ChatService/OpenSession" + ChatService_RunTurn_FullMethodName = "/aop.ChatService/RunTurn" + ChatService_CancelTurn_FullMethodName = "/aop.ChatService/CancelTurn" + ChatService_CloseSession_FullMethodName = "/aop.ChatService/CloseSession" + ChatService_WatchEvents_FullMethodName = "/aop.ChatService/WatchEvents" + ChatService_ListEvents_FullMethodName = "/aop.ChatService/ListEvents" +) + +// ChatServiceClient is the client API for ChatService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ChatServiceClient interface { + OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionResponse, error) + RunTurn(ctx context.Context, in *RunTurnRequest, opts ...grpc.CallOption) (*RunTurnResponse, error) + CancelTurn(ctx context.Context, in *CancelTurnRequest, opts ...grpc.CallOption) (*CancelTurnResponse, error) + CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error) + WatchEvents(ctx context.Context, in *WatchEventsRequest, opts ...grpc.CallOption) (ChatService_WatchEventsClient, error) + ListEvents(ctx context.Context, in *ListEventsRequest, opts ...grpc.CallOption) (*ListEventsResponse, error) +} + +type chatServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewChatServiceClient(cc grpc.ClientConnInterface) ChatServiceClient { + return &chatServiceClient{cc} +} + +func (c *chatServiceClient) OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionResponse, error) { + out := new(OpenSessionResponse) + err := c.cc.Invoke(ctx, ChatService_OpenSession_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) RunTurn(ctx context.Context, in *RunTurnRequest, opts ...grpc.CallOption) (*RunTurnResponse, error) { + out := new(RunTurnResponse) + err := c.cc.Invoke(ctx, ChatService_RunTurn_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) CancelTurn(ctx context.Context, in *CancelTurnRequest, opts ...grpc.CallOption) (*CancelTurnResponse, error) { + out := new(CancelTurnResponse) + err := c.cc.Invoke(ctx, ChatService_CancelTurn_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error) { + out := new(CloseSessionResponse) + err := c.cc.Invoke(ctx, ChatService_CloseSession_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *chatServiceClient) WatchEvents(ctx context.Context, in *WatchEventsRequest, opts ...grpc.CallOption) (ChatService_WatchEventsClient, error) { + stream, err := c.cc.NewStream(ctx, &ChatService_ServiceDesc.Streams[0], ChatService_WatchEvents_FullMethodName, opts...) + if err != nil { + return nil, err + } + x := &chatServiceWatchEventsClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type ChatService_WatchEventsClient interface { + Recv() (*WatchEventsResponse, error) + grpc.ClientStream +} + +type chatServiceWatchEventsClient struct { + grpc.ClientStream +} + +func (x *chatServiceWatchEventsClient) Recv() (*WatchEventsResponse, error) { + m := new(WatchEventsResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +func (c *chatServiceClient) ListEvents(ctx context.Context, in *ListEventsRequest, opts ...grpc.CallOption) (*ListEventsResponse, error) { + out := new(ListEventsResponse) + err := c.cc.Invoke(ctx, ChatService_ListEvents_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ChatServiceServer is the server API for ChatService service. +// All implementations must embed UnimplementedChatServiceServer +// for forward compatibility +type ChatServiceServer interface { + OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionResponse, error) + RunTurn(context.Context, *RunTurnRequest) (*RunTurnResponse, error) + CancelTurn(context.Context, *CancelTurnRequest) (*CancelTurnResponse, error) + CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error) + WatchEvents(*WatchEventsRequest, ChatService_WatchEventsServer) error + ListEvents(context.Context, *ListEventsRequest) (*ListEventsResponse, error) + mustEmbedUnimplementedChatServiceServer() +} + +// UnimplementedChatServiceServer must be embedded to have forward compatible implementations. +type UnimplementedChatServiceServer struct { +} + +func (UnimplementedChatServiceServer) OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method OpenSession not implemented") +} +func (UnimplementedChatServiceServer) RunTurn(context.Context, *RunTurnRequest) (*RunTurnResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RunTurn not implemented") +} +func (UnimplementedChatServiceServer) CancelTurn(context.Context, *CancelTurnRequest) (*CancelTurnResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelTurn not implemented") +} +func (UnimplementedChatServiceServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CloseSession not implemented") +} +func (UnimplementedChatServiceServer) WatchEvents(*WatchEventsRequest, ChatService_WatchEventsServer) error { + return status.Errorf(codes.Unimplemented, "method WatchEvents not implemented") +} +func (UnimplementedChatServiceServer) ListEvents(context.Context, *ListEventsRequest) (*ListEventsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListEvents not implemented") +} +func (UnimplementedChatServiceServer) mustEmbedUnimplementedChatServiceServer() {} + +// UnsafeChatServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ChatServiceServer will +// result in compilation errors. +type UnsafeChatServiceServer interface { + mustEmbedUnimplementedChatServiceServer() +} + +func RegisterChatServiceServer(s grpc.ServiceRegistrar, srv ChatServiceServer) { + s.RegisterService(&ChatService_ServiceDesc, srv) +} + +func _ChatService_OpenSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).OpenSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_OpenSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).OpenSession(ctx, req.(*OpenSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_RunTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RunTurnRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).RunTurn(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_RunTurn_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).RunTurn(ctx, req.(*RunTurnRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_CancelTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelTurnRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).CancelTurn(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_CancelTurn_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).CancelTurn(ctx, req.(*CancelTurnRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CloseSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).CloseSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_CloseSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).CloseSession(ctx, req.(*CloseSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChatService_WatchEvents_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(WatchEventsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ChatServiceServer).WatchEvents(m, &chatServiceWatchEventsServer{stream}) +} + +type ChatService_WatchEventsServer interface { + Send(*WatchEventsResponse) error + grpc.ServerStream +} + +type chatServiceWatchEventsServer struct { + grpc.ServerStream +} + +func (x *chatServiceWatchEventsServer) Send(m *WatchEventsResponse) error { + return x.ServerStream.SendMsg(m) +} + +func _ChatService_ListEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListEventsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatServiceServer).ListEvents(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatService_ListEvents_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatServiceServer).ListEvents(ctx, req.(*ListEventsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ChatService_ServiceDesc is the grpc.ServiceDesc for ChatService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ChatService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "aop.ChatService", + HandlerType: (*ChatServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "OpenSession", + Handler: _ChatService_OpenSession_Handler, + }, + { + MethodName: "RunTurn", + Handler: _ChatService_RunTurn_Handler, + }, + { + MethodName: "CancelTurn", + Handler: _ChatService_CancelTurn_Handler, + }, + { + MethodName: "CloseSession", + Handler: _ChatService_CloseSession_Handler, + }, + { + MethodName: "ListEvents", + Handler: _ChatService_ListEvents_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "WatchEvents", + Handler: _ChatService_WatchEvents_Handler, + ServerStreams: true, + }, + }, + Metadata: "aop/chat.proto", +} diff --git a/aop/content.pb.go b/aop/content.pb.go new file mode 100644 index 00000000..3d83172c --- /dev/null +++ b/aop/content.pb.go @@ -0,0 +1,1159 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/content.proto + +package aop + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Resource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Source: + // + // *Resource_Data + // *Resource_Uri + Source isResource_Source `protobuf_oneof:"source"` + MediaType string `protobuf:"bytes,3,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` + Filename string `protobuf:"bytes,4,opt,name=filename,proto3" json:"filename,omitempty"` +} + +func (x *Resource) Reset() { + *x = Resource{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Resource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Resource) ProtoMessage() {} + +func (x *Resource) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Resource.ProtoReflect.Descriptor instead. +func (*Resource) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{0} +} + +func (m *Resource) GetSource() isResource_Source { + if m != nil { + return m.Source + } + return nil +} + +func (x *Resource) GetData() []byte { + if x, ok := x.GetSource().(*Resource_Data); ok { + return x.Data + } + return nil +} + +func (x *Resource) GetUri() string { + if x, ok := x.GetSource().(*Resource_Uri); ok { + return x.Uri + } + return "" +} + +func (x *Resource) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +func (x *Resource) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +type isResource_Source interface { + isResource_Source() +} + +type Resource_Data struct { + Data []byte `protobuf:"bytes,1,opt,name=data,proto3,oneof"` +} + +type Resource_Uri struct { + Uri string `protobuf:"bytes,2,opt,name=uri,proto3,oneof"` +} + +func (*Resource_Data) isResource_Source() {} + +func (*Resource_Uri) isResource_Source() {} + +type Annotation struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Start uint64 `protobuf:"varint,2,opt,name=start,proto3" json:"start,omitempty"` + End uint64 `protobuf:"varint,3,opt,name=end,proto3" json:"end,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + Uri string `protobuf:"bytes,5,opt,name=uri,proto3" json:"uri,omitempty"` + Detail *EncodedValue `protobuf:"bytes,6,opt,name=detail,proto3" json:"detail,omitempty"` +} + +func (x *Annotation) Reset() { + *x = Annotation{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Annotation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Annotation) ProtoMessage() {} + +func (x *Annotation) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Annotation.ProtoReflect.Descriptor instead. +func (*Annotation) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{1} +} + +func (x *Annotation) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Annotation) GetStart() uint64 { + if x != nil { + return x.Start + } + return 0 +} + +func (x *Annotation) GetEnd() uint64 { + if x != nil { + return x.End + } + return 0 +} + +func (x *Annotation) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Annotation) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *Annotation) GetDetail() *EncodedValue { + if x != nil { + return x.Detail + } + return nil +} + +type TextContent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + Annotations []*Annotation `protobuf:"bytes,2,rep,name=annotations,proto3" json:"annotations,omitempty"` +} + +func (x *TextContent) Reset() { + *x = TextContent{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TextContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TextContent) ProtoMessage() {} + +func (x *TextContent) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TextContent.ProtoReflect.Descriptor instead. +func (*TextContent) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{2} +} + +func (x *TextContent) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *TextContent) GetAnnotations() []*Annotation { + if x != nil { + return x.Annotations + } + return nil +} + +type ReasoningContent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + Opaque []byte `protobuf:"bytes,2,opt,name=opaque,proto3" json:"opaque,omitempty"` + OpaqueType string `protobuf:"bytes,3,opt,name=opaque_type,json=opaqueType,proto3" json:"opaque_type,omitempty"` +} + +func (x *ReasoningContent) Reset() { + *x = ReasoningContent{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReasoningContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReasoningContent) ProtoMessage() {} + +func (x *ReasoningContent) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReasoningContent.ProtoReflect.Descriptor instead. +func (*ReasoningContent) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{3} +} + +func (x *ReasoningContent) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *ReasoningContent) GetOpaque() []byte { + if x != nil { + return x.Opaque + } + return nil +} + +func (x *ReasoningContent) GetOpaqueType() string { + if x != nil { + return x.OpaqueType + } + return "" +} + +type MediaContent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + Resource *Resource `protobuf:"bytes,2,opt,name=resource,proto3" json:"resource,omitempty"` + Transcript string `protobuf:"bytes,3,opt,name=transcript,proto3" json:"transcript,omitempty"` + Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` +} + +func (x *MediaContent) Reset() { + *x = MediaContent{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MediaContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MediaContent) ProtoMessage() {} + +func (x *MediaContent) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MediaContent.ProtoReflect.Descriptor instead. +func (*MediaContent) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{4} +} + +func (x *MediaContent) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *MediaContent) GetResource() *Resource { + if x != nil { + return x.Resource + } + return nil +} + +func (x *MediaContent) GetTranscript() string { + if x != nil { + return x.Transcript + } + return "" +} + +func (x *MediaContent) GetDetail() *EncodedValue { + if x != nil { + return x.Detail + } + return nil +} + +type ToolCall struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Kind string `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"` + Arguments *EncodedValue `protobuf:"bytes,4,opt,name=arguments,proto3" json:"arguments,omitempty"` + WorkingDirectory string `protobuf:"bytes,5,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` +} + +func (x *ToolCall) Reset() { + *x = ToolCall{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ToolCall) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolCall) ProtoMessage() {} + +func (x *ToolCall) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead. +func (*ToolCall) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{5} +} + +func (x *ToolCall) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ToolCall) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolCall) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *ToolCall) GetArguments() *EncodedValue { + if x != nil { + return x.Arguments + } + return nil +} + +func (x *ToolCall) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +type ToolResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + Output []*Content `protobuf:"bytes,2,rep,name=output,proto3" json:"output,omitempty"` + IsError bool `protobuf:"varint,3,opt,name=is_error,json=isError,proto3" json:"is_error,omitempty"` + Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + DurationMs uint64 `protobuf:"varint,6,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + Terminate bool `protobuf:"varint,7,opt,name=terminate,proto3" json:"terminate,omitempty"` +} + +func (x *ToolResult) Reset() { + *x = ToolResult{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ToolResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolResult) ProtoMessage() {} + +func (x *ToolResult) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ToolResult.ProtoReflect.Descriptor instead. +func (*ToolResult) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{6} +} + +func (x *ToolResult) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ToolResult) GetOutput() []*Content { + if x != nil { + return x.Output + } + return nil +} + +func (x *ToolResult) GetIsError() bool { + if x != nil { + return x.IsError + } + return false +} + +func (x *ToolResult) GetDetail() *EncodedValue { + if x != nil { + return x.Detail + } + return nil +} + +func (x *ToolResult) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolResult) GetDurationMs() uint64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +func (x *ToolResult) GetTerminate() bool { + if x != nil { + return x.Terminate + } + return false +} + +type OpaqueContent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Value *EncodedValue `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` +} + +func (x *OpaqueContent) Reset() { + *x = OpaqueContent{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpaqueContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpaqueContent) ProtoMessage() {} + +func (x *OpaqueContent) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpaqueContent.ProtoReflect.Descriptor instead. +func (*OpaqueContent) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{7} +} + +func (x *OpaqueContent) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *OpaqueContent) GetValue() *EncodedValue { + if x != nil { + return x.Value + } + return nil +} + +type Content struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Value: + // + // *Content_Text + // *Content_Reasoning + // *Content_Refusal + // *Content_Media + // *Content_ToolCall + // *Content_ToolResult + // *Content_Opaque + Value isContent_Value `protobuf_oneof:"value"` +} + +func (x *Content) Reset() { + *x = Content{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Content) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Content) ProtoMessage() {} + +func (x *Content) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Content.ProtoReflect.Descriptor instead. +func (*Content) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{8} +} + +func (m *Content) GetValue() isContent_Value { + if m != nil { + return m.Value + } + return nil +} + +func (x *Content) GetText() *TextContent { + if x, ok := x.GetValue().(*Content_Text); ok { + return x.Text + } + return nil +} + +func (x *Content) GetReasoning() *ReasoningContent { + if x, ok := x.GetValue().(*Content_Reasoning); ok { + return x.Reasoning + } + return nil +} + +func (x *Content) GetRefusal() string { + if x, ok := x.GetValue().(*Content_Refusal); ok { + return x.Refusal + } + return "" +} + +func (x *Content) GetMedia() *MediaContent { + if x, ok := x.GetValue().(*Content_Media); ok { + return x.Media + } + return nil +} + +func (x *Content) GetToolCall() *ToolCall { + if x, ok := x.GetValue().(*Content_ToolCall); ok { + return x.ToolCall + } + return nil +} + +func (x *Content) GetToolResult() *ToolResult { + if x, ok := x.GetValue().(*Content_ToolResult); ok { + return x.ToolResult + } + return nil +} + +func (x *Content) GetOpaque() *OpaqueContent { + if x, ok := x.GetValue().(*Content_Opaque); ok { + return x.Opaque + } + return nil +} + +type isContent_Value interface { + isContent_Value() +} + +type Content_Text struct { + Text *TextContent `protobuf:"bytes,1,opt,name=text,proto3,oneof"` +} + +type Content_Reasoning struct { + Reasoning *ReasoningContent `protobuf:"bytes,2,opt,name=reasoning,proto3,oneof"` +} + +type Content_Refusal struct { + Refusal string `protobuf:"bytes,3,opt,name=refusal,proto3,oneof"` +} + +type Content_Media struct { + Media *MediaContent `protobuf:"bytes,4,opt,name=media,proto3,oneof"` +} + +type Content_ToolCall struct { + ToolCall *ToolCall `protobuf:"bytes,5,opt,name=tool_call,json=toolCall,proto3,oneof"` +} + +type Content_ToolResult struct { + ToolResult *ToolResult `protobuf:"bytes,6,opt,name=tool_result,json=toolResult,proto3,oneof"` +} + +type Content_Opaque struct { + Opaque *OpaqueContent `protobuf:"bytes,7,opt,name=opaque,proto3,oneof"` +} + +func (*Content_Text) isContent_Value() {} + +func (*Content_Reasoning) isContent_Value() {} + +func (*Content_Refusal) isContent_Value() {} + +func (*Content_Media) isContent_Value() {} + +func (*Content_ToolCall) isContent_Value() {} + +func (*Content_ToolResult) isContent_Value() {} + +func (*Content_Opaque) isContent_Value() {} + +type Message struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Content []*Content `protobuf:"bytes,4,rep,name=content,proto3" json:"content,omitempty"` +} + +func (x *Message) Reset() { + *x = Message{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_content_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Message) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Message) ProtoMessage() {} + +func (x *Message) ProtoReflect() protoreflect.Message { + mi := &file_aop_content_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Message.ProtoReflect.Descriptor instead. +func (*Message) Descriptor() ([]byte, []int) { + return file_aop_content_proto_rawDescGZIP(), []int{9} +} + +func (x *Message) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Message) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *Message) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Message) GetContent() []*Content { + if x != nil { + return x.Content + } + return nil +} + +var File_aop_content_proto protoreflect.FileDescriptor + +var file_aop_content_proto_rawDesc = []byte{ + 0x0a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x6f, 0x70, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x79, 0x0a, 0x08, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x03, 0x75, + 0x72, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, + 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, + 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x22, 0x9b, 0x01, 0x0a, 0x0a, 0x41, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, + 0x03, 0x65, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, + 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, + 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, + 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, + 0x69, 0x6c, 0x22, 0x54, 0x0a, 0x0b, 0x54, 0x65, 0x78, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, 0x31, 0x0a, 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x41, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x61, 0x6e, 0x6e, + 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x5f, 0x0a, 0x10, 0x52, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x70, 0x61, 0x71, + 0x75, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, + 0x70, 0x61, 0x71, 0x75, 0x65, 0x54, 0x79, 0x70, 0x65, 0x22, 0x98, 0x01, 0x0a, 0x0c, 0x4d, 0x65, + 0x64, 0x69, 0x61, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, + 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x29, + 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x72, 0x61, + 0x6e, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, + 0x72, 0x61, 0x6e, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, + 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, + 0x74, 0x61, 0x69, 0x6c, 0x22, 0xa0, 0x01, 0x0a, 0x08, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, + 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x2f, 0x0a, 0x09, 0x61, 0x72, 0x67, + 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, + 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x77, 0x6f, + 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x44, 0x69, + 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x22, 0xe4, 0x01, 0x0a, 0x0a, 0x54, 0x6f, 0x6f, 0x6c, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x12, + 0x24, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x6f, + 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x45, 0x72, 0x72, 0x6f, 0x72, + 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, + 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x09, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x22, 0x4c, + 0x0a, 0x0d, 0x4f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, + 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, + 0x79, 0x70, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xc8, 0x02, 0x0a, + 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x65, 0x78, + 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, + 0x12, 0x35, 0x0a, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, + 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x09, 0x72, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x1a, 0x0a, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, + 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x66, 0x75, + 0x73, 0x61, 0x6c, 0x12, 0x29, 0x0a, 0x05, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x12, 0x2c, + 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, + 0x48, 0x00, 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x32, 0x0a, 0x0b, + 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x12, 0x2c, 0x0a, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x43, 0x6f, 0x6e, + 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x42, 0x07, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x69, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x07, 0x63, 0x6f, + 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, + 0x70, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_content_proto_rawDescOnce sync.Once + file_aop_content_proto_rawDescData = file_aop_content_proto_rawDesc +) + +func file_aop_content_proto_rawDescGZIP() []byte { + file_aop_content_proto_rawDescOnce.Do(func() { + file_aop_content_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_content_proto_rawDescData) + }) + return file_aop_content_proto_rawDescData +} + +var file_aop_content_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_aop_content_proto_goTypes = []interface{}{ + (*Resource)(nil), // 0: aop.Resource + (*Annotation)(nil), // 1: aop.Annotation + (*TextContent)(nil), // 2: aop.TextContent + (*ReasoningContent)(nil), // 3: aop.ReasoningContent + (*MediaContent)(nil), // 4: aop.MediaContent + (*ToolCall)(nil), // 5: aop.ToolCall + (*ToolResult)(nil), // 6: aop.ToolResult + (*OpaqueContent)(nil), // 7: aop.OpaqueContent + (*Content)(nil), // 8: aop.Content + (*Message)(nil), // 9: aop.Message + (*EncodedValue)(nil), // 10: aop.EncodedValue +} +var file_aop_content_proto_depIdxs = []int32{ + 10, // 0: aop.Annotation.detail:type_name -> aop.EncodedValue + 1, // 1: aop.TextContent.annotations:type_name -> aop.Annotation + 0, // 2: aop.MediaContent.resource:type_name -> aop.Resource + 10, // 3: aop.MediaContent.detail:type_name -> aop.EncodedValue + 10, // 4: aop.ToolCall.arguments:type_name -> aop.EncodedValue + 8, // 5: aop.ToolResult.output:type_name -> aop.Content + 10, // 6: aop.ToolResult.detail:type_name -> aop.EncodedValue + 10, // 7: aop.OpaqueContent.value:type_name -> aop.EncodedValue + 2, // 8: aop.Content.text:type_name -> aop.TextContent + 3, // 9: aop.Content.reasoning:type_name -> aop.ReasoningContent + 4, // 10: aop.Content.media:type_name -> aop.MediaContent + 5, // 11: aop.Content.tool_call:type_name -> aop.ToolCall + 6, // 12: aop.Content.tool_result:type_name -> aop.ToolResult + 7, // 13: aop.Content.opaque:type_name -> aop.OpaqueContent + 8, // 14: aop.Message.content:type_name -> aop.Content + 15, // [15:15] is the sub-list for method output_type + 15, // [15:15] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name +} + +func init() { file_aop_content_proto_init() } +func file_aop_content_proto_init() { + if File_aop_content_proto != nil { + return + } + file_aop_value_proto_init() + if !protoimpl.UnsafeEnabled { + file_aop_content_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Resource); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Annotation); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TextContent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReasoningContent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MediaContent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ToolCall); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ToolResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OpaqueContent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Content); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_content_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Message); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_content_proto_msgTypes[0].OneofWrappers = []interface{}{ + (*Resource_Data)(nil), + (*Resource_Uri)(nil), + } + file_aop_content_proto_msgTypes[8].OneofWrappers = []interface{}{ + (*Content_Text)(nil), + (*Content_Reasoning)(nil), + (*Content_Refusal)(nil), + (*Content_Media)(nil), + (*Content_ToolCall)(nil), + (*Content_ToolResult)(nil), + (*Content_Opaque)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_content_proto_rawDesc, + NumEnums: 0, + NumMessages: 10, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_content_proto_goTypes, + DependencyIndexes: file_aop_content_proto_depIdxs, + MessageInfos: file_aop_content_proto_msgTypes, + }.Build() + File_aop_content_proto = out.File + file_aop_content_proto_rawDesc = nil + file_aop_content_proto_goTypes = nil + file_aop_content_proto_depIdxs = nil +} diff --git a/aop/event.pb.go b/aop/event.pb.go new file mode 100644 index 00000000..259d5a12 --- /dev/null +++ b/aop/event.pb.go @@ -0,0 +1,1778 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/event.proto + +package aop + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DeltaOperation int32 + +const ( + DeltaOperation_DELTA_OPERATION_UNSPECIFIED DeltaOperation = 0 + DeltaOperation_DELTA_OPERATION_START DeltaOperation = 1 + DeltaOperation_DELTA_OPERATION_APPEND DeltaOperation = 2 + DeltaOperation_DELTA_OPERATION_REPLACE DeltaOperation = 3 + DeltaOperation_DELTA_OPERATION_END DeltaOperation = 4 +) + +// Enum value maps for DeltaOperation. +var ( + DeltaOperation_name = map[int32]string{ + 0: "DELTA_OPERATION_UNSPECIFIED", + 1: "DELTA_OPERATION_START", + 2: "DELTA_OPERATION_APPEND", + 3: "DELTA_OPERATION_REPLACE", + 4: "DELTA_OPERATION_END", + } + DeltaOperation_value = map[string]int32{ + "DELTA_OPERATION_UNSPECIFIED": 0, + "DELTA_OPERATION_START": 1, + "DELTA_OPERATION_APPEND": 2, + "DELTA_OPERATION_REPLACE": 3, + "DELTA_OPERATION_END": 4, + } +) + +func (x DeltaOperation) Enum() *DeltaOperation { + p := new(DeltaOperation) + *p = x + return p +} + +func (x DeltaOperation) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DeltaOperation) Descriptor() protoreflect.EnumDescriptor { + return file_aop_event_proto_enumTypes[0].Descriptor() +} + +func (DeltaOperation) Type() protoreflect.EnumType { + return &file_aop_event_proto_enumTypes[0] +} + +func (x DeltaOperation) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DeltaOperation.Descriptor instead. +func (DeltaOperation) EnumDescriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{0} +} + +type Direction int32 + +const ( + Direction_DIRECTION_UNSPECIFIED Direction = 0 + Direction_DIRECTION_REQUEST Direction = 1 + Direction_DIRECTION_RESPONSE Direction = 2 +) + +// Enum value maps for Direction. +var ( + Direction_name = map[int32]string{ + 0: "DIRECTION_UNSPECIFIED", + 1: "DIRECTION_REQUEST", + 2: "DIRECTION_RESPONSE", + } + Direction_value = map[string]int32{ + "DIRECTION_UNSPECIFIED": 0, + "DIRECTION_REQUEST": 1, + "DIRECTION_RESPONSE": 2, + } +) + +func (x Direction) Enum() *Direction { + p := new(Direction) + *p = x + return p +} + +func (x Direction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Direction) Descriptor() protoreflect.EnumDescriptor { + return file_aop_event_proto_enumTypes[1].Descriptor() +} + +func (Direction) Type() protoreflect.EnumType { + return &file_aop_event_proto_enumTypes[1] +} + +func (x Direction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Direction.Descriptor instead. +func (Direction) EnumDescriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{1} +} + +type SessionStarted struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` + ParentSessionId string `protobuf:"bytes,2,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` + ParentToolCallId string `protobuf:"bytes,3,opt,name=parent_tool_call_id,json=parentToolCallId,proto3" json:"parent_tool_call_id,omitempty"` +} + +func (x *SessionStarted) Reset() { + *x = SessionStarted{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SessionStarted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionStarted) ProtoMessage() {} + +func (x *SessionStarted) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionStarted.ProtoReflect.Descriptor instead. +func (*SessionStarted) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{0} +} + +func (x *SessionStarted) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *SessionStarted) GetParentSessionId() string { + if x != nil { + return x.ParentSessionId + } + return "" +} + +func (x *SessionStarted) GetParentToolCallId() string { + if x != nil { + return x.ParentToolCallId + } + return "" +} + +type SessionEnded struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *SessionEnded) Reset() { + *x = SessionEnded{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SessionEnded) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionEnded) ProtoMessage() {} + +func (x *SessionEnded) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionEnded.ProtoReflect.Descriptor instead. +func (*SessionEnded) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{1} +} + +func (x *SessionEnded) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type TurnStarted struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *TurnStarted) Reset() { + *x = TurnStarted{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TurnStarted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TurnStarted) ProtoMessage() {} + +func (x *TurnStarted) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TurnStarted.ProtoReflect.Descriptor instead. +func (*TurnStarted) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{2} +} + +type ProtocolError struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` +} + +func (x *ProtocolError) Reset() { + *x = ProtocolError{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolError) ProtoMessage() {} + +func (x *ProtocolError) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolError.ProtoReflect.Descriptor instead. +func (*ProtocolError) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{3} +} + +func (x *ProtocolError) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *ProtocolError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ProtocolError) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +func (x *ProtocolError) GetDetail() *EncodedValue { + if x != nil { + return x.Detail + } + return nil +} + +type TokenUsage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + InputTokens uint64 `protobuf:"varint,1,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + OutputTokens uint64 `protobuf:"varint,2,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + TotalTokens uint64 `protobuf:"varint,3,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + Model string `protobuf:"bytes,4,opt,name=model,proto3" json:"model,omitempty"` + Detail map[string]uint64 `protobuf:"bytes,5,rep,name=detail,proto3" json:"detail,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` +} + +func (x *TokenUsage) Reset() { + *x = TokenUsage{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TokenUsage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TokenUsage) ProtoMessage() {} + +func (x *TokenUsage) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TokenUsage.ProtoReflect.Descriptor instead. +func (*TokenUsage) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{4} +} + +func (x *TokenUsage) GetInputTokens() uint64 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *TokenUsage) GetOutputTokens() uint64 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *TokenUsage) GetTotalTokens() uint64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *TokenUsage) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *TokenUsage) GetDetail() map[string]uint64 { + if x != nil { + return x.Detail + } + return nil +} + +type TurnEnded struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StopReason string `protobuf:"bytes,1,opt,name=stop_reason,json=stopReason,proto3" json:"stop_reason,omitempty"` + Error *ProtocolError `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + Usage *TokenUsage `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` + ContextTokens uint64 `protobuf:"varint,4,opt,name=context_tokens,json=contextTokens,proto3" json:"context_tokens,omitempty"` +} + +func (x *TurnEnded) Reset() { + *x = TurnEnded{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TurnEnded) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TurnEnded) ProtoMessage() {} + +func (x *TurnEnded) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TurnEnded.ProtoReflect.Descriptor instead. +func (*TurnEnded) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{5} +} + +func (x *TurnEnded) GetStopReason() string { + if x != nil { + return x.StopReason + } + return "" +} + +func (x *TurnEnded) GetError() *ProtocolError { + if x != nil { + return x.Error + } + return nil +} + +func (x *TurnEnded) GetUsage() *TokenUsage { + if x != nil { + return x.Usage + } + return nil +} + +func (x *TurnEnded) GetContextTokens() uint64 { + if x != nil { + return x.ContextTokens + } + return 0 +} + +type MessageDelta struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + MessageId string `protobuf:"bytes,1,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + ContentIndex uint32 `protobuf:"varint,2,opt,name=content_index,json=contentIndex,proto3" json:"content_index,omitempty"` + Operation DeltaOperation `protobuf:"varint,3,opt,name=operation,proto3,enum=aop.DeltaOperation" json:"operation,omitempty"` + // Types that are assignable to Value: + // + // *MessageDelta_Text + // *MessageDelta_Reasoning + // *MessageDelta_Refusal + // *MessageDelta_Data + // *MessageDelta_ToolArguments + // *MessageDelta_Content + Value isMessageDelta_Value `protobuf_oneof:"value"` +} + +func (x *MessageDelta) Reset() { + *x = MessageDelta{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MessageDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MessageDelta) ProtoMessage() {} + +func (x *MessageDelta) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MessageDelta.ProtoReflect.Descriptor instead. +func (*MessageDelta) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{6} +} + +func (x *MessageDelta) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +func (x *MessageDelta) GetContentIndex() uint32 { + if x != nil { + return x.ContentIndex + } + return 0 +} + +func (x *MessageDelta) GetOperation() DeltaOperation { + if x != nil { + return x.Operation + } + return DeltaOperation_DELTA_OPERATION_UNSPECIFIED +} + +func (m *MessageDelta) GetValue() isMessageDelta_Value { + if m != nil { + return m.Value + } + return nil +} + +func (x *MessageDelta) GetText() string { + if x, ok := x.GetValue().(*MessageDelta_Text); ok { + return x.Text + } + return "" +} + +func (x *MessageDelta) GetReasoning() string { + if x, ok := x.GetValue().(*MessageDelta_Reasoning); ok { + return x.Reasoning + } + return "" +} + +func (x *MessageDelta) GetRefusal() string { + if x, ok := x.GetValue().(*MessageDelta_Refusal); ok { + return x.Refusal + } + return "" +} + +func (x *MessageDelta) GetData() []byte { + if x, ok := x.GetValue().(*MessageDelta_Data); ok { + return x.Data + } + return nil +} + +func (x *MessageDelta) GetToolArguments() string { + if x, ok := x.GetValue().(*MessageDelta_ToolArguments); ok { + return x.ToolArguments + } + return "" +} + +func (x *MessageDelta) GetContent() *Content { + if x, ok := x.GetValue().(*MessageDelta_Content); ok { + return x.Content + } + return nil +} + +type isMessageDelta_Value interface { + isMessageDelta_Value() +} + +type MessageDelta_Text struct { + Text string `protobuf:"bytes,4,opt,name=text,proto3,oneof"` +} + +type MessageDelta_Reasoning struct { + Reasoning string `protobuf:"bytes,5,opt,name=reasoning,proto3,oneof"` +} + +type MessageDelta_Refusal struct { + Refusal string `protobuf:"bytes,6,opt,name=refusal,proto3,oneof"` +} + +type MessageDelta_Data struct { + Data []byte `protobuf:"bytes,7,opt,name=data,proto3,oneof"` +} + +type MessageDelta_ToolArguments struct { + ToolArguments string `protobuf:"bytes,8,opt,name=tool_arguments,json=toolArguments,proto3,oneof"` +} + +type MessageDelta_Content struct { + Content *Content `protobuf:"bytes,9,opt,name=content,proto3,oneof"` +} + +func (*MessageDelta_Text) isMessageDelta_Value() {} + +func (*MessageDelta_Reasoning) isMessageDelta_Value() {} + +func (*MessageDelta_Refusal) isMessageDelta_Value() {} + +func (*MessageDelta_Data) isMessageDelta_Value() {} + +func (*MessageDelta_ToolArguments) isMessageDelta_Value() {} + +func (*MessageDelta_Content) isMessageDelta_Value() {} + +type ToolCallDelta struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + Index uint32 `protobuf:"varint,2,opt,name=index,proto3" json:"index,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Arguments []byte `protobuf:"bytes,4,opt,name=arguments,proto3" json:"arguments,omitempty"` +} + +func (x *ToolCallDelta) Reset() { + *x = ToolCallDelta{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ToolCallDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolCallDelta) ProtoMessage() {} + +func (x *ToolCallDelta) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ToolCallDelta.ProtoReflect.Descriptor instead. +func (*ToolCallDelta) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{7} +} + +func (x *ToolCallDelta) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ToolCallDelta) GetIndex() uint32 { + if x != nil { + return x.Index + } + return 0 +} + +func (x *ToolCallDelta) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolCallDelta) GetArguments() []byte { + if x != nil { + return x.Arguments + } + return nil +} + +type Status struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + State string `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` + Detail *EncodedValue `protobuf:"bytes,2,opt,name=detail,proto3" json:"detail,omitempty"` +} + +func (x *Status) Reset() { + *x = Status{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Status) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Status) ProtoMessage() {} + +func (x *Status) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Status.ProtoReflect.Descriptor instead. +func (*Status) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{8} +} + +func (x *Status) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Status) GetDetail() *EncodedValue { + if x != nil { + return x.Detail + } + return nil +} + +type ExtensionEvent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Value *EncodedValue `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` +} + +func (x *ExtensionEvent) Reset() { + *x = ExtensionEvent{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExtensionEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExtensionEvent) ProtoMessage() {} + +func (x *ExtensionEvent) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExtensionEvent.ProtoReflect.Descriptor instead. +func (*ExtensionEvent) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{9} +} + +func (x *ExtensionEvent) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ExtensionEvent) GetValue() *EncodedValue { + if x != nil { + return x.Value + } + return nil +} + +type ProviderMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` +} + +func (x *ProviderMetadata) Reset() { + *x = ProviderMetadata{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProviderMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderMetadata) ProtoMessage() {} + +func (x *ProviderMetadata) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderMetadata.ProtoReflect.Descriptor instead. +func (*ProviderMetadata) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{10} +} + +func (x *ProviderMetadata) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProviderMetadata) GetValue() []byte { + if x != nil { + return x.Value + } + return nil +} + +// ProviderFrame preserves one exact provider body or stream frame. +type ProviderFrame struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + Protocol string `protobuf:"bytes,2,opt,name=protocol,proto3" json:"protocol,omitempty"` + EventType string `protobuf:"bytes,3,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` + Direction Direction `protobuf:"varint,4,opt,name=direction,proto3,enum=aop.Direction" json:"direction,omitempty"` + Transport string `protobuf:"bytes,5,opt,name=transport,proto3" json:"transport,omitempty"` + Payload []byte `protobuf:"bytes,6,opt,name=payload,proto3" json:"payload,omitempty"` + MediaType string `protobuf:"bytes,7,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` + Metadata []*ProviderMetadata `protobuf:"bytes,8,rep,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *ProviderFrame) Reset() { + *x = ProviderFrame{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProviderFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderFrame) ProtoMessage() {} + +func (x *ProviderFrame) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderFrame.ProtoReflect.Descriptor instead. +func (*ProviderFrame) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{11} +} + +func (x *ProviderFrame) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ProviderFrame) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *ProviderFrame) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *ProviderFrame) GetDirection() Direction { + if x != nil { + return x.Direction + } + return Direction_DIRECTION_UNSPECIFIED +} + +func (x *ProviderFrame) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + +func (x *ProviderFrame) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ProviderFrame) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +func (x *ProviderFrame) GetMetadata() []*ProviderMetadata { + if x != nil { + return x.Metadata + } + return nil +} + +type Event struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + EmittedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=emitted_at,json=emittedAt,proto3" json:"emitted_at,omitempty"` + SessionId string `protobuf:"bytes,3,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + TurnId string `protobuf:"bytes,4,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + Emitter string `protobuf:"bytes,5,opt,name=emitter,proto3" json:"emitter,omitempty"` + Seq uint64 `protobuf:"varint,6,opt,name=seq,proto3" json:"seq,omitempty"` + Extensions []*Extension `protobuf:"bytes,7,rep,name=extensions,proto3" json:"extensions,omitempty"` + // Types that are assignable to Payload: + // + // *Event_SessionStarted + // *Event_SessionEnded + // *Event_TurnStarted + // *Event_TurnEnded + // *Event_Message + // *Event_MessageDelta + // *Event_ToolCall + // *Event_ToolCallDelta + // *Event_ToolResult + // *Event_Usage + // *Event_Error + // *Event_Status + // *Event_Extension + // *Event_ProviderFrame + Payload isEvent_Payload `protobuf_oneof:"payload"` +} + +func (x *Event) Reset() { + *x = Event{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_event_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Event) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Event) ProtoMessage() {} + +func (x *Event) ProtoReflect() protoreflect.Message { + mi := &file_aop_event_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_aop_event_proto_rawDescGZIP(), []int{12} +} + +func (x *Event) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Event) GetEmittedAt() *timestamppb.Timestamp { + if x != nil { + return x.EmittedAt + } + return nil +} + +func (x *Event) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Event) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *Event) GetEmitter() string { + if x != nil { + return x.Emitter + } + return "" +} + +func (x *Event) GetSeq() uint64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *Event) GetExtensions() []*Extension { + if x != nil { + return x.Extensions + } + return nil +} + +func (m *Event) GetPayload() isEvent_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *Event) GetSessionStarted() *SessionStarted { + if x, ok := x.GetPayload().(*Event_SessionStarted); ok { + return x.SessionStarted + } + return nil +} + +func (x *Event) GetSessionEnded() *SessionEnded { + if x, ok := x.GetPayload().(*Event_SessionEnded); ok { + return x.SessionEnded + } + return nil +} + +func (x *Event) GetTurnStarted() *TurnStarted { + if x, ok := x.GetPayload().(*Event_TurnStarted); ok { + return x.TurnStarted + } + return nil +} + +func (x *Event) GetTurnEnded() *TurnEnded { + if x, ok := x.GetPayload().(*Event_TurnEnded); ok { + return x.TurnEnded + } + return nil +} + +func (x *Event) GetMessage() *Message { + if x, ok := x.GetPayload().(*Event_Message); ok { + return x.Message + } + return nil +} + +func (x *Event) GetMessageDelta() *MessageDelta { + if x, ok := x.GetPayload().(*Event_MessageDelta); ok { + return x.MessageDelta + } + return nil +} + +func (x *Event) GetToolCall() *ToolCall { + if x, ok := x.GetPayload().(*Event_ToolCall); ok { + return x.ToolCall + } + return nil +} + +func (x *Event) GetToolCallDelta() *ToolCallDelta { + if x, ok := x.GetPayload().(*Event_ToolCallDelta); ok { + return x.ToolCallDelta + } + return nil +} + +func (x *Event) GetToolResult() *ToolResult { + if x, ok := x.GetPayload().(*Event_ToolResult); ok { + return x.ToolResult + } + return nil +} + +func (x *Event) GetUsage() *TokenUsage { + if x, ok := x.GetPayload().(*Event_Usage); ok { + return x.Usage + } + return nil +} + +func (x *Event) GetError() *ProtocolError { + if x, ok := x.GetPayload().(*Event_Error); ok { + return x.Error + } + return nil +} + +func (x *Event) GetStatus() *Status { + if x, ok := x.GetPayload().(*Event_Status); ok { + return x.Status + } + return nil +} + +func (x *Event) GetExtension() *ExtensionEvent { + if x, ok := x.GetPayload().(*Event_Extension); ok { + return x.Extension + } + return nil +} + +func (x *Event) GetProviderFrame() *ProviderFrame { + if x, ok := x.GetPayload().(*Event_ProviderFrame); ok { + return x.ProviderFrame + } + return nil +} + +type isEvent_Payload interface { + isEvent_Payload() +} + +type Event_SessionStarted struct { + SessionStarted *SessionStarted `protobuf:"bytes,10,opt,name=session_started,json=sessionStarted,proto3,oneof"` +} + +type Event_SessionEnded struct { + SessionEnded *SessionEnded `protobuf:"bytes,11,opt,name=session_ended,json=sessionEnded,proto3,oneof"` +} + +type Event_TurnStarted struct { + TurnStarted *TurnStarted `protobuf:"bytes,12,opt,name=turn_started,json=turnStarted,proto3,oneof"` +} + +type Event_TurnEnded struct { + TurnEnded *TurnEnded `protobuf:"bytes,13,opt,name=turn_ended,json=turnEnded,proto3,oneof"` +} + +type Event_Message struct { + Message *Message `protobuf:"bytes,14,opt,name=message,proto3,oneof"` +} + +type Event_MessageDelta struct { + MessageDelta *MessageDelta `protobuf:"bytes,15,opt,name=message_delta,json=messageDelta,proto3,oneof"` +} + +type Event_ToolCall struct { + ToolCall *ToolCall `protobuf:"bytes,16,opt,name=tool_call,json=toolCall,proto3,oneof"` +} + +type Event_ToolCallDelta struct { + ToolCallDelta *ToolCallDelta `protobuf:"bytes,17,opt,name=tool_call_delta,json=toolCallDelta,proto3,oneof"` +} + +type Event_ToolResult struct { + ToolResult *ToolResult `protobuf:"bytes,18,opt,name=tool_result,json=toolResult,proto3,oneof"` +} + +type Event_Usage struct { + Usage *TokenUsage `protobuf:"bytes,19,opt,name=usage,proto3,oneof"` +} + +type Event_Error struct { + Error *ProtocolError `protobuf:"bytes,20,opt,name=error,proto3,oneof"` +} + +type Event_Status struct { + Status *Status `protobuf:"bytes,21,opt,name=status,proto3,oneof"` +} + +type Event_Extension struct { + Extension *ExtensionEvent `protobuf:"bytes,22,opt,name=extension,proto3,oneof"` +} + +type Event_ProviderFrame struct { + ProviderFrame *ProviderFrame `protobuf:"bytes,23,opt,name=provider_frame,json=providerFrame,proto3,oneof"` +} + +func (*Event_SessionStarted) isEvent_Payload() {} + +func (*Event_SessionEnded) isEvent_Payload() {} + +func (*Event_TurnStarted) isEvent_Payload() {} + +func (*Event_TurnEnded) isEvent_Payload() {} + +func (*Event_Message) isEvent_Payload() {} + +func (*Event_MessageDelta) isEvent_Payload() {} + +func (*Event_ToolCall) isEvent_Payload() {} + +func (*Event_ToolCallDelta) isEvent_Payload() {} + +func (*Event_ToolResult) isEvent_Payload() {} + +func (*Event_Usage) isEvent_Payload() {} + +func (*Event_Error) isEvent_Payload() {} + +func (*Event_Status) isEvent_Payload() {} + +func (*Event_Extension) isEvent_Payload() {} + +func (*Event_ProviderFrame) isEvent_Payload() {} + +var File_aop_event_proto protoreflect.FileDescriptor + +var file_aop_event_proto_rawDesc = []byte{ + 0x0a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x03, 0x61, 0x6f, 0x70, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, + 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x81, 0x01, 0x0a, 0x0e, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x14, + 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, + 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x2a, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, + 0x12, 0x2d, 0x0a, 0x13, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, + 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, + 0x61, 0x72, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x22, + 0x26, 0x0a, 0x0c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, + 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x0d, 0x0a, 0x0b, 0x54, 0x75, 0x72, 0x6e, 0x53, + 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x22, 0x86, 0x01, 0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, + 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, + 0x61, 0x62, 0x6c, 0x65, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, + 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x22, + 0xfd, 0x01, 0x0a, 0x0a, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, + 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x6f, + 0x74, 0x61, 0x6c, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, + 0x33, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1b, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, + 0x2e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x64, 0x65, + 0x74, 0x61, 0x69, 0x6c, 0x1a, 0x39, 0x0a, 0x0b, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0xa4, 0x01, 0x0a, 0x09, 0x54, 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x1f, 0x0a, + 0x0b, 0x73, 0x74, 0x6f, 0x70, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x73, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x28, + 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x25, 0x0a, 0x05, 0x75, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0xc9, 0x02, 0x0a, 0x0c, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, + 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x31, 0x0a, 0x09, 0x6f, + 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, + 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, + 0x74, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, + 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x1a, 0x0a, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, 0x61, 0x6c, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, 0x61, 0x6c, + 0x12, 0x14, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, + 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x27, 0x0a, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, + 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, + 0x28, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, + 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x42, 0x07, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x22, 0x70, 0x0a, 0x0d, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x44, 0x65, + 0x6c, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, + 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x69, 0x6e, 0x64, + 0x65, 0x78, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, + 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, + 0x65, 0x6e, 0x74, 0x73, 0x22, 0x49, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x14, + 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, + 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x22, + 0x4d, 0x0a, 0x0e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, + 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x3c, + 0x0a, 0x10, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x9e, 0x02, 0x0a, + 0x0d, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x44, + 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, + 0x74, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, + 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xc5, 0x07, + 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x6d, 0x69, 0x74, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6d, + 0x69, 0x74, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x65, 0x6d, 0x69, + 0x74, 0x74, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x65, 0x71, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x03, 0x73, 0x65, 0x71, 0x12, 0x2e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3e, 0x0a, 0x0f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, + 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x38, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, + 0x48, 0x00, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, + 0x12, 0x35, 0x0a, 0x0c, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x75, 0x72, + 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0b, 0x74, 0x75, 0x72, 0x6e, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x0a, 0x74, 0x75, 0x72, 0x6e, 0x5f, + 0x65, 0x6e, 0x64, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, + 0x70, 0x2e, 0x54, 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x48, 0x00, 0x52, 0x09, 0x74, + 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x38, 0x0a, 0x0d, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x64, 0x65, + 0x6c, 0x74, 0x61, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x0c, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x2c, 0x0a, 0x09, + 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x48, 0x00, + 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x3c, 0x0a, 0x0f, 0x74, 0x6f, + 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x11, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, + 0x6c, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x43, + 0x61, 0x6c, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x0b, 0x74, 0x6f, 0x6f, 0x6c, + 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, + 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x27, 0x0a, 0x05, + 0x75, 0x73, 0x61, 0x67, 0x65, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, + 0x70, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, + 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2a, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x14, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x15, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, + 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x6f, + 0x70, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x48, 0x00, 0x52, 0x09, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3b, 0x0a, + 0x0e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, + 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x48, 0x00, 0x52, 0x0d, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, + 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2a, 0x9e, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x4f, + 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x1b, 0x44, 0x45, 0x4c, 0x54, + 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, + 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x44, 0x45, 0x4c, + 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, + 0x52, 0x54, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, + 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x50, 0x50, 0x45, 0x4e, 0x44, 0x10, 0x02, + 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, + 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x10, 0x03, 0x12, 0x17, 0x0a, + 0x13, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, + 0x5f, 0x45, 0x4e, 0x44, 0x10, 0x04, 0x2a, 0x55, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x15, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x15, + 0x0a, 0x11, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, + 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, + 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_event_proto_rawDescOnce sync.Once + file_aop_event_proto_rawDescData = file_aop_event_proto_rawDesc +) + +func file_aop_event_proto_rawDescGZIP() []byte { + file_aop_event_proto_rawDescOnce.Do(func() { + file_aop_event_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_event_proto_rawDescData) + }) + return file_aop_event_proto_rawDescData +} + +var file_aop_event_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_aop_event_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_aop_event_proto_goTypes = []interface{}{ + (DeltaOperation)(0), // 0: aop.DeltaOperation + (Direction)(0), // 1: aop.Direction + (*SessionStarted)(nil), // 2: aop.SessionStarted + (*SessionEnded)(nil), // 3: aop.SessionEnded + (*TurnStarted)(nil), // 4: aop.TurnStarted + (*ProtocolError)(nil), // 5: aop.ProtocolError + (*TokenUsage)(nil), // 6: aop.TokenUsage + (*TurnEnded)(nil), // 7: aop.TurnEnded + (*MessageDelta)(nil), // 8: aop.MessageDelta + (*ToolCallDelta)(nil), // 9: aop.ToolCallDelta + (*Status)(nil), // 10: aop.Status + (*ExtensionEvent)(nil), // 11: aop.ExtensionEvent + (*ProviderMetadata)(nil), // 12: aop.ProviderMetadata + (*ProviderFrame)(nil), // 13: aop.ProviderFrame + (*Event)(nil), // 14: aop.Event + nil, // 15: aop.TokenUsage.DetailEntry + (*EncodedValue)(nil), // 16: aop.EncodedValue + (*Content)(nil), // 17: aop.Content + (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp + (*Extension)(nil), // 19: aop.Extension + (*Message)(nil), // 20: aop.Message + (*ToolCall)(nil), // 21: aop.ToolCall + (*ToolResult)(nil), // 22: aop.ToolResult +} +var file_aop_event_proto_depIdxs = []int32{ + 16, // 0: aop.ProtocolError.detail:type_name -> aop.EncodedValue + 15, // 1: aop.TokenUsage.detail:type_name -> aop.TokenUsage.DetailEntry + 5, // 2: aop.TurnEnded.error:type_name -> aop.ProtocolError + 6, // 3: aop.TurnEnded.usage:type_name -> aop.TokenUsage + 0, // 4: aop.MessageDelta.operation:type_name -> aop.DeltaOperation + 17, // 5: aop.MessageDelta.content:type_name -> aop.Content + 16, // 6: aop.Status.detail:type_name -> aop.EncodedValue + 16, // 7: aop.ExtensionEvent.value:type_name -> aop.EncodedValue + 1, // 8: aop.ProviderFrame.direction:type_name -> aop.Direction + 12, // 9: aop.ProviderFrame.metadata:type_name -> aop.ProviderMetadata + 18, // 10: aop.Event.emitted_at:type_name -> google.protobuf.Timestamp + 19, // 11: aop.Event.extensions:type_name -> aop.Extension + 2, // 12: aop.Event.session_started:type_name -> aop.SessionStarted + 3, // 13: aop.Event.session_ended:type_name -> aop.SessionEnded + 4, // 14: aop.Event.turn_started:type_name -> aop.TurnStarted + 7, // 15: aop.Event.turn_ended:type_name -> aop.TurnEnded + 20, // 16: aop.Event.message:type_name -> aop.Message + 8, // 17: aop.Event.message_delta:type_name -> aop.MessageDelta + 21, // 18: aop.Event.tool_call:type_name -> aop.ToolCall + 9, // 19: aop.Event.tool_call_delta:type_name -> aop.ToolCallDelta + 22, // 20: aop.Event.tool_result:type_name -> aop.ToolResult + 6, // 21: aop.Event.usage:type_name -> aop.TokenUsage + 5, // 22: aop.Event.error:type_name -> aop.ProtocolError + 10, // 23: aop.Event.status:type_name -> aop.Status + 11, // 24: aop.Event.extension:type_name -> aop.ExtensionEvent + 13, // 25: aop.Event.provider_frame:type_name -> aop.ProviderFrame + 26, // [26:26] is the sub-list for method output_type + 26, // [26:26] is the sub-list for method input_type + 26, // [26:26] is the sub-list for extension type_name + 26, // [26:26] is the sub-list for extension extendee + 0, // [0:26] is the sub-list for field type_name +} + +func init() { file_aop_event_proto_init() } +func file_aop_event_proto_init() { + if File_aop_event_proto != nil { + return + } + file_aop_content_proto_init() + file_aop_value_proto_init() + if !protoimpl.UnsafeEnabled { + file_aop_event_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SessionStarted); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SessionEnded); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TurnStarted); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolError); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TokenUsage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TurnEnded); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MessageDelta); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ToolCallDelta); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Status); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ExtensionEvent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProviderMetadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProviderFrame); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_event_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Event); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_event_proto_msgTypes[6].OneofWrappers = []interface{}{ + (*MessageDelta_Text)(nil), + (*MessageDelta_Reasoning)(nil), + (*MessageDelta_Refusal)(nil), + (*MessageDelta_Data)(nil), + (*MessageDelta_ToolArguments)(nil), + (*MessageDelta_Content)(nil), + } + file_aop_event_proto_msgTypes[12].OneofWrappers = []interface{}{ + (*Event_SessionStarted)(nil), + (*Event_SessionEnded)(nil), + (*Event_TurnStarted)(nil), + (*Event_TurnEnded)(nil), + (*Event_Message)(nil), + (*Event_MessageDelta)(nil), + (*Event_ToolCall)(nil), + (*Event_ToolCallDelta)(nil), + (*Event_ToolResult)(nil), + (*Event_Usage)(nil), + (*Event_Error)(nil), + (*Event_Status)(nil), + (*Event_Extension)(nil), + (*Event_ProviderFrame)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_event_proto_rawDesc, + NumEnums: 2, + NumMessages: 14, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_event_proto_goTypes, + DependencyIndexes: file_aop_event_proto_depIdxs, + EnumInfos: file_aop_event_proto_enumTypes, + MessageInfos: file_aop_event_proto_msgTypes, + }.Build() + File_aop_event_proto = out.File + file_aop_event_proto_rawDesc = nil + file_aop_event_proto_goTypes = nil + file_aop_event_proto_depIdxs = nil +} diff --git a/aop/helpers.go b/aop/helpers.go new file mode 100644 index 00000000..02684246 --- /dev/null +++ b/aop/helpers.go @@ -0,0 +1,167 @@ +package aop + +import ( + "encoding/json" + "fmt" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +const JSONMediaType = "application/json" +const ProtoJSONMediaType = "application/protobuf+json" + +func JSONValue(value any) (*EncodedValue, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + return &EncodedValue{Data: data, MediaType: JSONMediaType}, nil +} + +func DecodeJSON[T any](value *EncodedValue) (T, error) { + var decoded T + if value == nil { + return decoded, fmt.Errorf("encoded value is required") + } + if err := json.Unmarshal(value.Data, &decoded); err != nil { + return decoded, err + } + return decoded, nil +} + +func ProtoJSONValue(value proto.Message) (*EncodedValue, error) { + if value == nil { + return nil, fmt.Errorf("protobuf value is required") + } + data, err := protojson.Marshal(value) + if err != nil { + return nil, err + } + return &EncodedValue{Data: data, MediaType: ProtoJSONMediaType}, nil +} + +func DecodeProtoJSON(value *EncodedValue, target proto.Message) error { + if value == nil || target == nil { + return fmt.Errorf("encoded value and target are required") + } + return protojson.Unmarshal(value.Data, target) +} + +func SetProtoExtension(event *Event, namespace string, value proto.Message) error { + if event == nil { + return fmt.Errorf("event is required") + } + encoded, err := ProtoJSONValue(value) + if err != nil { + return err + } + for _, extension := range event.Extensions { + if extension.Namespace == namespace { + extension.Value = encoded + return nil + } + } + event.Extensions = append(event.Extensions, &Extension{Namespace: namespace, Value: encoded}) + return nil +} + +func ProtoExtension(event *Event, namespace string, target proto.Message) (bool, error) { + if event == nil { + return false, nil + } + for _, extension := range event.Extensions { + if extension.Namespace == namespace { + return true, DecodeProtoJSON(extension.Value, target) + } + } + return false, nil +} + +func SetJSONExtension(event *Event, namespace string, value any) error { + if event == nil { + return fmt.Errorf("event is required") + } + encoded, err := JSONValue(value) + if err != nil { + return err + } + for _, extension := range event.Extensions { + if extension.Namespace == namespace { + extension.Value = encoded + return nil + } + } + event.Extensions = append(event.Extensions, &Extension{Namespace: namespace, Value: encoded}) + return nil +} + +func GetJSONExtension[T any](event *Event, namespace string) (T, bool, error) { + var zero T + if event == nil { + return zero, false, nil + } + for _, extension := range event.Extensions { + if extension.Namespace == namespace { + value, err := DecodeJSON[T](extension.Value) + return value, true, err + } + } + return zero, false, nil +} + +func Text(text string) *Content { + return &Content{Value: &Content_Text{Text: &TextContent{Text: text}}} +} + +func Reasoning(text string) *Content { + return &Content{Value: &Content_Reasoning{Reasoning: &ReasoningContent{Text: text}}} +} + +func Image(mediaType string, data []byte) *Content { + return &Content{Value: &Content_Media{Media: &MediaContent{ + Kind: "image", + Resource: &Resource{ + Source: &Resource_Data{Data: data}, + MediaType: mediaType, + }, + }}} +} + +func Kind(event *Event) string { + if event == nil { + return "" + } + switch event.Payload.(type) { + case *Event_SessionStarted: + return "session.started" + case *Event_SessionEnded: + return "session.ended" + case *Event_TurnStarted: + return "turn.started" + case *Event_TurnEnded: + return "turn.ended" + case *Event_Message: + return "message" + case *Event_MessageDelta: + return "message.delta" + case *Event_ToolCall: + return "tool.call" + case *Event_ToolCallDelta: + return "tool.call.delta" + case *Event_ToolResult: + return "tool.result" + case *Event_Usage: + return "usage" + case *Event_Error: + return "error" + case *Event_Status: + return "status" + case *Event_Extension: + return event.GetExtension().GetType() + case *Event_ProviderFrame: + return "provider.frame" + default: + return "" + } +} diff --git a/aop/helpers_test.go b/aop/helpers_test.go new file mode 100644 index 00000000..51d15004 --- /dev/null +++ b/aop/helpers_test.go @@ -0,0 +1,45 @@ +package aop + +import ( + "bytes" + "testing" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +func TestProviderFrameJSONAndBinaryRoundTrip(t *testing.T) { + original := &Event{Payload: &Event_ProviderFrame{ProviderFrame: &ProviderFrame{ + Provider: "openai", Protocol: "responses", EventType: "response.output_text.delta", + Direction: Direction_DIRECTION_RESPONSE, Transport: "sse", + Payload: []byte("event: response.output_text.delta\ndata: {\"delta\":\"hi\"}\n\n"), + MediaType: "text/event-stream", + }}} + + jsonData, err := protojson.Marshal(original) + if err != nil { + t.Fatal(err) + } + fromJSON := new(Event) + if err := protojson.Unmarshal(jsonData, fromJSON); err != nil { + t.Fatal(err) + } + if !proto.Equal(original, fromJSON) { + t.Fatalf("protojson round trip changed event") + } + + binary, err := proto.Marshal(original) + if err != nil { + t.Fatal(err) + } + fromBinary := new(Event) + if err := proto.Unmarshal(binary, fromBinary); err != nil { + t.Fatal(err) + } + if !proto.Equal(fromJSON, fromBinary) { + t.Fatalf("JSON and binary decoded messages differ") + } + if !bytes.Equal(original.GetProviderFrame().Payload, fromBinary.GetProviderFrame().Payload) { + t.Fatalf("provider bytes changed") + } +} diff --git a/aop/interop_fixture_test.go b/aop/interop_fixture_test.go new file mode 100644 index 00000000..b8794d7a --- /dev/null +++ b/aop/interop_fixture_test.go @@ -0,0 +1,60 @@ +package aop + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +type interopFixture struct { + Event json.RawMessage `json:"event"` + BinaryBase64 string `json:"binaryBase64"` + ProviderPayloads struct { + OpenAIBase64 string `json:"openaiBase64"` + AnthropicBase64 string `json:"anthropicBase64"` + } `json:"providerPayloads"` +} + +func TestInteropFixtureMatchesProtoBinaryAndProtoJSON(t *testing.T) { + path := filepath.Join("..", "web", "frontend", "cyber-ui", "packages", "aop", "fixtures", "interop.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var fixture interopFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + event := new(Event) + if err := protojson.Unmarshal(fixture.Event, event); err != nil { + t.Fatal(err) + } + binary, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) + if err != nil { + t.Fatal(err) + } + got := base64.StdEncoding.EncodeToString(binary) + if got != fixture.BinaryBase64 { + t.Fatalf("binaryBase64 = %q", got) + } + openAI, err := base64.StdEncoding.DecodeString(fixture.ProviderPayloads.OpenAIBase64) + if err != nil || string(openAI) != string(event.GetProviderFrame().Payload) { + t.Fatalf("OpenAI payload mismatch: %q, %v", openAI, err) + } + if _, err := base64.StdEncoding.DecodeString(fixture.ProviderPayloads.AnthropicBase64); err != nil { + t.Fatalf("Anthropic payload: %v", err) + } + jsonRoundTrip, err := protojson.Marshal(event) + if err != nil { + t.Fatal(err) + } + fromJSON := new(Event) + if err := protojson.Unmarshal(jsonRoundTrip, fromJSON); err != nil || !proto.Equal(event, fromJSON) { + t.Fatalf("protobuf JSON round trip failed: %v", err) + } +} diff --git a/aop/value.pb.go b/aop/value.pb.go new file mode 100644 index 00000000..6ee51840 --- /dev/null +++ b/aop/value.pb.go @@ -0,0 +1,227 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/value.proto + +package aop + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// EncodedValue preserves structured or binary data without JSON coercion. +type EncodedValue struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *EncodedValue) Reset() { + *x = EncodedValue{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_value_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EncodedValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EncodedValue) ProtoMessage() {} + +func (x *EncodedValue) ProtoReflect() protoreflect.Message { + mi := &file_aop_value_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EncodedValue.ProtoReflect.Descriptor instead. +func (*EncodedValue) Descriptor() ([]byte, []int) { + return file_aop_value_proto_rawDescGZIP(), []int{0} +} + +func (x *EncodedValue) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *EncodedValue) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +// Extension carries namespaced semantics outside the stable AOP core. +type Extension struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Value *EncodedValue `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` +} + +func (x *Extension) Reset() { + *x = Extension{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_value_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Extension) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Extension) ProtoMessage() {} + +func (x *Extension) ProtoReflect() protoreflect.Message { + mi := &file_aop_value_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Extension.ProtoReflect.Descriptor instead. +func (*Extension) Descriptor() ([]byte, []int) { + return file_aop_value_proto_rawDescGZIP(), []int{1} +} + +func (x *Extension) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *Extension) GetValue() *EncodedValue { + if x != nil { + return x.Value + } + return nil +} + +var File_aop_value_proto protoreflect.FileDescriptor + +var file_aop_value_proto_rawDesc = []byte{ + 0x0a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x03, 0x61, 0x6f, 0x70, 0x22, 0x41, 0x0a, 0x0c, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, + 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, + 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x22, 0x52, 0x0a, 0x09, 0x45, 0x78, 0x74, + 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, + 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_value_proto_rawDescOnce sync.Once + file_aop_value_proto_rawDescData = file_aop_value_proto_rawDesc +) + +func file_aop_value_proto_rawDescGZIP() []byte { + file_aop_value_proto_rawDescOnce.Do(func() { + file_aop_value_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_value_proto_rawDescData) + }) + return file_aop_value_proto_rawDescData +} + +var file_aop_value_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_aop_value_proto_goTypes = []interface{}{ + (*EncodedValue)(nil), // 0: aop.EncodedValue + (*Extension)(nil), // 1: aop.Extension +} +var file_aop_value_proto_depIdxs = []int32{ + 0, // 0: aop.Extension.value:type_name -> aop.EncodedValue + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_aop_value_proto_init() } +func file_aop_value_proto_init() { + if File_aop_value_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aop_value_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EncodedValue); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_value_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Extension); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_value_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_value_proto_goTypes, + DependencyIndexes: file_aop_value_proto_depIdxs, + MessageInfos: file_aop_value_proto_msgTypes, + }.Build() + File_aop_value_proto = out.File + file_aop_value_proto_rawDesc = nil + file_aop_value_proto_goTypes = nil + file_aop_value_proto_depIdxs = nil +} diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go index acecada8..a1b57f29 100644 --- a/cmd/aiscan/cli_test.go +++ b/cmd/aiscan/cli_test.go @@ -218,6 +218,7 @@ func TestAgentHelpRendersAgentOptionsWithoutRootCatalog(t *testing.T) { var buf bytes.Buffer writeHelp(parser, &buf) help := buf.String() + searchableHelp := help + "\n" + strings.Join(strings.Fields(help), " ") for _, wants := range [][]string{ {"agent [OPTIONS]"}, {"Agent Options:"}, @@ -228,7 +229,7 @@ func TestAgentHelpRendersAgentOptionsWithoutRootCatalog(t *testing.T) { {"--transport", "/transport"}, {"--server-url", "/server-url"}, } { - if !containsAny(help, wants...) { + if !containsAny(searchableHelp, wants...) { want := strings.Join(wants, " or ") t.Fatalf("agent help missing %q:\n%s", want, help) } diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index b109508e..c87dd44c 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/capability" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" @@ -161,7 +161,7 @@ func scannerWithAgent(ctx context.Context, option *cfg.Option, application *runn if err != nil { return err } - run, err := session.Run(ctx, runner.RunInput{Parts: []aop.MessagePart{{Type: aop.PartText, Text: prompt}}}) + run, err := session.Run(ctx, runner.RunInput{Content: []*aop.Content{aop.Text(prompt)}}) if err != nil { return err } diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 14b800a4..9e7421ee 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -20,10 +20,11 @@ import ( "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/web" - "github.com/chainreactors/aiscan/pkg/webproto" webstatic "github.com/chainreactors/aiscan/web" "github.com/chainreactors/ioa/protocols" ioaserver "github.com/chainreactors/ioa/server" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" "gopkg.in/yaml.v3" ) @@ -118,7 +119,19 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom localAgents.StopAll() }() - handler := web.NewHandler(service, pool, localAgents, ioaHandler, newSPAFileServer(staticSub), accessKey, ioaSvc) + httpHandler := web.NewHandler(service, pool, localAgents, ioaHandler, newSPAFileServer(staticSub), accessKey, ioaSvc) + grpcServer := web.NewGRPCServer(accessKey, service, pool) + handler := h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/aop.ChatService/") || strings.HasPrefix(r.URL.Path, "/aiscan.chat.SessionService/") { + httpHandler.ServeHTTP(w, r) + return + } + if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") { + grpcServer.ServeHTTP(w, r) + return + } + httpHandler.ServeHTTP(w, r) + }), &http2.Server{}) srv := &http.Server{ Addr: opts.Addr, @@ -127,6 +140,7 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom go func() { <-ctx.Done() + grpcServer.Stop() shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutCancel() _ = srv.Shutdown(shutCtx) @@ -226,17 +240,17 @@ type webConfigStore struct { mu sync.Mutex } -func (s *webConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, webproto.DistributeConfig, error) { +func (s *webConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, cfg.DistributeConfig, error) { if err := ctx.Err(); err != nil { - return "", false, webproto.DistributeConfig{}, err + return "", false, cfg.DistributeConfig{}, err } p, loaded := s.resolveConfigPath() if !loaded { - return p, false, webproto.DistributeConfig{}, nil + return p, false, cfg.DistributeConfig{}, nil } data, err := os.ReadFile(p) if err != nil { - return p, false, webproto.DistributeConfig{}, err + return p, false, cfg.DistributeConfig{}, err } dc := parseDistributeConfig(data) return p, true, dc, nil @@ -245,18 +259,18 @@ func (s *webConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, // parseDistributeConfig decodes the YAML settings file and migrates a legacy // flat llm section into the provider profile list — the only place the flat // representation is still accepted. -func parseDistributeConfig(data []byte) webproto.DistributeConfig { - var dc webproto.DistributeConfig +func parseDistributeConfig(data []byte) cfg.DistributeConfig { + var dc cfg.DistributeConfig _ = yaml.Unmarshal(data, &dc) var legacy struct { - LLM webproto.LLMProviderConfig `yaml:"llm"` + LLM cfg.LLMProviderConfig `yaml:"llm"` } _ = yaml.Unmarshal(data, &legacy) - webproto.MigrateLLMConfig(&dc.LLM, legacy.LLM) + cfg.MigrateLLMConfig(&dc.LLM, legacy.LLM) return dc } -func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming webproto.DistributeConfig) (*web.PreparedConfig, error) { +func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming cfg.DistributeConfig) (*web.PreparedConfig, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -264,7 +278,7 @@ func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming w defer s.mu.Unlock() p, loaded := s.resolveConfigPath() - var current webproto.DistributeConfig + var current cfg.DistributeConfig if loaded { data, err := os.ReadFile(p) if err != nil { @@ -272,7 +286,7 @@ func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming w } current = parseDistributeConfig(data) } - webproto.MigrateLLMConfig(&incoming.LLM, webproto.LLMProviderConfig{}) + cfg.MigrateLLMConfig(&incoming.LLM, cfg.LLMProviderConfig{}) // Preserve existing secrets when incoming value is empty. preserveLLMProfileSecrets(&incoming.LLM, current.LLM) @@ -360,8 +374,8 @@ func preserveSecret(incoming *string, existing string) { } } -func preserveLLMProfileSecrets(incoming *webproto.LLMConfig, existing webproto.LLMConfig) { - byID := make(map[string]webproto.LLMProviderConfig, len(existing.Providers)) +func preserveLLMProfileSecrets(incoming *cfg.LLMConfig, existing cfg.LLMConfig) { + byID := make(map[string]cfg.LLMProviderConfig, len(existing.Providers)) for _, profile := range existing.Providers { if profile.ID != "" { byID[profile.ID] = profile diff --git a/cmd/aiscan/web_full_test.go b/cmd/aiscan/web_full_test.go index 9e114ea0..fe5b7ea7 100644 --- a/cmd/aiscan/web_full_test.go +++ b/cmd/aiscan/web_full_test.go @@ -10,10 +10,10 @@ import ( "runtime" "testing" + cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/web" - "github.com/chainreactors/aiscan/pkg/webproto" "gopkg.in/yaml.v3" ) @@ -93,11 +93,11 @@ func TestWireWebAppBindsSCONodesForReloadedApp(t *testing.T) { } } -func configForWebStore(model, apiKey string) webproto.DistributeConfig { - var cfg webproto.DistributeConfig - cfg.LLM.ActiveProfile = "primary" - cfg.LLM.Providers = []webproto.LLMProviderConfig{{ +func configForWebStore(model, apiKey string) cfg.DistributeConfig { + var value cfg.DistributeConfig + value.LLM.ActiveProfile = "primary" + value.LLM.Providers = []cfg.LLMProviderConfig{{ ID: "primary", Provider: "openai", Model: model, APIKey: apiKey, }} - return cfg + return value } diff --git a/core/aop/decode.go b/core/aop/decode.go deleted file mode 100644 index 8eca8a83..00000000 --- a/core/aop/decode.go +++ /dev/null @@ -1,16 +0,0 @@ -package aop - -import ( - "encoding/json" - "fmt" -) - -// DecodeData decodes an event payload without changing or replacing the -// original envelope. -func DecodeData[T any](event Event) (T, error) { - var data T - if err := json.Unmarshal(event.Data, &data); err != nil { - return data, fmt.Errorf("decode AOP %s data: %w", event.Type, err) - } - return data, nil -} diff --git a/core/aop/event.go b/core/aop/event.go deleted file mode 100644 index 80f96caf..00000000 --- a/core/aop/event.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package aop implements Agent Output Protocol — a language-neutral JSONL -// event protocol for AI coding agents. -package aop - -import "encoding/json" - -// Event is the stable hand-written AOP envelope. Data and extension namespaces -// stay raw until a consumer explicitly decodes them, so bridges can forward -// unknown protocol additions without rewriting them. -type Event struct { - Type string `json:"type"` - TS string `json:"ts"` - SessionID string `json:"session_id"` - TurnID string `json:"turn_id,omitempty"` - Agent string `json:"agent"` - Seq int `json:"seq,omitempty"` - Data json.RawMessage `json:"data"` - Ext map[string]json.RawMessage `json:"ext,omitempty"` -} - -func (e Event) Valid() bool { - return e.Type != "" && e.TS != "" && e.SessionID != "" && e.Agent != "" && len(e.Data) > 0 -} - -const ( - TypeSessionStart = "session.start" - TypeSessionEnd = "session.end" - TypeMessage = "message" - TypeMessageDelta = "message.delta" - TypeToolCall = "tool.call" - TypeToolResult = "tool.result" - TypeUsage = "usage" - TypeTurnStart = "turn.start" - TypeTurnEnd = "turn.end" - TypeError = "error" - TypeStatus = "status" -) - -const ( - PartText = "text" - PartReasoning = "reasoning" - PartImage = "image" -) - -const ( - NSAOP = "aop" - - StatusTokenBudgetWarning = "token_budget_warning" - StatusLLMRequest = "llm_request" -) - -// ToolResultContent is the structured Content variant used when a tool result -// contains images alongside its text. -type ToolResultContent struct { - Content string `json:"content"` - Images []ImageSource `json:"images,omitempty"` -} diff --git a/core/aop/ext.go b/core/aop/ext.go deleted file mode 100644 index 02cae0a2..00000000 --- a/core/aop/ext.go +++ /dev/null @@ -1,39 +0,0 @@ -package aop - -import ( - "encoding/json" - "fmt" -) - -// Ext decodes one extension namespace without touching the others. -// -// Ext/SetExt are the codec primitives for the extension map. Business code -// must not call them directly — use the typed namespace packages under -// core/aop/x/ (or pkg/webproto for hub-owned namespaces) instead. -func Ext[T any](event Event, namespace string) (T, bool, error) { - var value T - raw, ok := event.Ext[namespace] - if !ok { - return value, false, nil - } - if err := json.Unmarshal(raw, &value); err != nil { - return value, true, fmt.Errorf("decode AOP ext.%s: %w", namespace, err) - } - return value, true, nil -} - -// SetExt serializes one namespace while preserving all other raw namespaces. -func SetExt[T any](event *Event, namespace string, value T) error { - if event == nil { - return fmt.Errorf("set AOP ext.%s on nil event", namespace) - } - raw, err := json.Marshal(value) - if err != nil { - return fmt.Errorf("encode AOP ext.%s: %w", namespace, err) - } - if event.Ext == nil { - event.Ext = make(map[string]json.RawMessage) - } - event.Ext[namespace] = raw - return nil -} diff --git a/core/aop/ext_types_gen.go b/core/aop/ext_types_gen.go deleted file mode 100644 index c8bde60e..00000000 --- a/core/aop/ext_types_gen.go +++ /dev/null @@ -1,35 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type BudgetWarning struct { - // ContextTokens corresponds to the JSON schema field "context_tokens". - ContextTokens int `json:"context_tokens"` - - // TokenBudget corresponds to the JSON schema field "token_budget". - TokenBudget int `json:"token_budget"` -} - -type LLMRequest struct { - // MaxTokens corresponds to the JSON schema field "max_tokens". - MaxTokens int `json:"max_tokens"` - - // Messages corresponds to the JSON schema field "messages". - Messages int `json:"messages"` - - // Model corresponds to the JSON schema field "model". - Model string `json:"model"` - - // Stream corresponds to the JSON schema field "stream". - Stream bool `json:"stream"` -} - -type MessageMeta struct { - // AgentID corresponds to the JSON schema field "agent_id". - AgentID string `json:"agent_id,omitempty,omitzero"` - - // Metadata corresponds to the JSON schema field "metadata". - Metadata MessageMetaMetadata `json:"metadata,omitempty,omitzero"` -} - -type MessageMetaMetadata map[string]interface{} diff --git a/core/aop/gen_error.go b/core/aop/gen_error.go deleted file mode 100644 index 45119908..00000000 --- a/core/aop/gen_error.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type ErrorData struct { - // Code corresponds to the JSON schema field "code". - Code string `json:"code,omitempty,omitzero"` - - // Message corresponds to the JSON schema field "message". - Message string `json:"message"` - - // Retryable corresponds to the JSON schema field "retryable". - Retryable bool `json:"retryable,omitempty,omitzero"` -} diff --git a/core/aop/gen_message.go b/core/aop/gen_message.go deleted file mode 100644 index da18c98f..00000000 --- a/core/aop/gen_message.go +++ /dev/null @@ -1,36 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type ImageSource struct { - // Base64 corresponds to the JSON schema field "base64". - Base64 string `json:"base64,omitempty,omitzero"` - - // MediaType corresponds to the JSON schema field "media_type". - MediaType string `json:"media_type,omitempty,omitzero"` - - // Path corresponds to the JSON schema field "path". - Path string `json:"path,omitempty,omitzero"` -} - -type MessageData struct { - // MessageID corresponds to the JSON schema field "message_id". - MessageID string `json:"message_id"` - - // Parts corresponds to the JSON schema field "parts". - Parts []MessagePart `json:"parts"` - - // Role corresponds to the JSON schema field "role". - Role string `json:"role"` -} - -type MessagePart struct { - // Image corresponds to the JSON schema field "image". - Image *ImageSource `json:"image,omitempty,omitzero"` - - // Text corresponds to the JSON schema field "text". - Text string `json:"text,omitempty,omitzero"` - - // Type corresponds to the JSON schema field "type". - Type string `json:"type"` -} diff --git a/core/aop/gen_message_delta.go b/core/aop/gen_message_delta.go deleted file mode 100644 index d4acf0ee..00000000 --- a/core/aop/gen_message_delta.go +++ /dev/null @@ -1,17 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type MessageDeltaData struct { - // Delta corresponds to the JSON schema field "delta". - Delta string `json:"delta"` - - // MessageID corresponds to the JSON schema field "message_id". - MessageID string `json:"message_id"` - - // PartIndex corresponds to the JSON schema field "part_index". - PartIndex int `json:"part_index"` - - // PartType corresponds to the JSON schema field "part_type". - PartType string `json:"part_type"` -} diff --git a/core/aop/gen_session_start.go b/core/aop/gen_session_start.go deleted file mode 100644 index c177a173..00000000 --- a/core/aop/gen_session_start.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type SessionStartData struct { - // Model corresponds to the JSON schema field "model". - Model string `json:"model,omitempty,omitzero"` - - // ParentSessionID corresponds to the JSON schema field "parent_session_id". - ParentSessionID string `json:"parent_session_id,omitempty,omitzero"` - - // ParentToolCallID corresponds to the JSON schema field "parent_tool_call_id". - ParentToolCallID string `json:"parent_tool_call_id,omitempty,omitzero"` -} diff --git a/core/aop/gen_status.go b/core/aop/gen_status.go deleted file mode 100644 index f2ddd2e8..00000000 --- a/core/aop/gen_status.go +++ /dev/null @@ -1,8 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type StatusData struct { - // State corresponds to the JSON schema field "state". - State string `json:"state"` -} diff --git a/core/aop/gen_tool_call.go b/core/aop/gen_tool_call.go deleted file mode 100644 index f5f87c64..00000000 --- a/core/aop/gen_tool_call.go +++ /dev/null @@ -1,17 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type ToolCallData struct { - // Args corresponds to the JSON schema field "args". - Args any `json:"args"` - - // ToolCallID corresponds to the JSON schema field "tool_call_id". - ToolCallID string `json:"tool_call_id"` - - // ToolName corresponds to the JSON schema field "tool_name". - ToolName string `json:"tool_name"` - - // WorkDir corresponds to the JSON schema field "work_dir". - WorkDir string `json:"work_dir,omitempty,omitzero"` -} diff --git a/core/aop/gen_tool_result.go b/core/aop/gen_tool_result.go deleted file mode 100644 index 76cbc358..00000000 --- a/core/aop/gen_tool_result.go +++ /dev/null @@ -1,26 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type ToolResultData struct { - // Content corresponds to the JSON schema field "content". - Content any `json:"content"` - - // Details corresponds to the JSON schema field "details". - Details any `json:"details,omitempty,omitzero"` - - // DurationMs corresponds to the JSON schema field "duration_ms". - DurationMs int `json:"duration_ms,omitempty,omitzero"` - - // IsError corresponds to the JSON schema field "is_error". - IsError bool `json:"is_error,omitempty,omitzero"` - - // Terminate corresponds to the JSON schema field "terminate". - Terminate bool `json:"terminate,omitempty,omitzero"` - - // ToolCallID corresponds to the JSON schema field "tool_call_id". - ToolCallID string `json:"tool_call_id"` - - // ToolName corresponds to the JSON schema field "tool_name". - ToolName string `json:"tool_name,omitempty,omitzero"` -} diff --git a/core/aop/gen_turn.go b/core/aop/gen_turn.go deleted file mode 100644 index 7ebfea1d..00000000 --- a/core/aop/gen_turn.go +++ /dev/null @@ -1,5 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type TurnStartData struct{} diff --git a/core/aop/gen_usage_session.go b/core/aop/gen_usage_session.go deleted file mode 100644 index cae93d06..00000000 --- a/core/aop/gen_usage_session.go +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package aop - -type SessionEndData struct { - // Reason corresponds to the JSON schema field "reason". - Reason string `json:"reason"` -} - -type TurnEndData struct { - // Error corresponds to the JSON schema field "error". - Error string `json:"error,omitempty,omitzero"` - - // ContextTokens corresponds to the JSON schema field "context_tokens". - ContextTokens int `json:"context_tokens,omitempty,omitzero"` - - // Stop corresponds to the JSON schema field "stop". - Stop string `json:"stop"` - - // Usage corresponds to the JSON schema field "usage". - Usage *UsageData `json:"usage,omitempty,omitzero"` -} - -type UsageData struct { - // CacheReadTokens corresponds to the JSON schema field "cache_read_tokens". - CacheReadTokens int `json:"cache_read_tokens,omitempty,omitzero"` - - // CacheWriteTokens corresponds to the JSON schema field "cache_write_tokens". - CacheWriteTokens int `json:"cache_write_tokens,omitempty,omitzero"` - - // InputTokens corresponds to the JSON schema field "input_tokens". - InputTokens int `json:"input_tokens"` - - // Model corresponds to the JSON schema field "model". - Model string `json:"model,omitempty,omitzero"` - - // OutputTokens corresponds to the JSON schema field "output_tokens". - OutputTokens int `json:"output_tokens"` - - // TotalTokens corresponds to the JSON schema field "total_tokens". - TotalTokens int `json:"total_tokens"` -} diff --git a/core/aop/generate.go b/core/aop/generate.go deleted file mode 100644 index b2e0f2e9..00000000 --- a/core/aop/generate.go +++ /dev/null @@ -1,12 +0,0 @@ -package aop - -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_message.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/message.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_message_delta.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/message.delta.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_tool_call.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/tool.call.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_tool_result.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/tool.result.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_usage_session.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/usage.schema.json ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/session.end.schema.json ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/turn.end.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_session_start.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/session.start.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_turn.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/turn.start.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_error.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/error.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o gen_status.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/events/status.schema.json -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID --capitalization LLM --capitalization AOP -p aop -o ext_types_gen.go ../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/aop.schema.json diff --git a/core/aop/schema_test.go b/core/aop/schema_test.go deleted file mode 100644 index 5ebe8344..00000000 --- a/core/aop/schema_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package aop - -import ( - "bufio" - "bytes" - "encoding/json" - "os" - "path/filepath" - "testing" - - jsonschema "github.com/santhosh-tekuri/jsonschema/v6" -) - -func TestCanonicalCyberUIFixtures(t *testing.T) { - protocolRoot := filepath.Join("..", "..", "web", "frontend", "cyber-ui", "packages", "agent-protocol") - fixtureRoot := filepath.Join(protocolRoot, "fixtures") - compiler := jsonschema.NewCompiler() - err := filepath.WalkDir(filepath.Join(protocolRoot, "schema"), func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil || entry.IsDir() || filepath.Ext(path) != ".json" { - return walkErr - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - var document map[string]any - if err := json.Unmarshal(data, &document); err != nil { - return err - } - id, _ := document["$id"].(string) - if id != "" { - return compiler.AddResource(id, document) - } - return nil - }) - if err != nil { - t.Fatal(err) - } - schema, err := compiler.Compile("https://github.com/chainreactors/cyber-ui/packages/agent-protocol/schema/aop.schema.json") - if err != nil { - t.Fatal(err) - } - delegationSchema, err := compiler.Compile("https://github.com/chainreactors/cyber-ui/packages/agent-protocol/schema/ext/delegation.schema.json") - if err != nil { - t.Fatal(err) - } - paths, err := filepath.Glob(filepath.Join(fixtureRoot, "*.jsonl")) - if err != nil { - t.Fatal(err) - } - - var ( - seenReasoningDelta = false - seenComplete = false - seenStatusExt = false - ) - for _, path := range paths { - content, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - scanner := bufio.NewScanner(bytes.NewReader(content)) - for scanner.Scan() { - var document any - if err := json.Unmarshal(scanner.Bytes(), &document); err != nil { - t.Fatalf("decode fixture document %s: %v", path, err) - } - if err := schema.Validate(document); err != nil { - t.Fatalf("schema validation failed for %s: %v\n%s", path, err, scanner.Text()) - } - var event Event - if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { - t.Fatalf("decode fixture %s: %v", path, err) - } - if !event.Valid() { - t.Fatalf("invalid fixture envelope in %s: %+v", path, event) - } - if raw, ok := event.Ext["delegation"]; ok { - var detail any - if err := json.Unmarshal(raw, &detail); err != nil { - t.Fatalf("decode delegation fixture %s: %v", path, err) - } - if err := delegationSchema.Validate(detail); err != nil { - t.Fatalf("delegation schema validation failed for %s: %v", path, err) - } - } - switch event.Type { - case TypeMessage: - var data MessageData - if err := json.Unmarshal(event.Data, &data); err != nil { - t.Fatal(err) - } - if data.MessageID == "" || data.Role == "" || len(data.Parts) == 0 { - t.Fatalf("invalid message payload: %+v", data) - } - seenComplete = true - case TypeMessageDelta: - var data MessageDeltaData - if err := json.Unmarshal(event.Data, &data); err != nil { - t.Fatal(err) - } - if data.MessageID == "" || data.PartType == "" { - t.Fatalf("invalid message.delta payload: %+v", data) - } - seenReasoningDelta = seenReasoningDelta || data.PartType == PartReasoning - case TypeStatus: - if len(event.Ext) > 0 { - seenStatusExt = true - } - } - } - if err := scanner.Err(); err != nil { - t.Fatal(err) - } - } - if !seenReasoningDelta { - t.Fatal("canonical fixtures do not cover reasoning deltas") - } - if !seenComplete { - t.Fatal("canonical fixtures do not cover complete messages") - } - if !seenStatusExt { - t.Fatal("canonical fixtures do not cover status ext payloads") - } -} diff --git a/core/aop/tool_result.go b/core/aop/tool_result.go deleted file mode 100644 index 93ad9d0a..00000000 --- a/core/aop/tool_result.go +++ /dev/null @@ -1,60 +0,0 @@ -package aop - -import ( - "fmt" - "time" - - "github.com/chainreactors/aiscan/core/tool" -) - -// ToolResultContentFromResult converts the canonical tool Result blocks to the -// AOP content variant without flattening images or changing the supplied text. -func ToolResultContentFromResult(result tool.Result, text string) any { - if !result.HasImages() { - return text - } - content := ToolResultContent{Content: text} - for _, block := range result.Content { - if block.Type == "image" { - content.Images = append(content.Images, ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}) - } - } - return content -} - -// ToolResultDataFromResult is the single conversion used by Agent-internal and -// direct remote tool execution. -func ToolResultDataFromResult(call ToolCallData, result tool.Result, execErr error, duration time.Duration) ToolResultData { - text := result.Text() - if execErr != nil { - text = execErr.Error() - } - return ToolResultData{ - ToolCallID: call.ToolCallID, - ToolName: call.ToolName, - Content: ToolResultContentFromResult(result, text), - Details: result.Details, - Terminate: result.Terminate, - IsError: execErr != nil || result.IsError, - DurationMs: int(duration.Milliseconds()), - } -} - -// ToolResultText reads both in-memory and JSON-decoded structured content. -func ToolResultText(content any) string { - switch value := content.(type) { - case string: - return value - case ToolResultContent: - return value.Content - case *ToolResultContent: - if value != nil { - return value.Content - } - case map[string]any: - if text, ok := value["content"].(string); ok { - return text - } - } - return fmt.Sprint(content) -} diff --git a/core/aop/tool_result_test.go b/core/aop/tool_result_test.go deleted file mode 100644 index 3f213dd0..00000000 --- a/core/aop/tool_result_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package aop - -import ( - "errors" - "testing" - "time" - - "github.com/chainreactors/aiscan/core/tool" -) - -func TestToolResultDataFromResultPreservesStructuredContent(t *testing.T) { - result := tool.Result{ - Content: []tool.ContentBlock{ - tool.TextBlock("done"), - tool.ImageBlock("image/png", "aGVsbG8="), - }, - Details: map[string]any{"ports": 3}, Terminate: true, - } - data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1", ToolName: "scan"}, result, nil, 12*time.Millisecond) - if data.ToolCallID != "call-1" || data.ToolName != "scan" || data.DurationMs != 12 || !data.Terminate || data.IsError { - t.Fatalf("data = %+v", data) - } - content, ok := data.Content.(ToolResultContent) - if !ok || content.Content != "done" || len(content.Images) != 1 || content.Images[0].MediaType != "image/png" { - t.Fatalf("content = %#v", data.Content) - } -} - -func TestToolResultDataFromResultUsesExecutionError(t *testing.T) { - data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1"}, tool.TextResult("partial"), errors.New("failed"), 0) - if !data.IsError || ToolResultText(data.Content) != "failed" { - t.Fatalf("data = %+v", data) - } -} diff --git a/core/aop/x/command/command.go b/core/aop/x/command/command.go deleted file mode 100644 index ba5e1365..00000000 --- a/core/aop/x/command/command.go +++ /dev/null @@ -1,13 +0,0 @@ -package command - -import "github.com/chainreactors/aiscan/core/aop" - -const NS = "command" - -type Detail struct { - Line string `json:"line"` - Presentation string `json:"presentation,omitempty"` -} - -func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) } -func SetDetail(event *aop.Event, value Detail) error { return aop.SetExt(event, NS, value) } diff --git a/core/aop/x/compact/compact.go b/core/aop/x/compact/compact.go deleted file mode 100644 index 1e77568a..00000000 --- a/core/aop/x/compact/compact.go +++ /dev/null @@ -1,14 +0,0 @@ -package compact - -import "github.com/chainreactors/aiscan/core/aop" - -const ( - NS = "compact" - - StateStart = "compact_start" - StateEnd = "compact_end" - StateError = "compact_error" -) - -func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) } -func SetDetail(event *aop.Event, value Detail) error { return aop.SetExt(event, NS, value) } diff --git a/core/aop/x/compact/generate.go b/core/aop/x/compact/generate.go deleted file mode 100644 index e9d9e06e..00000000 --- a/core/aop/x/compact/generate.go +++ /dev/null @@ -1,3 +0,0 @@ -package compact - -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p compact -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/compact.schema.json diff --git a/core/aop/x/compact/types_gen.go b/core/aop/x/compact/types_gen.go deleted file mode 100644 index 889fed9e..00000000 --- a/core/aop/x/compact/types_gen.go +++ /dev/null @@ -1,17 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package compact - -type Detail struct { - // Error corresponds to the JSON schema field "error". - Error string `json:"error,omitempty,omitzero"` - - // KeptMessages corresponds to the JSON schema field "kept_messages". - KeptMessages int `json:"kept_messages,omitempty,omitzero"` - - // TokensAfter corresponds to the JSON schema field "tokens_after". - TokensAfter int `json:"tokens_after,omitempty,omitzero"` - - // TokensBefore corresponds to the JSON schema field "tokens_before". - TokensBefore int `json:"tokens_before,omitempty,omitzero"` -} diff --git a/core/aop/x/delegation/delegation.go b/core/aop/x/delegation/delegation.go deleted file mode 100644 index 0cd27e1b..00000000 --- a/core/aop/x/delegation/delegation.go +++ /dev/null @@ -1,13 +0,0 @@ -package delegation - -import "github.com/chainreactors/aiscan/core/aop" - -const NS = "delegation" - -func Get(event aop.Event) (DelegationDetail, bool, error) { - return aop.Ext[DelegationDetail](event, NS) -} - -func Set(event *aop.Event, value DelegationDetail) error { - return aop.SetExt(event, NS, value) -} diff --git a/core/aop/x/delegation/generate.go b/core/aop/x/delegation/generate.go deleted file mode 100644 index eca41c3f..00000000 --- a/core/aop/x/delegation/generate.go +++ /dev/null @@ -1,3 +0,0 @@ -package delegation - -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p delegation -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/delegation.schema.json diff --git a/core/aop/x/delegation/types_gen.go b/core/aop/x/delegation/types_gen.go deleted file mode 100644 index 11ef0a19..00000000 --- a/core/aop/x/delegation/types_gen.go +++ /dev/null @@ -1,33 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package delegation - -type DelegationDetail struct { - // AgentID corresponds to the JSON schema field "agent_id". - AgentID string `json:"agent_id,omitempty,omitzero"` - - // AgentName corresponds to the JSON schema field "agent_name". - AgentName string `json:"agent_name,omitempty,omitzero"` - - // AgentType corresponds to the JSON schema field "agent_type". - AgentType string `json:"agent_type,omitempty,omitzero"` - - // ContextMode corresponds to the JSON schema field "context_mode". - ContextMode DelegationDetailContextMode `json:"context_mode,omitempty,omitzero"` - - // RunMode corresponds to the JSON schema field "run_mode". - RunMode DelegationDetailRunMode `json:"run_mode,omitempty,omitzero"` - - // Task corresponds to the JSON schema field "task". - Task string `json:"task,omitempty,omitzero"` -} - -type DelegationDetailContextMode string - -const DelegationDetailContextModeFork DelegationDetailContextMode = "fork" -const DelegationDetailContextModeFresh DelegationDetailContextMode = "fresh" - -type DelegationDetailRunMode string - -const DelegationDetailRunModeBackground DelegationDetailRunMode = "background" -const DelegationDetailRunModeForeground DelegationDetailRunMode = "foreground" diff --git a/core/aop/x/eval/eval.go b/core/aop/x/eval/eval.go deleted file mode 100644 index 5b9c9188..00000000 --- a/core/aop/x/eval/eval.go +++ /dev/null @@ -1,16 +0,0 @@ -package eval - -import "github.com/chainreactors/aiscan/core/aop" - -const ( - NS = "eval" - - StateStart = "eval_start" - StateEnd = "eval_end" - StateError = "eval_error" -) - -func Get(event aop.Event) (Control, bool, error) { return aop.Ext[Control](event, NS) } -func Set(event *aop.Event, value Control) error { return aop.SetExt(event, NS, value) } -func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) } -func SetDetail(event *aop.Event, value Detail) error { return aop.SetExt(event, NS, value) } diff --git a/core/aop/x/eval/generate.go b/core/aop/x/eval/generate.go deleted file mode 100644 index 6f47cca3..00000000 --- a/core/aop/x/eval/generate.go +++ /dev/null @@ -1,3 +0,0 @@ -package eval - -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p eval -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/eval.schema.json diff --git a/core/aop/x/eval/types_gen.go b/core/aop/x/eval/types_gen.go deleted file mode 100644 index df02ce02..00000000 --- a/core/aop/x/eval/types_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package eval - -type Control struct { - // Criteria corresponds to the JSON schema field "criteria". - Criteria string `json:"criteria"` - - // MaxRounds corresponds to the JSON schema field "max_rounds". - MaxRounds int `json:"max_rounds,omitempty,omitzero"` -} - -type Detail struct { - // Error corresponds to the JSON schema field "error". - Error string `json:"error,omitempty,omitzero"` - - // MaxRounds corresponds to the JSON schema field "max_rounds". - MaxRounds int `json:"max_rounds"` - - // Pass corresponds to the JSON schema field "pass". - Pass bool `json:"pass,omitempty,omitzero"` - - // Reason corresponds to the JSON schema field "reason". - Reason string `json:"reason,omitempty,omitzero"` - - // Round corresponds to the JSON schema field "round". - Round int `json:"round"` -} diff --git a/core/aop/x/ioa/generate.go b/core/aop/x/ioa/generate.go deleted file mode 100644 index 0e58ca51..00000000 --- a/core/aop/x/ioa/generate.go +++ /dev/null @@ -1,3 +0,0 @@ -package ioa - -//go:generate go run github.com/atombender/go-jsonschema@v0.23.1 --only-models --tags json --struct-name-from-title --capitalization ID -p ioa -o types_gen.go ../../../../web/frontend/cyber-ui/packages/agent-protocol/schema/ext/ioa.schema.json diff --git a/core/aop/x/ioa/ioa.go b/core/aop/x/ioa/ioa.go deleted file mode 100644 index 2a231fa3..00000000 --- a/core/aop/x/ioa/ioa.go +++ /dev/null @@ -1,10 +0,0 @@ -package ioa - -import "github.com/chainreactors/aiscan/core/aop" - -const NS = "ioa" - -func GetDetail(event aop.Event) (HandoffDetail, bool, error) { - return aop.Ext[HandoffDetail](event, NS) -} -func SetDetail(event *aop.Event, value HandoffDetail) error { return aop.SetExt(event, NS, value) } diff --git a/core/aop/x/ioa/types_gen.go b/core/aop/x/ioa/types_gen.go deleted file mode 100644 index 64f0161e..00000000 --- a/core/aop/x/ioa/types_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package ioa - -type HandoffDetail struct { - // Mode corresponds to the JSON schema field "mode". - Mode *string `json:"mode,omitempty,omitzero"` - - // Model corresponds to the JSON schema field "model". - Model *string `json:"model,omitempty,omitzero"` - - // Name corresponds to the JSON schema field "name". - Name *string `json:"name,omitempty,omitzero"` - - // ParentSessionID corresponds to the JSON schema field "parent_session_id". - ParentSessionID *string `json:"parent_session_id,omitempty,omitzero"` - - // Phase corresponds to the JSON schema field "phase". - Phase *string `json:"phase,omitempty,omitzero"` - - // Refs corresponds to the JSON schema field "refs". - Refs []string `json:"refs,omitempty,omitzero"` - - // SessionID corresponds to the JSON schema field "session_id". - SessionID *string `json:"session_id,omitempty,omitzero"` - - // Status corresponds to the JSON schema field "status". - Status *string `json:"status,omitempty,omitzero"` - - // Type corresponds to the JSON schema field "type". - Type *string `json:"type,omitempty,omitzero"` - - AdditionalProperties interface{} `mapstructure:",remain"` -} diff --git a/pkg/webproto/config.go b/core/config/distribute.go similarity index 61% rename from pkg/webproto/config.go rename to core/config/distribute.go index 0827fd54..ef94125d 100644 --- a/pkg/webproto/config.go +++ b/core/config/distribute.go @@ -1,15 +1,11 @@ -package webproto +package config import ( "fmt" "strings" - - agentprovider "github.com/chainreactors/aiscan/agent/provider" ) -// LLMProviderConfig is one named LLM profile. The profile selected by -// ActiveProfile is the runtime primary provider; the remaining entries are -// available for switching. +// LLMProviderConfig is one named LLM profile distributed by the Web server. type LLMProviderConfig struct { ID string `json:"id" yaml:"id,omitempty"` Name string `json:"name" yaml:"name,omitempty"` @@ -22,30 +18,23 @@ type LLMProviderConfig struct { ContextWindow int `json:"context_window,omitempty" yaml:"context_window,omitempty"` } -// LLMConfig is the provider profile list — the single representation of LLM -// settings. Selection is by ActiveProfile id, never by list position. type LLMConfig struct { ActiveProfile string `json:"active_profile,omitempty" yaml:"active_profile,omitempty"` Providers []LLMProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"` } -// Active returns the selected primary provider profile (Providers[0] when -// ActiveProfile is unset or unknown). func (c LLMConfig) Active() LLMProviderConfig { if len(c.Providers) == 0 { return LLMProviderConfig{} } - for _, p := range c.Providers { - if p.ID == c.ActiveProfile { - return NormalizeLLMProvider(p) + for _, provider := range c.Providers { + if provider.ID == c.ActiveProfile { + return NormalizeLLMProvider(provider) } } return NormalizeLLMProvider(c.Providers[0]) } -// MigrateLLMConfig normalizes a freshly loaded config exactly once: a legacy -// flat provider section becomes a single profile, missing ids/names are -// filled, and ActiveProfile is validated. It never writes back into flat. func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { if len(llm.Providers) == 0 { if flat.Provider == "" && flat.BaseURL == "" && flat.Model == "" { @@ -57,36 +46,35 @@ func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { } llm.Providers = []LLMProviderConfig{flat} } - for i := range llm.Providers { - llm.Providers[i] = NormalizeLLMProvider(llm.Providers[i]) - if llm.Providers[i].ID == "" { - llm.Providers[i].ID = fmt.Sprintf("profile-%d", i+1) + for index := range llm.Providers { + llm.Providers[index] = NormalizeLLMProvider(llm.Providers[index]) + if llm.Providers[index].ID == "" { + llm.Providers[index].ID = fmt.Sprintf("profile-%d", index+1) } - if llm.Providers[i].Name == "" { - llm.Providers[i].Name = llm.Providers[i].Model - if llm.Providers[i].Name == "" { - llm.Providers[i].Name = llm.Providers[i].Provider + if llm.Providers[index].Name == "" { + llm.Providers[index].Name = llm.Providers[index].Model + if llm.Providers[index].Name == "" { + llm.Providers[index].Name = llm.Providers[index].Provider } } } - active := llm.Active() - llm.ActiveProfile = active.ID + llm.ActiveProfile = llm.Active().ID } -// NormalizeLLMProvider canonicalizes protocol casing and infers the protocol -// from base_url when omitted. Validation rejects unsupported protocols. func NormalizeLLMProvider(profile LLMProviderConfig) LLMProviderConfig { - if strings.TrimSpace(profile.Provider) != "" { - profile.Provider = agentprovider.NormalizeProvider(profile.Provider) - } else { - profile.Provider = agentprovider.InferFromBaseURL(profile.BaseURL) + profile.Provider = strings.ToLower(strings.TrimSpace(profile.Provider)) + if profile.Provider == "" { + if strings.Contains(strings.ToLower(profile.BaseURL), "anthropic.com") { + profile.Provider = "anthropic" + } else { + profile.Provider = "openai" + } } return profile } -// DistributeConfig is the configuration payload sent from the web server -// to agents. All secret fields are included so agents can use them. -// Also used by the settings UI (with secrets masked at the handler level). +// DistributeConfig is the shared configuration document loaded by the Web +// server and consumed by remote agents. HTTP masking stays in pkg/web. type DistributeConfig struct { LLM LLMConfig `json:"llm" yaml:"llm"` Cyberhub struct { diff --git a/pkg/webproto/config_test.go b/core/config/distribute_test.go similarity index 97% rename from pkg/webproto/config_test.go rename to core/config/distribute_test.go index f9263b4a..2ee6ff12 100644 --- a/pkg/webproto/config_test.go +++ b/core/config/distribute_test.go @@ -1,4 +1,4 @@ -package webproto +package config import "testing" diff --git a/core/config/options.go b/core/config/options.go index 7611bc9f..2bb8333b 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -73,20 +73,21 @@ type ScannerOptions struct { } type AgentOptions struct { - Prompt string `short:"p" long:"prompt" description:"Natural language task or existing file path for the agent"` - Inputs []string `short:"i" long:"input" description:"Target input: IP, URL, IP:port, or CIDR. Can specify multiple"` - Skills []string `short:"s" long:"skill" description:"Skill to apply (name or file path). Can specify multiple"` - Tools []string `short:"t" long:"tools" config:"tools" description:"Optional tool groups to enable (search, browser). Arsenal is always loaded"` - TaskFile string `long:"task-file" description:"File containing task description"` - Heartbeat int `long:"heartbeat" description:"Heartbeat interval in minutes: periodically wake the agent to review context (0 disables)" default:"0"` - Timeout int `long:"timeout" config:"timeout" description:"Overall timeout in seconds" default:"3600"` - EvalCriteria string `short:"e" long:"eval" config:"eval_criteria" description:"Goal evaluation criteria — an independent LLM evaluates whether the task was achieved"` - EvalModel string `long:"eval-model" config:"eval_model" description:"Model for goal evaluation (defaults to main model)"` - EvalMaxRetries int `long:"eval-retries" config:"eval_retries" description:"Max goal evaluation retry rounds" default:"3"` - WebURL string `long:"web-url" config:"web_url" description:"AIScan web server URL for remote REPL and PTY access"` - Transport string `long:"transport" config:"transport" description:"Agent transport: auto, local, web, or stdio" default:"auto"` - Resume string `long:"resume" description:"Resume session from a saved session file path"` - SaveSession bool `long:"save-session" config:"save_session" description:"Auto-save conversation to .aiscan/sessions/ after each agent run (default: off)"` + Prompt string `short:"p" long:"prompt" description:"Natural language task or existing file path for the agent"` + Inputs []string `short:"i" long:"input" description:"Target input: IP, URL, IP:port, or CIDR. Can specify multiple"` + Skills []string `short:"s" long:"skill" description:"Skill to apply (name or file path). Can specify multiple"` + Tools []string `short:"t" long:"tools" config:"tools" description:"Optional tool groups to enable (search, browser). Arsenal is always loaded"` + TaskFile string `long:"task-file" description:"File containing task description"` + Heartbeat int `long:"heartbeat" description:"Heartbeat interval in minutes: periodically wake the agent to review context (0 disables)" default:"0"` + Timeout int `long:"timeout" config:"timeout" description:"Overall timeout in seconds" default:"3600"` + EvalCriteria string `short:"e" long:"eval" config:"eval_criteria" description:"Goal evaluation criteria — an independent LLM evaluates whether the task was achieved"` + EvalModel string `long:"eval-model" config:"eval_model" description:"Model for goal evaluation (defaults to main model)"` + EvalMaxRetries int `long:"eval-retries" config:"eval_retries" description:"Max goal evaluation retry rounds" default:"3"` + WebURL string `long:"web-url" config:"web_url" description:"AIScan web server URL for remote REPL and PTY access"` + Transport string `long:"transport" config:"transport" description:"Agent transport: auto, local, web, grpc, or stdio" default:"auto"` + Resume string `long:"resume" description:"Resume session from a saved session file path"` + SaveSession bool `long:"save-session" config:"save_session" description:"Auto-save conversation to .aiscan/sessions/ after each agent run (default: off)"` + CaptureProviderFrames bool `long:"capture-provider-frames" config:"capture_provider_frames" description:"Emit exact provider request/response frames as sensitive AOP events"` } type AgentTransport string @@ -95,6 +96,7 @@ const ( AgentTransportAuto AgentTransport = "auto" AgentTransportLocal AgentTransport = "local" AgentTransportWeb AgentTransport = "web" + AgentTransportGRPC AgentTransport = "grpc" AgentTransportStdio AgentTransport = "stdio" ) @@ -111,13 +113,13 @@ func ResolveAgentTransport(opt *Option) (AgentTransport, error) { return AgentTransportLocal, nil case AgentTransportLocal, AgentTransportStdio: return value, nil - case AgentTransportWeb: + case AgentTransportWeb, AgentTransportGRPC: if strings.TrimSpace(opt.WebURL) == "" { - return "", fmt.Errorf("--transport web requires --web-url") + return "", fmt.Errorf("--transport %s requires --web-url", value) } return value, nil default: - return "", fmt.Errorf("unsupported agent transport %q: use auto, local, web, or stdio", opt.Transport) + return "", fmt.Errorf("unsupported agent transport %q: use auto, local, web, grpc, or stdio", opt.Transport) } } diff --git a/core/deps/architecture_test.go b/core/deps/architecture_test.go index 3faca71d..257143ba 100644 --- a/core/deps/architecture_test.go +++ b/core/deps/architecture_test.go @@ -1,6 +1,7 @@ package deps_test import ( + "go/ast" "go/parser" "go/token" "io/fs" @@ -28,6 +29,22 @@ func TestLayerImportsAreUnidirectional(t *testing.T) { }) } +func TestAOPProtocolLayerHasNoRuntimeDependencies(t *testing.T) { + root := repositoryRoot(t) + assertNoFirstPartyImports(t, filepath.Join(root, "aop"), map[string]bool{ + "agent": true, + "core": true, + "pkg": true, + "tools": true, + "cmd": true, + }) +} + +func TestRunnerDoesNotDependOnWeb(t *testing.T) { + root := repositoryRoot(t) + assertNoImportPrefix(t, filepath.Join(root, "pkg", "runner"), modulePath+"/pkg/web") +} + func TestLegacyPackagesCannotReturn(t *testing.T) { root := repositoryRoot(t) legacy := []struct { @@ -42,13 +59,19 @@ func TestLegacyPackagesCannotReturn(t *testing.T) { {dir: filepath.Join("core", "transport"), importPath: modulePath + "/core/" + "transport"}, {dir: filepath.Join("cmd", "agent"), importPath: modulePath + "/cmd/" + "agent"}, {dir: filepath.Join("cmd", "runner"), importPath: modulePath + "/cmd/" + "runner"}, + {dir: filepath.Join("core", "aop"), importPath: modulePath + "/core/aop"}, + {dir: filepath.Join("pkg", "webproto"), importPath: modulePath + "/pkg/webproto"}, + {dir: filepath.Join("pkg", "webagent"), importPath: modulePath + "/pkg/webagent"}, + {dir: filepath.Join("pkg", "web", "proto"), importPath: modulePath + "/pkg/web/proto"}, + {dir: filepath.Join("internal", "aoputil"), importPath: modulePath + "/internal/aoputil"}, + {dir: filepath.Join("internal", "gen"), importPath: modulePath + "/internal/gen"}, + {dir: "api", importPath: modulePath + "/api"}, + {dir: filepath.Join("aop", "ext"), importPath: modulePath + "/aop/ext"}, } for _, item := range legacy { legacyDir := filepath.Join(root, item.dir) - if _, err := os.Stat(legacyDir); err == nil { - t.Errorf("legacy package directory still exists: %s", legacyDir) - } else if !os.IsNotExist(err) { - t.Errorf("stat legacy package directory: %v", err) + if hasGoFiles(legacyDir) { + t.Errorf("legacy package still contains Go files: %s", legacyDir) } } @@ -83,6 +106,93 @@ func TestLegacyPackagesCannotReturn(t *testing.T) { } } +func TestGeneratedProtobufLivesUnderAOP(t *testing.T) { + root := repositoryRoot(t) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if path != root && shouldSkipTree(root, path) { + return filepath.SkipDir + } + return nil + } + name := entry.Name() + if !strings.HasSuffix(name, ".pb.go") && !strings.HasSuffix(name, ".connect.go") { + return nil + } + rel := filepath.ToSlash(relative(root, path)) + if !strings.HasPrefix(rel, "aop/") { + t.Errorf("generated protobuf file outside aop/: %s", rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestWebProtocolDoesNotDefineGenericJSONEnvelope(t *testing.T) { + root := repositoryRoot(t) + tree := filepath.Join(root, "pkg", "web") + err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + file, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if parseErr != nil { + return parseErr + } + ast.Inspect(file, func(node ast.Node) bool { + typeSpec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + structure, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return true + } + hasTypeString, hasRawPayload := false, false + for _, field := range structure.Fields.List { + for _, name := range field.Names { + if name.Name == "Type" && expressionName(field.Type) == "string" { + hasTypeString = true + } + if (name.Name == "Data" || name.Name == "Payload" || name.Name == "Value" || name.Name == "Body") && expressionName(field.Type) == "json.RawMessage" { + hasRawPayload = true + } + } + } + if hasTypeString && hasRawPayload { + t.Errorf("generic Type + json.RawMessage envelope %s in %s", typeSpec.Name.Name, relative(root, path)) + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestLiveBrokerDoesNotUseJSON(t *testing.T) { + root := repositoryRoot(t) + path := filepath.Join(root, "pkg", "web", "broker.go") + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"encoding/json", "json.RawMessage", "protojson"} { + if strings.Contains(string(content), forbidden) { + t.Errorf("live broker contains JSON bridge %q", forbidden) + } + } +} + func TestAgentExampleIsNotMaintainedBuildTarget(t *testing.T) { root := repositoryRoot(t) example := filepath.Join(root, "examples", "agent", "main.go") @@ -139,6 +249,58 @@ func assertNoFirstPartyImports(t *testing.T, tree string, forbidden map[string]b } } +func assertNoImportPrefix(t *testing.T, tree, forbidden string) { + t.Helper() + root := repositoryRoot(t) + err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + imports, parseErr := importsInFile(path) + if parseErr != nil { + return parseErr + } + for _, importPath := range imports { + if importPath == forbidden || strings.HasPrefix(importPath, forbidden+"/") { + t.Errorf("forbidden dependency %q in %s", importPath, relative(root, path)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func hasGoFiles(tree string) bool { + found := false + _ = filepath.WalkDir(tree, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + if !entry.IsDir() && filepath.Ext(path) == ".go" { + found = true + return fs.SkipAll + } + return nil + }) + return found +} + +func expressionName(expression ast.Expr) string { + switch value := expression.(type) { + case *ast.Ident: + return value.Name + case *ast.SelectorExpr: + return expressionName(value.X) + "." + value.Sel.Name + default: + return "" + } +} + func importsInFile(path string) ([]string, error) { file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) if err != nil { diff --git a/core/output/timeline.go b/core/output/timeline.go index da2c6b87..72e09872 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -10,11 +10,12 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/core/aop" - xcommand "github.com/chainreactors/aiscan/core/aop/x/command" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/utils/parsers" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" + "google.golang.org/protobuf/encoding/protojson" ) // --------------------------------------------------------------------------- @@ -54,12 +55,13 @@ func ParseTimelineFile(path string) ([]TimelineEntry, error) { } func parseLine(line []byte) (TimelineEntry, bool) { - var event aop.Event - if json.Unmarshal(line, &event) == nil && event.Valid() { - timestamp, err := time.Parse(time.RFC3339Nano, event.TS) - if err == nil { - return TimelineEntry{Timestamp: timestamp, Type: event.Type, Data: &event}, true + event := new(aop.Event) + if protojson.Unmarshal(line, event) == nil && event.SessionId != "" && event.Payload != nil { + timestamp := time.Time{} + if event.EmittedAt != nil { + timestamp = event.EmittedAt.AsTime() } + return TimelineEntry{Timestamp: timestamp, Type: aop.Kind(event), Data: event}, true } rec, err := ParseRecord(line) if err != nil || rec.Type == "" { @@ -199,32 +201,28 @@ func collectSessionMeta(entries []TimelineEntry) sessionMeta { switch d := e.Data.(type) { case *aop.Event: if m.id == "" { - m.id = d.SessionID + m.id = d.SessionId } - switch d.Type { - case aop.TypeSessionStart: + switch payload := d.Payload.(type) { + case *aop.Event_SessionStarted: m.startTS = e.Timestamp - if data, err := aop.DecodeData[aop.SessionStartData](*d); err == nil { - m.parentID = data.ParentSessionID - if data.Model != "" && m.model == "" { - m.model = data.Model - } + m.parentID = payload.SessionStarted.ParentSessionId + if payload.SessionStarted.Model != "" && m.model == "" { + m.model = payload.SessionStarted.Model } - case aop.TypeSessionEnd: + case *aop.Event_SessionEnded: m.endTS = e.Timestamp - case aop.TypeTurnStart: + case *aop.Event_TurnStarted: m.turns++ - case aop.TypeTurnEnd: + case *aop.Event_TurnEnded: m.endTS = e.Timestamp - if data, err := aop.DecodeData[aop.TurnEndData](*d); err == nil { - m.stop = data.Stop - if data.Usage != nil && data.Usage.TotalTokens > 0 { - m.totalTokens = data.Usage.TotalTokens - } + m.stop = payload.TurnEnded.StopReason + if payload.TurnEnded.Usage != nil && payload.TurnEnded.Usage.TotalTokens > 0 { + m.totalTokens = int(payload.TurnEnded.Usage.TotalTokens) } - case aop.TypeUsage: - if data, err := aop.DecodeData[aop.UsageData](*d); err == nil && data.TotalTokens > 0 { - m.totalTokens = data.TotalTokens + case *aop.Event_Usage: + if payload.Usage.TotalTokens > 0 { + m.totalTokens = int(payload.Usage.TotalTokens) } } } @@ -356,19 +354,16 @@ func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { if event == nil { return } - switch event.Type { - case aop.TypeTurnStart: - sb.WriteString(fmt.Sprintf("## Run %s\n\n", event.TurnID)) + switch payload := event.Payload.(type) { + case *aop.Event_TurnStarted: + sb.WriteString(fmt.Sprintf("## Run %s\n\n", event.TurnId)) - case aop.TypeMessage: - data, err := aop.DecodeData[aop.MessageData](*event) - if err != nil { - return - } + case *aop.Event_Message: + data := payload.Message var textParts []string - for _, part := range data.Parts { - if part.Type == aop.PartText && part.Text != "" { - textParts = append(textParts, part.Text) + for _, part := range data.Content { + if text := part.GetText().GetText(); text != "" { + textParts = append(textParts, text) } } text := strings.Join(textParts, "\n") @@ -378,7 +373,7 @@ func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { if data.Role == "user" { sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(text, 200))) } else { - detail, ok, _ := xcommand.GetDetail(*event) + detail, ok, _ := ext.GetCommandDetail(event) if ok && detail.Presentation == "preformatted" { sb.WriteString(markdownCodeFence(text) + "\n\n") } else { @@ -386,70 +381,59 @@ func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { } } - case aop.TypeToolCall: - data, err := aop.DecodeData[aop.ToolCallData](*event) - if err != nil { - return - } - argsStr := "" - switch args := data.Args.(type) { - case string: - argsStr = args - case map[string]any: - raw, _ := json.Marshal(args) - argsStr = string(raw) - } - summary := summarizeToolArgs(data.ToolName, argsStr) + case *aop.Event_ToolCall: + data := payload.ToolCall + argsStr := string(data.GetArguments().GetData()) + summary := summarizeToolArgs(data.Name, argsStr) if summary != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", data.ToolName, summary)) + sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", data.Name, summary)) } else { - sb.WriteString(fmt.Sprintf("- **%s**\n", data.ToolName)) + sb.WriteString(fmt.Sprintf("- **%s**\n", data.Name)) } - case aop.TypeToolResult: - data, err := aop.DecodeData[aop.ToolResultData](*event) - if err != nil { - return - } - result := aop.ToolResultText(data.Content) + case *aop.Event_ToolResult: + data := payload.ToolResult + result := aopContentText(data.Output) if data.IsError { sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(result, 120))) } else { sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(result, 150))) } - case aop.TypeUsage: - data, err := aop.DecodeData[aop.UsageData](*event) - if err != nil { - return - } + case *aop.Event_Usage: + data := payload.Usage if data.TotalTokens > 0 { usage := fmt.Sprintf("*%d tokens", data.TotalTokens) - if data.CacheReadTokens > 0 && data.InputTokens > 0 { - pct := float64(data.CacheReadTokens) / float64(data.InputTokens) * 100 + if data.Detail["cache_read"] > 0 && data.InputTokens > 0 { + pct := float64(data.Detail["cache_read"]) / float64(data.InputTokens) * 100 usage += fmt.Sprintf(", cache %.0f%%", pct) } sb.WriteString("\n" + usage + "*\n\n") } - case aop.TypeError: - data, err := aop.DecodeData[aop.ErrorData](*event) - if err == nil && data.Message != "" { - sb.WriteString(fmt.Sprintf("\n> **error:** %s\n\n", data.Message)) + case *aop.Event_Error: + if payload.Error.Message != "" { + sb.WriteString(fmt.Sprintf("\n> **error:** %s\n\n", payload.Error.Message)) } - case aop.TypeTurnEnd: - data, err := aop.DecodeData[aop.TurnEndData](*event) - if err == nil { - sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", data.Stop)) - } + case *aop.Event_TurnEnded: + sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", payload.TurnEnded.StopReason)) + + case *aop.Event_SessionEnded: + sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", payload.SessionEnded.Reason)) + } +} - case aop.TypeSessionEnd: - data, err := aop.DecodeData[aop.SessionEndData](*event) - if err == nil { - sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", data.Reason)) +func aopContentText(content []*aop.Content) string { + var parts []string + for _, item := range content { + if text := item.GetText().GetText(); text != "" { + parts = append(parts, text) + } else if opaque := item.GetOpaque(); opaque != nil { + parts = append(parts, string(opaque.Value.GetData())) } } + return strings.Join(parts, "\n") } func markdownCodeFence(text string) string { diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index b0d1b884..a9844674 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -1,21 +1,28 @@ package output import ( - "encoding/json" "strings" "testing" "time" - "github.com/chainreactors/aiscan/core/aop" - xcommand "github.com/chainreactors/aiscan/core/aop/x/command" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { - raw := []byte(`{"type":"message","ts":"2026-07-20T00:00:00Z","session_id":"session-1","agent":"aiscan","data":{"message_id":"m-1","role":"assistant","parts":[{"type":"text","text":"hello"}]}}`) + event := timelineEvent(&aop.Event{Payload: &aop.Event_Message{Message: &aop.Message{ + Id: "m-1", Role: "assistant", Content: []*aop.Content{aop.Text("hello")}, + }}}) + raw, err := protojson.Marshal(event) + if err != nil { + t.Fatal(err) + } entry, ok := parseLine(raw) if !ok { - t.Fatal("native AOP envelope was not parsed") + t.Fatal("native AOP event was not parsed") } if _, ok := entry.Data.(*aop.Event); !ok { t.Fatalf("entry data type = %T", entry.Data) @@ -26,40 +33,35 @@ func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { } func TestTimelineRendersStructuredToolResult(t *testing.T) { - data, _ := json.Marshal(aop.ToolResultData{ - ToolCallID: "call-1", ToolName: "scan", - Content: aop.ToolResultContent{Content: "three ports", Images: []aop.ImageSource{{MediaType: "image/png", Base64: "eA=="}}}, - }) - event := aop.Event{ - Type: aop.TypeToolResult, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", TurnID: "turn-1", Agent: "aiscan", Data: data, - } - markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + event := timelineEvent(&aop.Event{Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{ + CallId: "call-1", Name: "scan", Output: []*aop.Content{ + aop.Text("three ports"), aop.Image("image/png", []byte("x")), + }, + }}}) + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: event.EmittedAt.AsTime(), Type: aop.Kind(event), Data: event}}) if !strings.Contains(markdown, "three ports") { t.Fatalf("timeline markdown = %q", markdown) } } func TestTimelineFormatsPreformattedCommandAtPresentationBoundary(t *testing.T) { - data, _ := json.Marshal(aop.MessageData{ - MessageID: "command-1", Role: "assistant", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "one\ntwo"}}, - }) - event := aop.Event{ - Type: aop.TypeMessage, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", Agent: "aiscan", Data: data, - } - _ = xcommand.SetDetail(&event, xcommand.Detail{Line: "/status", Presentation: "preformatted"}) - markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + event := timelineEvent(&aop.Event{Payload: &aop.Event_Message{Message: &aop.Message{ + Id: "command-1", Role: "assistant", Content: []*aop.Content{aop.Text("one\ntwo")}, + }}}) + _ = ext.SetCommandDetail(event, ext.CommandDetail{Line: "/status", Presentation: "preformatted"}) + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: event.EmittedAt.AsTime(), Type: aop.Kind(event), Data: event}}) if !strings.Contains(markdown, "```\none\ntwo\n```") { t.Fatalf("timeline markdown = %q", markdown) } } -func mustTimelineTime(t *testing.T, value string) time.Time { - t.Helper() - parsed, err := time.Parse(time.RFC3339Nano, value) - if err != nil { - t.Fatal(err) - } - return parsed +func timelineEvent(event *aop.Event) *aop.Event { + event.Id = "e-1" + event.SessionId = "session-1" + event.TurnId = "turn-1" + event.Emitter = "aiscan" + event.EmittedAt = timestamppb.New(time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)) + return event } func TestParseLineRejectsLegacyAgentRecord(t *testing.T) { diff --git a/core/resources/resources.go b/core/resources/resources.go index ccc78cf5..1ca5a679 100644 --- a/core/resources/resources.go +++ b/core/resources/resources.go @@ -42,7 +42,7 @@ type Set struct { NeutronConfig *neutron.Config Fingers *fingers.Engine Neutron *neutron.Engine - configs map[string]map[string][]byte + configs map[string]map[string][]byte } // Init loads scanner resources once for aiscan and prepares SDK configs. @@ -64,7 +64,7 @@ func Init(ctx context.Context, opts Options) (*Set, error) { set := &Set{ Mode: mode, RemoteEnabled: opts.CyberhubURL != "" && opts.APIKey != "", - configs: defaultConfigs(), + configs: defaultConfigs(), } if set.RemoteEnabled { @@ -150,7 +150,7 @@ func NormalizeMode(mode string) (string, error) { func defaultConfigs() map[string]map[string][]byte { shared := loadEngineConfigs("http", "socket", "port") return map[string]map[string][]byte{ - "gogo": mergeConfigs(shared, + "gogo": mergeConfigs(shared, "fingerprinthub_web", "fingerprinthub_service", "extract", "workflow", "neutron"), "spray": mergeConfigs(shared, "extract", "spray_rule", "spray_dict", "spray_common"), diff --git a/core/tool/definition.go b/core/tool/definition.go index 974e4210..947cb906 100644 --- a/core/tool/definition.go +++ b/core/tool/definition.go @@ -2,7 +2,7 @@ package tool // Definition describes a tool the LLM can invoke. type Definition struct { - Type string `json:"type"` + Type string `json:"type"` Function FuncDef `json:"function"` } diff --git a/docs/agent-runtime-multipath-analysis.md b/docs/agent-runtime-multipath-analysis.md index 2da80840..e9f88b0c 100644 --- a/docs/agent-runtime-multipath-analysis.md +++ b/docs/agent-runtime-multipath-analysis.md @@ -50,20 +50,20 @@ session.end ## Transport -stdio 与 WebSocket 共用 `webproto.Message` 语义帧: +stdio 与 WebSocket 共用生成的 `aiscan.transport.ServerFrame/AgentFrame`, +分别以标准 protobuf JSON 传输;gRPC 使用同一消息的 protobuf binary: ```text -session.open / session.opened -session.close / session.closed -run / run.cancel -command / command.result -aop -error +open_session / close_session +run_turn / cancel_turn +command / command_result +event +operation_error ``` - Web Run API 只使用 `turn_id` 关联;协议中不存在独立的 `run_id`; - Runner 身份使用注册消息中的 `NodeRef.ID`;它是节点路由身份,不进入 `Session → Run` 领域模型,也不与 `turn_id` 混用; -- direct structured tool execution 使用 `command / command.result`,不再把 inbound AOP 当 RPC; +- direct structured tool execution 使用 `tool_call` / AOP `tool_result`; - PTY、file RPC、node status/config 仍属于各自控制或终端平面,不伪装成 Agent Turn。 ## 并发与异步输入 @@ -81,4 +81,5 @@ error - 不双读、双写旧协议; - SQLite 保留 sessions、messages、assets、records; -- 旧 `chat_aop_events` 在 migration version 2 时一次性清空,因为旧 Session/Turn 边界不能安全重解释。 +- `chat_aop_events.event_json` 只存标准 protobuf JSON;迁移保留已有历史,不做双读写; +- SQLite delivery 列统一为 `cursor`,旧 `hub_seq` 仅执行一次列重命名。 diff --git a/docs/mechanisms.md b/docs/mechanisms.md index 06081c29..e32ec2fe 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -8,7 +8,7 @@ **问题**: hub 原来每次 WS 连接都 `generateID()` 生成随机 key。chat session 在创建时冻结 `agent_id`,agent 断连重连后 id 变化,session 绑定的旧 id 解析到空,消息被拒为 "not connected"。 -**机制**: `agentKey()` 从 agent 的 `RegisterPayload` 中提取稳定标识(`NodeName` → `Name` → fallback random),作为 pool 的唯一 key。重连的 agent 覆盖旧 slot 而非新建。 +**机制**: `agentKey()` 从生成的 `transport.AgentHello` 中提取稳定标识,作为 pool 的唯一 key。重连的 agent 覆盖旧 slot 而非新建。 **守卫**: - `register()` 检测旧连接并 Close,触发旧 read loop 退出 @@ -19,17 +19,17 @@ --- -## 2. SSE 可靠性分级 +## 2. Typed broker 可靠性分级 -**问题**: SSE buffer 满时所有事件同等丢弃。终结性事件(message_end、error)被丢弃后 UI 永远停在 streaming indicator。 +**问题**: live buffer 满时若所有事件同等丢弃,终结性事件被丢弃后 UI 会停在 streaming indicator。 -**机制**: `HubEvent` 新增 `Reliable bool`。`Hub.Broadcast` 在 buffer 满时: -- 非 Reliable(token delta): 直接丢弃,下一个 delta 会补 -- Reliable(终结性事件): 驱逐最旧的 queued 事件腾出空间,保证送达 +**机制**: `Hub` 只传递 typed `AOPDelivery` 和 `scan.ScanEvent`。广播方显式标记可靠性;buffer 满时: +- 非 reliable(token delta、scan progress):直接丢弃 +- reliable(完整 message、turn ended、scan terminal):驱逐最旧 queued 事件后入队 -**Reliable 事件**: message, message_end, error, scan_complete, scan_error, eval +持久化重放由 `chat_aop_events` 和 Scan snapshot 负责,live protobuf 不经过 JSON envelope。 -**文件**: `pkg/web/sse.go`, `pkg/web/service.go` +**文件**: `pkg/web/broker.go`, `pkg/web/aop_grpc.go`, `pkg/web/scan_rpc.go` --- @@ -60,35 +60,24 @@ Settings UI 保存 **并发模型**: hub 的 `saveMu` 防止多个配置事务交错;本地扫描通过 managed App 租约继续使用旧运行时,不会被保存设置中断。agent 侧 `Agent.SetProvider()` / `SetMaxTurns()` 在 `mu.Lock` 下修改 `Cfg`,`Run`/`Continue` 开始时 `configSnapshot()` 在锁下拷贝,已在飞的 run 不受影响。 -**文件**: `pkg/web/service.go`, `cmd/aiscan/web_full.go`, `pkg/web/agents.go`, `pkg/webagent/agent.go`, `pkg/runner/runner.go`, `agent/agent.go` +**文件**: `pkg/web/service.go`, `cmd/aiscan/web_full.go`, `pkg/web/agents.go`, `pkg/web/agent/agent.go`, `pkg/runner/runner.go`, `agent/agent.go` --- -## 4. ChatPayload — Goal 模式协议 +## 4. Goal 模式 AOP 扩展 -**旧协议**: `"chat"` 消息的 Payload 只有 `{"session_id":"..."}`。 +Goal 参数不再定义 Chat DTO。`RunTurnRequest` 是唯一输入;AIScan 专属字段编码为 +`aiscan.transport.RunOptions` 的标准 protobuf JSON,并放入 namespace +`io.chainreactors.aiscan.run`。普通对话和 evaluator 复用同一 Run/Turn 生命周期。 -**新协议**: 扩展为 `webproto.ChatPayload`: - -```go -type ChatPayload struct { - SessionID string // web session 隔离 - Persist bool // 多轮保持 - EvalCriteria string // Goal 评判标准 (非空触发 evaluator loop) - EvalMaxRounds int // 评估轮次上限 - PersistMaxTurns int // 单轮 turn 上限 -} -``` - -从前端 Goal 面板 → hub `SendMessageRequest` → `DispatchChatSession` → agent WS 透传。agent 端 `runChatWithAgent` 据此决定执行普通对话还是进入 evaluator 循环。 - -**文件**: `pkg/webproto/message.go`, `pkg/web/types.go`, `pkg/webagent/agent.go` +**文件**: `proto/aiscan/transport/operation.proto`, `pkg/runner/runtime_protocol.go`, `pkg/web/service.go` --- ## 5. Eval 事件透传与持久化 -agent 在 producer 边缘生成原生 AOP envelope;hub 只校验 envelope,并以固定的 `aop` transport frame 原样转发。评估字段保留在 `ext.aiscan` 中,嵌套结构不会 flatten。 +agent 在 producer 边缘生成 `aop.Event`;hub 通过生成的 `AgentFrame.event` 原样转发。 +评估字段使用 `aiscan.transport.EvalDetail` protobuf JSON 扩展,不做 flatten。 eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事件,但不会再投影成另一套 agent 事件或 system message。会话正文只持久化到 `chat_aop_events`,刷新后从同一 AOP 源重建。 @@ -173,7 +162,7 @@ DELETE /api/deploy/local/{id} — Stop (kill 子进程) `dispatchUserMessage` 对 `/verb` 消息分三层路由: -1. `/clear` — hub 全权处理(清 store → 信号 UI → 转发 agent 清 context) +1. `/clear` — 前端调用 `SessionService.ResetSession`,原 session 关闭并创建 clean session 2. hub 命令 (`/scan`, `/agents`, `/help`) — 本地执行 3. 其余 — 透传给 agent 的 `runChatREPLLine`,由 agent 的完整 TUI console 执行 @@ -181,7 +170,7 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。 ### 命令菜单 -`GET /api/chat/sessions/{id}/commands` 返回 `SessionMenu()` — hub 3 个命令 + agent 注册时上报的命令元数据(从 `tui.Command` 提取,含 skill)。前端 "/" 弹出菜单从这里拉取。 +`aiscan.chat.SessionService/ListCommands` 返回 `SessionMenu()` — hub 命令 + agent 注册时上报的命令元数据(从 `tui.Command` 提取,含 skill)。前端 "/" 弹出菜单通过生成的 Connect client 拉取;Scan 不属于 Chat 命令协议。 **文件**: `pkg/web/service.go`, `pkg/web/handler.go` @@ -189,13 +178,15 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。 ## 10. System Message i18n -`broadcastSystemMessage(sessionID, code, fallback, params)`: +`broadcastSystemMessage(sessionID, code, fallback, params)` 直接生成并持久化 AOP message event: - `code`: 稳定翻译 key(如 `file_uploaded`) - `params`: 插值变量(如 `{"filename": "note.txt", "path": "/tmp/..."}`) - `fallback`: 英文文本,供非 i18n 消费者 / 日志 / 测试使用 -AOP error 事件把 code 保存在标准 data 中,并把 params 保存在 `ext["aiscan.web"]`。通用 reducer 会保留该扩展块,前端从中渲染本地化文本;因此实时流和重放使用同一参数来源。 +AOP error 事件把 code 保存在 `ProtocolError.code`,params 使用 +`aiscan.transport.WebMessageExtension`,通过标准 protobuf JSON 放入扩展。通用 reducer +保留该扩展,因此实时流和重放使用同一参数来源。 已定义的 code: @@ -222,7 +213,7 @@ AOP error 事件把 code 保存在标准 data 中,并把 params 保存在 `ext 2. 下次该 session 的自然语言消息到达时,`takePendingUploads` 一次性 drain 所有 note,拼接到 prompt 前面 3. REPL 命令(`/` 或 `!` 开头)不触发 drain,防止污染命令语法,note 保留到下一条自然语言消息 -**文件**: `pkg/webagent/agent.go` +**文件**: `pkg/web/agent/agent.go` --- @@ -232,9 +223,10 @@ AOP error 事件把 code 保存在标准 data 中,并把 params 保存在 `ext **机制**: Runtime 产生的 typed AOP event 是 Agent 消息、工具调用和 turn 状态的唯一语义来源。Web 层直接转发和持久化这些事件,不再合成第二套 assistant 完成事件,也不再为中间轮次维护独立的聊天事件协议。 -scan、agent joined、session cleared 等产品事件保留独立的 `DomainEvent`,不携带 Agent 的 role/content/message ID 字段。 +AIScan 产品事件使用 typed AOP `ExtensionEvent`;例如 scan 完成通过 +`io.chainreactors.aiscan.scan` 携带 `scan.SessionScanEvent`。不再维护 `DomainEvent`。 -**文件**: `pkg/runner/`, `core/aop/`, `pkg/web/service.go` +**文件**: `pkg/runner/`, `aop/`, `pkg/web/service.go` --- @@ -260,7 +252,7 @@ scan、agent joined、session cleared 等产品事件保留独立的 `DomainEven 跨界面 Runtime 命令通过 typed AOP command detail 标记 `presentation: preformatted`。Web timeline 在最终展示边界生成自适应 Markdown code fence;Runtime、Session 和 transport 不再处理 Markdown 或终端格式。 -**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `core/aop/x/command/command.go`, `core/output/timeline.go` +**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `aop/aiscan/extensions/extensions.go`, `core/output/timeline.go` --- diff --git a/docs/web-chat-api.md b/docs/web-chat-api.md new file mode 100644 index 00000000..71e3600f --- /dev/null +++ b/docs/web-chat-api.md @@ -0,0 +1,413 @@ +# AIScan ConnectRPC Chat 接入手册 + +AIScan Chat 现在以 protobuf 为唯一接口模型,由 ConnectRPC 同时提供 Connect、 +gRPC-Web 和原生 gRPC。这里没有额外的 JSON-RPC 2.0 envelope,也不需要维护一套 +手写 REST DTO 或 SSE 事件协议。 + +旧的 `/api/chat/*` 与 `/api/scans/*` REST/SSE 已下线。Chat、会话管理和扫描都通过 +同一套 protobuf + ConnectRPC 接口提供。 + +## 对外接口形态 + +外部接入者仍只需要理解 `aop.ChatService` 的六个方法: + +| 方法 | 语义 | Connect/gRPC procedure | +| --- | --- | --- | +| `OpenSession` | 打开一个 Agent 会话 | `/aop.ChatService/OpenSession` | +| `RunTurn` | 提交一轮输入 | `/aop.ChatService/RunTurn` | +| `CancelTurn` | 按 `session_id + turn_id` 精确取消 | `/aop.ChatService/CancelTurn` | +| `CloseSession` | 关闭会话 | `/aop.ChatService/CloseSession` | +| `ListEvents` | 按 cursor 读取持久化历史 | `/aop.ChatService/ListEvents` | +| `WatchEvents` | 单向服务端流式监听事件 | `/aop.ChatService/WatchEvents` | + +AIScan 产品自己的会话管理能力放在独立的 +`aiscan.chat.SessionService`,不会污染通用 AOP Chat 语义: + +- `ListSessions`、`GetSession`、`DeleteSession` +- `ResetSession` +- `ListCommands`、`ExecuteCommand` +- `UploadSessionFile` + +其 procedure 前缀为 `/aiscan.chat.SessionService/`。 + +扫描能力位于 `aiscan.scan.ScanService`: + +- `SubmitScan`、`GetScan`、`ListScans`、`CancelScan` +- `WatchScanEvents`(服务端单向流式) +- `GetScanReport` + +外部 Go 调用方不需要分别初始化这些生成 client。稳定入口 +`aop/aiscan.Client` 将它们统一暴露为 `Chat`、`Sessions`、`Scans` 三个 API group。 +底层 group 仍保持独立的 protobuf service 边界,但共享同一个 HTTP client、base URL、 +认证 interceptor 和 Connect 选项。 + +## 传输形态 + +```text +Browser / TypeScript + createConnectTransport + generated client + │ Connect protobuf JSON(或 binary) + ▼ + AIScan HTTP handler + │ 同一 protobuf service implementation + ┌─────────┼──────────┐ + │ │ │ + Connect gRPC-Web native gRPC +``` + +ConnectRPC 解决的是“同一 protobuf API 适配浏览器和 gRPC 客户端”,不是把 gRPC +转换成 JSON-RPC 2.0。浏览器默认使用标准 Protobuf JSON;grpc-go 使用 protobuf +binary,但两者的方法名、字段、错误码和流式终止语义完全相同。 + +服务端同时接受: + +- Connect protocol(浏览器和普通 HTTP client) +- gRPC-Web +- 原生 gRPC(需要 HTTP/2) + +Connect handler 最大 wire message 为 72 MiB(给 Protobuf JSON 的 bytes/base64 留出 +空间),业务文件上传上限仍严格为 50 MiB。 + +## 独立 Go 工具完整接入 + +公共生成代码位于可被仓库外模块导入的路径: + +```text +github.com/chainreactors/aiscan/aop +github.com/chainreactors/aiscan/aop/aiscan +``` + +不要引用 `aop/aiscan/transport`。`aop/aiscan/transport` 只服务 AIScan AgentTransport, +不属于外部 Chat SDK。 + +### 1. 启动 AIScan Web 和 Agent + +```bash +aiscan web --addr 127.0.0.1:8080 --token dev-token +``` + +确认至少一个 Agent 已连接,然后取得它的 participant ID: + +```bash +curl -H "Authorization: Bearer dev-token" \ + http://127.0.0.1:8080/api/agents +``` + +取返回数组中的 `id`,例如 `agent-1`。该值用于 `OpenSession.participant`。 + +### 2. 创建完全独立的 Go module + +```bash +mkdir aiscan-connect-client +cd aiscan-connect-client +go mod init example.com/aiscan-connect-client +go get connectrpc.com/connect@v1.20.0 +go get github.com/chainreactors/aiscan@latest +``` + +如果是在 AIScan 源码 checkout 内验证尚未发布的版本,可临时添加: + +```go +replace github.com/chainreactors/aiscan => /absolute/path/to/aiscan +``` + +发布后的独立项目应删除 `replace` 并锁定明确的 AIScan tag/version。 + +业务代码只初始化一次根客户端: + +```go +client := aiscan.NewClient( + http.DefaultClient, + "http://127.0.0.1:8080", + connect.WithProtoJSON(), +) + +// 通用对话协议 +client.Chat.OpenSession(...) +client.Chat.WatchEvents(...) + +// AIScan 会话管理 +client.Sessions.ListSessions(...) + +// AIScan 扫描 +client.Scans.SubmitScan(...) +client.Scans.WatchScanEvents(...) +``` + +原生 gRPC 也使用相同分组形态,并复用一条 `grpc.ClientConnInterface`: + +```go +client := aiscan.NewGRPCClient(conn) +client.Chat.RunTurn(...) +client.Sessions.GetSession(...) +client.Scans.GetScan(...) +``` + +### 3. 运行可复制的完整客户端 + +仓库提供了一个拥有自己 `go.mod` 的独立示例: + +```bash +cd examples/external-go-client +go run . \ + -url http://127.0.0.1:8080 \ + -token dev-token \ + -agent '' \ + -prompt '请用一句话介绍你的能力' +``` + +这个程序通过公共的 `aop/aiscan.Client` 门面初始化一次,并使用 `client.Chat` 完整执行: + +```text +OpenSession + ├─ 并发建立 WatchEvents + ├─ RunTurn 发送自然语言 Message + ├─ 持续输出 message_delta + ├─ 使用完整 message 作为可靠结果 + ├─ 收到 turn_ended 后结束 + └─ 断线时使用最后的 EventDelivery.cursor 自动重连 +``` + +预期输出形态: + +```text +我是 AIScan,可以协助分析安全目标。 +stop=completed cursor=6 session=session-... turn=turn-... +``` + +实现文件:`examples/external-go-client/main.go`。 + +仓库的端到端回归会把该目录作为 `example.com/aiscan-external-client` 独立 module, +启动真实 HTTP Connect handler 后以子进程执行 `go run .`。因此它能捕获误用 +`internal` 包、认证失败、procedure 不兼容和流式终止缺失等问题。 + +### 4. SDK 重新生成 + +修改 protobuf 后执行: + +```bash +go generate ./proto +``` + +生成代码统一位于 `aop/`;AgentTransport 位于 `aop/aiscan/transport`,但它是服务端与 +Agent 之间的内部运行时协议,不属于外部工具的公共业务 API。生成后必须同时运行独立 +module 编译和端到端测试。 + +该入口同时生成 Go、Connect-Go 与前端 TypeScript 文件;前端依赖尚未安装时,先在 +`web/frontend` 执行 `npm install`。 + +## TypeScript 接入 + +```ts +import { createClient } from '@connectrpc/connect' +import { createConnectTransport } from '@connectrpc/connect-web' +import { ChatService, ScanService, SessionService } from '@cyber/aop' + +const transport = createConnectTransport({ + baseUrl: window.location.origin, + useBinaryFormat: false, // 标准 Protobuf JSON,便于浏览器调试 +}) + +const aiscan = { + chat: createClient(ChatService, transport), + sessions: createClient(SessionService, transport), + scans: createClient(ScanService, transport), +} +``` + +一次完整调用: + +```ts +const sessionId = crypto.randomUUID() + +const opened = await aiscan.chat.openSession({ + requestId: crypto.randomUUID(), + sessionId, + participant: agentId, + title: 'demo', +}) +if (opened.outcome.case !== 'accepted') throw new Error(opened.outcome.value.message) + +let cursor = '' +const controller = new AbortController() + +void (async () => { + while (!controller.signal.aborted) { + try { + for await (const response of aiscan.chat.watchEvents( + { sessionId, afterCursor: cursor }, + { signal: controller.signal }, + )) { + const delivery = response.delivery + if (!delivery?.event) continue + cursor = delivery.cursor + + const event = delivery.event + if (event.payload.case === 'messageDelta') { + const delta = event.payload.value + if (delta.value.case === 'text') console.log(delta.value.value) + } + if (event.payload.case === 'turnEnded') { + console.log(event.payload.value.stopReason) + } + } + } catch { + // 使用最后确认的 delivery cursor 重连;服务端先订阅 live stream, + // 再从 SQLite replay,因此重连窗口不会丢 durable event。 + } + } +})() + +const turnId = crypto.randomUUID() +const run = await aiscan.chat.runTurn({ + requestId: crypto.randomUUID(), + sessionId, + turnId, + input: { + id: crypto.randomUUID(), + role: 'user', + name: 'operator', + content: [{ value: { case: 'text', value: { text: '你好' } } }], + }, +}) +if (run.outcome.case !== 'accepted') throw new Error(run.outcome.value.message) +``` + +`WatchEvents` 是 Connect 的 server-streaming RPC。浏览器端表现为生成 client 提供的 +异步迭代器,底层使用 HTTP response stream;不再使用 `EventSource`,也没有旧的 +`event: aop` / `data:` 文本帧。 + +## grpc-go 接入 + +原生 gRPC 客户端继续使用同一个 `aop.ChatServiceClient`,无需迁移业务调用: + +```go +import aop "github.com/chainreactors/aiscan/aop" + +conn, err := grpc.NewClient( + "127.0.0.1:8080", + grpc.WithTransportCredentials(insecure.NewCredentials()), +) +if err != nil { /* handle */ } +defer conn.Close() + +ctx := metadata.AppendToOutgoingContext( + context.Background(), + "authorization", "Bearer "+token, +) +client := aop.NewChatServiceClient(conn) + +opened, err := client.OpenSession(ctx, &aop.OpenSessionRequest{ + RequestId: "open-1", + SessionId: "demo", + Participant: agentID, +}) +``` + +仓库示例: + +```bash +# 原生 grpc-go +go run ./examples/aop-chat -addr 127.0.0.1:8080 -token dev-token -agent '' -prompt '你好' + +# 浏览器兼容的 Connect protobuf JSON +go run ./examples/web-chat -url http://127.0.0.1:8080 -token dev-token -agent '' -prompt '你好' + +# 拥有独立 go.mod、只使用公共 SDK 的外部工具形态 +cd examples/external-go-client +go run . -url http://127.0.0.1:8080 -token dev-token -agent '' -prompt '你好' +``` + +## Terminal WebSocket + +浏览器 terminal WebSocket 与 AgentTransport 共用生成的 +`aiscan.transport.TerminalFrame`。浏览器传输使用标准 ProtoJSON,AgentTransport 的 +gRPC bidi 使用 protobuf binary,Agent WebSocket 使用相同 message 的 ProtoJSON。 +`pkg/web/terminal` 是 `pty.Frame` 与生成类型之间唯一的 Go codec;浏览器不再发送一套 +手写 snake_case terminal DTO。 + +## 单向流式与重连语义 + +原 SSE 的单向输出由 `WatchEvents` 完整替代: + +1. client 提交 `session_id` 和可选 `after_cursor`。 +2. server 先注册 live subscription,再读取 SQLite backlog。 +3. 每个响应包含 `EventDelivery { cursor, event }`。 +4. client 处理成功后保存 `cursor`。 +5. 网络断开后以该 cursor 重建 `WatchEvents`。 + +`Event.seq` 是 AOP session 内的语义顺序;`EventDelivery.cursor` 是持久化位置。重连 +只能使用 cursor,不能拿 `seq` 代替。 + +`message_delta` 是低延迟增量,允许在背压下丢弃;完整 `message`、`turn_ended` 和 +生命周期事件是可靠结果。UI 应用完整 `message` 覆盖增量拼接结果,并以唯一的 +`turn_ended` 结束本轮。 + +## 请求幂等与错误 + +`OpenSession`、`RunTurn`、`CancelTurn`、`CloseSession` 以及 AIScan 的变更类 RPC +都要求非空 `request_id`: + +- 同一方法、同一请求体重试:返回 SQLite journal 中的原响应,不重复执行。 +- 同一 ID 对应不同方法或请求体:返回 `ALREADY_EXISTS` rejection。 +- 业务拒绝位于 response 的 `rejected` oneof;传输/认证故障使用 Connect/gRPC code。 + +浏览器认证可使用现有 HttpOnly 登录 cookie,也可发送: + +```text +Authorization: Bearer dev-token +``` + +## 全链路验收 + +```bash +go generate ./proto +go test ./... + +cd examples/external-go-client +GOWORK=off go test ./... + +cd ../../web/frontend +npm run build +npx playwright test +``` + +## ResetSession(`/clear`) + +前端 `/clear` 不再清空或覆盖原 session,而是调用原子的产品 RPC: + +```text +ResetSession(old_session) + ├─ 创建同 participant 的 clean session + ├─ 关闭 old session,reason = "reset" + └─ 返回 { previous, current } +``` + +旧 session 的消息和事件历史完整保留,只新增一次 `session_ended(reason=reset)`;新 +session 只包含自己的 `session_started` 生命周期,不继承旧 turn/message。相同 +`request_id` 重试不会重复创建或重复生命周期事件。 + +## 调试要点 + +- `UNAUTHENTICATED`:Bearer token/cookie 缺失或无效。 +- `ALREADY_EXISTS`:`request_id` 被不同请求复用。 +- `UNAVAILABLE`:participant 对应 Agent 未连接,或代理未正确转发 HTTP/2。 +- accepted 后没有最终答案:继续读取 `WatchEvents`;`RunTurn` accepted 只代表接收。 +- 重复事件:持久化并提交 delivery cursor。 +- 取消错轮次:调用 `CancelTurn` 时必须同时传准确的 `session_id` 和 `turn_id`。 +- `/api/chat/*` 返回 404:这是预期 cutover;改用生成的 Connect/gRPC client。 + +## 验证命令 + +```bash +# 独立 Go module 编译 +cd examples/external-go-client && go test ./... + +# 独立进程 → Connect HTTP → Hub → fake Agent → WatchEvents 全链路 +go test ./pkg/web -run TestExternalGoModuleConnectClientEndToEnd -count=1 + +# AIScan Web:CRUD、真实 LLM round-trip、独立 Go client、断线 cursor replay +cd web/frontend +npx playwright test e2e/aiscan-web.spec.ts \ + --grep "Chat Session CRUD|Chat LLM round-trip|External Go Connect client|Connect stream reconnect" +``` diff --git a/examples/aop-chat/client.go b/examples/aop-chat/client.go new file mode 100644 index 00000000..91561d48 --- /dev/null +++ b/examples/aop-chat/client.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "time" + + aop "github.com/chainreactors/aiscan/aop" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +func main() { + addr := flag.String("addr", "127.0.0.1:8080", "AIScan gRPC address") + token := flag.String("token", os.Getenv("AISCAN_WEB_TOKEN"), "AIScan access token") + agentID := flag.String("agent", "", "connected Agent ID") + prompt := flag.String("prompt", "你好", "natural-language prompt") + flag.Parse() + if *agentID == "" { + fmt.Fprintln(os.Stderr, "-agent is required") + os.Exit(2) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if *token != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+*token) + } + conn, err := grpc.NewClient(*addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fatal(err) + } + defer conn.Close() + client := aop.NewChatServiceClient(conn) + sessionID := fmt.Sprintf("example-%d", time.Now().UnixNano()) + opened, err := client.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open:" + sessionID, SessionId: sessionID, Participant: *agentID}) + if err != nil { + fatal(err) + } + if rejected := opened.GetRejected(); rejected != nil { + fatal(fmt.Errorf("open rejected: %s: %s", rejected.Code, rejected.Message)) + } + watch, err := client.WatchEvents(ctx, &aop.WatchEventsRequest{SessionId: sessionID}) + if err != nil { + fatal(err) + } + turnID := "turn:" + sessionID + run, err := client.RunTurn(ctx, &aop.RunTurnRequest{ + RequestId: turnID, SessionId: sessionID, TurnId: turnID, + Input: &aop.Message{Id: "input:" + sessionID, Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: *prompt}}}}}, + }) + if err != nil { + fatal(err) + } + if rejected := run.GetRejected(); rejected != nil { + fatal(fmt.Errorf("run rejected: %s: %s", rejected.Code, rejected.Message)) + } + for { + response, err := watch.Recv() + if err == io.EOF { + return + } + if err != nil { + fatal(err) + } + event := response.GetDelivery().GetEvent() + switch payload := event.Payload.(type) { + case *aop.Event_MessageDelta: + fmt.Print(payload.MessageDelta.GetText()) + case *aop.Event_Message: + fmt.Println() + for _, content := range payload.Message.Content { + fmt.Print(content.GetText().GetText()) + } + fmt.Println() + case *aop.Event_TurnEnded: + if event.TurnId == turnID { + if payload.TurnEnded.Error != nil { + fatal(fmt.Errorf("turn failed: %s: %s", payload.TurnEnded.Error.Code, payload.TurnEnded.Error.Message)) + } + return + } + case *aop.Event_Error: + fmt.Fprintln(os.Stderr, payload.Error.Message) + } + } +} + +func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) } diff --git a/examples/external-go-client/go.mod b/examples/external-go-client/go.mod new file mode 100644 index 00000000..6e67944d --- /dev/null +++ b/examples/external-go-client/go.mod @@ -0,0 +1,21 @@ +module example.com/aiscan-external-client + +go 1.25.7 + +require ( + connectrpc.com/connect v1.20.0 + github.com/chainreactors/aiscan v0.0.0 +) + +require ( + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +// Repository-local validation. An external project should remove this line and +// use a released AIScan version instead. +replace github.com/chainreactors/aiscan => ../.. diff --git a/examples/external-go-client/go.sum b/examples/external-go-client/go.sum new file mode 100644 index 00000000..8a58506d --- /dev/null +++ b/examples/external-go-client/go.sum @@ -0,0 +1,38 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/examples/external-go-client/main.go b/examples/external-go-client/main.go new file mode 100644 index 00000000..606eb391 --- /dev/null +++ b/examples/external-go-client/main.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "os" + "strings" + "time" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + aiscan "github.com/chainreactors/aiscan/aop/aiscan" +) + +func main() { + baseURL := flag.String("url", "http://127.0.0.1:8080", "AIScan Web base URL") + token := flag.String("token", os.Getenv("AISCAN_WEB_TOKEN"), "AIScan access token") + agentID := flag.String("agent", os.Getenv("AISCAN_AGENT_ID"), "connected Agent participant ID") + prompt := flag.String("prompt", "请用一句话介绍你的能力", "natural-language prompt") + timeout := flag.Duration("timeout", 10*time.Minute, "overall timeout") + flag.Parse() + if strings.TrimSpace(*agentID) == "" { + fatal(errors.New("-agent or AISCAN_AGENT_ID is required")) + } + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + client := aiscan.NewClient(http.DefaultClient, *baseURL, connect.WithProtoJSON()) + sessionID := newID("session") + opened, err := client.Chat.OpenSession(ctx, authenticated(*token, &aop.OpenSessionRequest{ + RequestId: newID("open"), SessionId: sessionID, Participant: *agentID, Title: "external Go client", + })) + if err != nil { + fatal(err) + } + if rejected := opened.Msg.GetRejected(); rejected != nil { + fatal(fmt.Errorf("OpenSession rejected: %s: %s", rejected.Code, rejected.Message)) + } + + type watchResult struct { + stream *connect.ServerStreamForClient[aop.WatchEventsResponse] + err error + } + watchCtx, stopWatch := context.WithCancel(ctx) + defer stopWatch() + watchReady := make(chan watchResult, 1) + go func() { + stream, watchErr := client.Chat.WatchEvents(watchCtx, authenticated(*token, &aop.WatchEventsRequest{SessionId: sessionID})) + watchReady <- watchResult{stream: stream, err: watchErr} + }() + + turnID := newID("turn") + run, err := client.Chat.RunTurn(ctx, authenticated(*token, &aop.RunTurnRequest{ + RequestId: newID("run"), SessionId: sessionID, TurnId: turnID, + Input: &aop.Message{Id: newID("message"), Role: "user", Name: "external-tool", Content: []*aop.Content{{ + Value: &aop.Content_Text{Text: &aop.TextContent{Text: *prompt}}, + }}}, + })) + if err != nil { + fatal(err) + } + if rejected := run.Msg.GetRejected(); rejected != nil { + fatal(fmt.Errorf("RunTurn rejected: %s: %s", rejected.Code, rejected.Message)) + } + + initial := <-watchReady + if initial.err != nil { + fatal(initial.err) + } + if err := receiveTurn(ctx, client, *token, sessionID, turnID, initial.stream); err != nil { + fatal(err) + } +} + +func receiveTurn( + ctx context.Context, + client *aiscan.Client, + token, sessionID, turnID string, + stream *connect.ServerStreamForClient[aop.WatchEventsResponse], +) error { + var cursor string + var sawDelta bool + retry := 250 * time.Millisecond + for { + for stream.Receive() { + delivery := stream.Msg().GetDelivery() + if delivery == nil || delivery.Event == nil { + continue + } + cursor = delivery.Cursor + event := delivery.Event + if event.TurnId != turnID { + continue + } + switch payload := event.Payload.(type) { + case *aop.Event_MessageDelta: + if text := payload.MessageDelta.GetText(); text != "" { + sawDelta = true + fmt.Print(text) + } + case *aop.Event_Message: + if !sawDelta && payload.Message.GetRole() == "assistant" { + fmt.Print(messageText(payload.Message)) + } + case *aop.Event_Error: + fmt.Fprintf(os.Stderr, "\nprotocol error: %s\n", payload.Error.GetMessage()) + case *aop.Event_TurnEnded: + ended := payload.TurnEnded + fmt.Printf("\nstop=%s cursor=%s session=%s turn=%s\n", ended.GetStopReason(), cursor, sessionID, turnID) + if failure := ended.GetError(); failure != nil { + return fmt.Errorf("turn failed: %s: %s", failure.Code, failure.Message) + } + return nil + } + } + if ctx.Err() != nil { + return ctx.Err() + } + if err := stream.Err(); err != nil { + fmt.Fprintf(os.Stderr, "watch disconnected: %v; resuming after cursor %s\n", err, cursor) + } + select { + case <-time.After(retry): + case <-ctx.Done(): + return ctx.Err() + } + retry = min(retry*2, 5*time.Second) + next, err := client.Chat.WatchEvents(ctx, authenticated(token, &aop.WatchEventsRequest{ + SessionId: sessionID, AfterCursor: cursor, + })) + if err != nil { + continue + } + stream = next + } +} + +func authenticated[T any](token string, message *T) *connect.Request[T] { + request := connect.NewRequest(message) + if token != "" { + request.Header().Set("Authorization", "Bearer "+token) + } + return request +} + +func messageText(message *aop.Message) string { + var text strings.Builder + for _, content := range message.GetContent() { + text.WriteString(content.GetText().GetText()) + } + return text.String() +} + +func newID(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) +} diff --git a/examples/web-chat/client.go b/examples/web-chat/client.go new file mode 100644 index 00000000..681c95fb --- /dev/null +++ b/examples/web-chat/client.go @@ -0,0 +1,303 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + "github.com/chainreactors/aiscan/aop/aopconnect" +) + +// Client demonstrates browser-compatible ConnectRPC. The public chat surface +// is still exactly the six generated aop.ChatService methods; ListAgents is the +// existing product REST endpoint used only to discover a participant. +type Client struct { + baseURL string + token string + http *http.Client + chat aopconnect.ChatServiceClient +} + +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + Busy bool `json:"busy"` + Status AgentStatus `json:"status"` +} + +type AgentStatus struct { + Provider string `json:"provider"` + Model string `json:"model"` + ConfigError string `json:"config_error"` +} + +type AskResult struct { + SessionID string + AgentID string + TurnID string + Output string + Stop string + Usage *aop.TokenUsage +} + +func NewClient(baseURL, token string) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + u, err := url.Parse(baseURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("invalid AIScan Web URL %q", baseURL) + } + httpClient := &http.Client{} + return &Client{ + baseURL: baseURL, + token: strings.TrimSpace(token), + http: httpClient, + chat: aopconnect.NewChatServiceClient(httpClient, baseURL, connect.WithProtoJSON()), + }, nil +} + +func requestWithToken[T any](token string, message *T) *connect.Request[T] { + request := connect.NewRequest(message) + if token != "" { + request.Header().Set("Authorization", "Bearer "+token) + } + return request +} + +func (c *Client) OpenSession(ctx context.Context, request *aop.OpenSessionRequest) (*aop.OpenSessionResponse, error) { + response, err := c.chat.OpenSession(ctx, requestWithToken(c.token, request)) + if err != nil { + return nil, err + } + return response.Msg, nil +} + +func (c *Client) RunTurn(ctx context.Context, request *aop.RunTurnRequest) (*aop.RunTurnResponse, error) { + response, err := c.chat.RunTurn(ctx, requestWithToken(c.token, request)) + if err != nil { + return nil, err + } + return response.Msg, nil +} + +func (c *Client) CancelTurn(ctx context.Context, request *aop.CancelTurnRequest) (*aop.CancelTurnResponse, error) { + response, err := c.chat.CancelTurn(ctx, requestWithToken(c.token, request)) + if err != nil { + return nil, err + } + return response.Msg, nil +} + +func (c *Client) CloseSession(ctx context.Context, request *aop.CloseSessionRequest) (*aop.CloseSessionResponse, error) { + response, err := c.chat.CloseSession(ctx, requestWithToken(c.token, request)) + if err != nil { + return nil, err + } + return response.Msg, nil +} + +func (c *Client) WatchEvents(ctx context.Context, request *aop.WatchEventsRequest) (*connect.ServerStreamForClient[aop.WatchEventsResponse], error) { + return c.chat.WatchEvents(ctx, requestWithToken(c.token, request)) +} + +func (c *Client) ListEvents(ctx context.Context, request *aop.ListEventsRequest) (*aop.ListEventsResponse, error) { + response, err := c.chat.ListEvents(ctx, requestWithToken(c.token, request)) + if err != nil { + return nil, err + } + return response.Msg, nil +} + +func (c *Client) ListAgents(ctx context.Context) ([]Agent, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/agents", nil) + if err != nil { + return nil, err + } + if c.token != "" { + request.Header.Set("Authorization", "Bearer "+c.token) + } + response, err := c.http.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + return nil, fmt.Errorf("HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(data))) + } + var agents []Agent + if err := json.NewDecoder(response.Body).Decode(&agents); err != nil { + return nil, err + } + return agents, nil +} + +// Ask is convenience only: it composes OpenSession, WatchEvents, RunTurn and +// the terminal-event loop without introducing another wire protocol. +func (c *Client) Ask(ctx context.Context, prompt, requestedAgentID string, onDelta func(string)) (*AskResult, error) { + if strings.TrimSpace(prompt) == "" { + return nil, errors.New("prompt is required") + } + agents, err := c.ListAgents(ctx) + if err != nil { + return nil, fmt.Errorf("list agents: %w", err) + } + agent, err := pickAgent(agents, requestedAgentID) + if err != nil { + return nil, err + } + sessionID := "session-" + newID() + opened, err := c.OpenSession(ctx, &aop.OpenSessionRequest{ + RequestId: "open-" + newID(), SessionId: sessionID, Participant: agent.ID, + Title: "API: " + truncateRunes(prompt, 48), + }) + if err != nil { + return nil, fmt.Errorf("open session: %w", err) + } + if rejected := opened.GetRejected(); rejected != nil { + return nil, rejectionError("open session", rejected) + } + + // A server-streaming Connect call may wait for its first response before the + // client call returns. Start it concurrently with RunTurn; the server's + // subscribe-before-replay implementation makes this race-free in both orders. + type watchResult struct { + stream *connect.ServerStreamForClient[aop.WatchEventsResponse] + err error + } + watchCtx, stopWatch := context.WithCancel(ctx) + defer stopWatch() + watchReady := make(chan watchResult, 1) + go func() { + stream, watchErr := c.WatchEvents(watchCtx, &aop.WatchEventsRequest{SessionId: sessionID}) + watchReady <- watchResult{stream: stream, err: watchErr} + }() + turnID := "turn-" + newID() + run, err := c.RunTurn(ctx, &aop.RunTurnRequest{ + RequestId: "run-" + newID(), SessionId: sessionID, TurnId: turnID, + Input: &aop.Message{Id: "message-" + newID(), Role: "user", Content: []*aop.Content{{ + Value: &aop.Content_Text{Text: &aop.TextContent{Text: prompt}}, + }}}, + }) + if err != nil { + return nil, fmt.Errorf("run turn: %w", err) + } + if rejected := run.GetRejected(); rejected != nil { + return nil, rejectionError("run turn", rejected) + } + var watch *connect.ServerStreamForClient[aop.WatchEventsResponse] + select { + case result := <-watchReady: + if result.err != nil { + return nil, fmt.Errorf("watch events: %w", result.err) + } + watch = result.stream + case <-ctx.Done(): + return nil, ctx.Err() + } + + result := &AskResult{SessionID: sessionID, AgentID: agent.ID, TurnID: turnID} + var deltas strings.Builder + for watch.Receive() { + event := watch.Msg().GetDelivery().GetEvent() + if event == nil || event.TurnId != turnID { + continue + } + switch payload := event.Payload.(type) { + case *aop.Event_MessageDelta: + text := payload.MessageDelta.GetText() + deltas.WriteString(text) + if onDelta != nil && text != "" { + onDelta(text) + } + case *aop.Event_Message: + if payload.Message.GetRole() == "assistant" { + if text := messageText(payload.Message); text != "" { + result.Output = text + } + } + case *aop.Event_TurnEnded: + result.Stop = payload.TurnEnded.GetStopReason() + result.Usage = payload.TurnEnded.GetUsage() + if failure := payload.TurnEnded.GetError(); failure != nil { + return nil, fmt.Errorf("turn failed: %s: %s", failure.Code, failure.Message) + } + if result.Output == "" { + result.Output = deltas.String() + } + return result, nil + case *aop.Event_Error: + if payload.Error != nil { + return nil, fmt.Errorf("turn error: %s", payload.Error.Message) + } + } + } + if err := watch.Err(); err != nil { + return nil, err + } + return nil, errors.New("event stream closed before turn_ended") +} + +func rejectionError(operation string, rejected *aop.Rejection) error { + return fmt.Errorf("%s rejected: %s: %s", operation, rejected.GetCode(), rejected.GetMessage()) +} + +func messageText(message *aop.Message) string { + var text strings.Builder + for _, content := range message.GetContent() { + text.WriteString(content.GetText().GetText()) + } + return text.String() +} + +func pickAgent(agents []Agent, requestedID string) (Agent, error) { + if requestedID != "" { + for _, agent := range agents { + if agent.ID == requestedID { + if agent.Status.Provider == "" { + return Agent{}, fmt.Errorf("agent %q has no LLM provider", requestedID) + } + return agent, nil + } + } + return Agent{}, fmt.Errorf("agent %q is not connected", requestedID) + } + var busy *Agent + for index := range agents { + if agents[index].Status.Provider == "" { + continue + } + if !agents[index].Busy { + return agents[index], nil + } + if busy == nil { + busy = &agents[index] + } + } + if busy != nil { + return *busy, nil + } + return Agent{}, errors.New("no connected LLM-capable agent") +} + +func newID() string { + value := make([]byte, 16) + _, _ = rand.Read(value) + return hex.EncodeToString(value) +} + +func truncateRunes(value string, limit int) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) + "..." +} diff --git a/examples/web-chat/client_test.go b/examples/web-chat/client_test.go new file mode 100644 index 00000000..db73ff9b --- /dev/null +++ b/examples/web-chat/client_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + "github.com/chainreactors/aiscan/aop/aopconnect" +) + +type exampleChatHandler struct { + aopconnect.UnimplementedChatServiceHandler + t *testing.T + token string + ready chan struct{} + events chan *aop.Event + once sync.Once +} + +func (h *exampleChatHandler) authenticate(header http.Header) { + h.t.Helper() + if got := header.Get("Authorization"); got != "Bearer "+h.token { + h.t.Errorf("Authorization = %q", got) + } +} + +func (h *exampleChatHandler) OpenSession(_ context.Context, request *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { + h.authenticate(request.Header()) + return connect.NewResponse(&aop.OpenSessionResponse{RequestId: request.Msg.RequestId, Outcome: &aop.OpenSessionResponse_Accepted{Accepted: &aop.Session{ + Id: request.Msg.SessionId, Participant: request.Msg.Participant, State: "open", Title: request.Msg.Title, + }}}), nil +} + +func (h *exampleChatHandler) RunTurn(ctx context.Context, request *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { + h.authenticate(request.Header()) + select { + case <-h.ready: + case <-ctx.Done(): + return nil, ctx.Err() + } + if request.Msg.Input.GetRole() != "user" || request.Msg.Input.GetId() == "" { + h.t.Errorf("RunTurn input = %v", request.Msg.Input) + } + turnID := request.Msg.TurnId + h.events <- &aop.Event{SessionId: request.Msg.SessionId, TurnId: turnID, Payload: &aop.Event_MessageDelta{MessageDelta: &aop.MessageDelta{ + MessageId: "assistant-1", Value: &aop.MessageDelta_Text{Text: "hello"}, + }}} + h.events <- &aop.Event{SessionId: request.Msg.SessionId, TurnId: turnID, Payload: &aop.Event_Message{Message: &aop.Message{ + Id: "assistant-1", Role: "assistant", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}, + }}} + h.events <- &aop.Event{SessionId: request.Msg.SessionId, TurnId: turnID, Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{ + StopReason: "completed", Usage: &aop.TokenUsage{TotalTokens: 3}, + }}} + return connect.NewResponse(&aop.RunTurnResponse{RequestId: request.Msg.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ + SessionId: request.Msg.SessionId, TurnId: turnID, State: "running", + }}}), nil +} + +func (h *exampleChatHandler) WatchEvents(ctx context.Context, request *connect.Request[aop.WatchEventsRequest], stream *connect.ServerStream[aop.WatchEventsResponse]) error { + h.authenticate(request.Header()) + h.once.Do(func() { close(h.ready) }) + cursor := 0 + for { + select { + case event := <-h.events: + cursor++ + if err := stream.Send(&aop.WatchEventsResponse{Delivery: &aop.EventDelivery{Cursor: string(rune('0' + cursor)), Event: event}}); err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func TestAskUsesConnectChatServiceEndToEnd(t *testing.T) { + const token = "test-token" + handler := &exampleChatHandler{t: t, token: token, ready: make(chan struct{}), events: make(chan *aop.Event, 8)} + path, connectHandler := aopconnect.NewChatServiceHandler(handler) + mux := http.NewServeMux() + mux.Handle(path, connectHandler) + mux.HandleFunc("GET /api/agents", func(w http.ResponseWriter, request *http.Request) { + handler.authenticate(request.Header) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"agent-1","name":"worker","status":{"provider":"openai","model":"test"}}]`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client, err := NewClient(server.URL, token) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var streamed strings.Builder + result, err := client.Ask(ctx, "hello", "", func(delta string) { streamed.WriteString(delta) }) + if err != nil { + t.Fatal(err) + } + if result.Output != "hello" || streamed.String() != "hello" || result.Stop != "completed" { + t.Fatalf("result = %+v streamed=%q", result, streamed.String()) + } + if result.SessionID == "" || result.TurnID == "" || result.AgentID != "agent-1" || result.Usage.GetTotalTokens() != 3 { + t.Fatalf("result identity/usage = %+v", result) + } +} + +func TestAskRequiresLLMCapableAgent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"scanner-only","status":{}}]`)) + })) + defer server.Close() + client, err := NewClient(server.URL, "") + if err != nil { + t.Fatal(err) + } + _, err = client.Ask(context.Background(), "hello", "", nil) + if err == nil || !strings.Contains(err.Error(), "no connected LLM-capable agent") { + t.Fatalf("error = %v", err) + } +} diff --git a/examples/web-chat/main.go b/examples/web-chat/main.go new file mode 100644 index 00000000..dfcd0914 --- /dev/null +++ b/examples/web-chat/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" +) + +func main() { + baseURL := flag.String("url", envOr("AISCAN_WEB_URL", "http://127.0.0.1:8080"), "AIScan Web base URL") + token := flag.String("token", os.Getenv("AISCAN_WEB_TOKEN"), "Web access token (or AISCAN_WEB_TOKEN)") + agentID := flag.String("agent", "", "connected agent ID; empty selects an LLM-capable agent") + prompt := flag.String("prompt", "", "natural-language input") + timeout := flag.Duration("timeout", 10*time.Minute, "maximum time to wait for turn_ended") + stream := flag.Bool("stream", false, "print text deltas while the agent runs") + flag.Parse() + + input := strings.TrimSpace(*prompt) + if input == "" { + input = strings.TrimSpace(strings.Join(flag.Args(), " ")) + } + if input == "" { + fmt.Fprintln(os.Stderr, "usage: go run ./examples/web-chat -prompt \"summarize the authorized target\"") + os.Exit(2) + } + client, err := NewClient(*baseURL, *token) + if err != nil { + fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + printedDelta := false + var onDelta func(string) + if *stream { + onDelta = func(delta string) { + printedDelta = true + fmt.Print(delta) + } + } + result, err := client.Ask(ctx, input, *agentID, onDelta) + if err != nil { + fatal(err) + } + if printedDelta { + fmt.Println() + } else { + fmt.Println(result.Output) + } + fmt.Fprintf(os.Stderr, "session=%s agent=%s stop=%s\n", result.SessionID, result.AgentID, result.Stop) +} + +func envOr(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) +} diff --git a/go.mod b/go.mod index 5ea1892c..7cb6ee7d 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/chainreactors/tui/console v0.0.0-20260712082522-2ba36ad7841f github.com/chainreactors/tui/readline v0.0.0-20260723062039-ed89e758c21b github.com/chainreactors/utils v0.0.0-20260711153742-f3d210a5fa9d - github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 + github.com/chainreactors/utils/mitmproxy v0.0.0-20260722180147-5b1816060721 github.com/chainreactors/utils/parsers v0.0.3 github.com/chainreactors/utils/pty v0.0.0-20260722180147-5b1816060721 github.com/chainreactors/zombie v1.3.0 @@ -51,6 +51,13 @@ require ( modernc.org/sqlite v1.40.1 ) +require ( + connectrpc.com/connect v1.20.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + require ( go.yaml.in/yaml/v2 v2.4.2 // indirect sigs.k8s.io/yaml v1.6.0 diff --git a/go.sum b/go.sum index 4a9111d4..f42a41db 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -147,6 +149,8 @@ github.com/bits-and-blooms/bloom/v3 v3.5.0 h1:AKDvi1V3xJCmSR6QhcBfHbCN4Vf8FfxeWk github.com/bits-and-blooms/bloom/v3 v3.5.0/go.mod h1:Y8vrn7nk1tPIlmLtW2ZPV+W7StdVMor6bC1xgpjMZFs= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= +github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= +github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0= github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= @@ -218,6 +222,8 @@ github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863 h1:41tvJz github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863/go.mod h1:xvvWMcU9Fcht6GR1cc9ceAZ3/Hl2HrkoRzpeyOzx1rQ= github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 h1:r6UUUUQt4r/0SL6vgrwoq6ynidAkN3auSZsvzZ5BBRE= github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M= +github.com/chainreactors/utils/mitmproxy v0.0.0-20260722180147-5b1816060721 h1:BJh043izz46BCpNN3SJBGwEQDWW9SrKLGUFrbP5+/H0= +github.com/chainreactors/utils/mitmproxy v0.0.0-20260722180147-5b1816060721/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M= github.com/chainreactors/utils/parsers v0.0.3 h1:3ld7xG5TSvzikVOCkQHjqjHO3otjODwSwHQDkMKbu5o= github.com/chainreactors/utils/parsers v0.0.3/go.mod h1:bE/znJWt08n9QOORWsWu0ggB8GWfOg3+dfUMMITmwV4= github.com/chainreactors/utils/pty v0.0.0-20260722180147-5b1816060721 h1:gxkedbTvFEFTtel7XJEPMVh1iznfD+91woPkGBXZMNk= @@ -818,6 +824,7 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagernet/sing v0.7.6 h1:6LBfDH+aI/26J3r9UHlaxTNjJeMhBpU/wrk0JKDZYI4= github.com/sagernet/sing v0.7.6/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= @@ -1035,6 +1042,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= +go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1157,6 +1166,7 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= @@ -1493,7 +1503,13 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1521,6 +1537,10 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1537,6 +1557,10 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/headless/action_types.go b/pkg/headless/action_types.go index 4d9f69a8..494c95ee 100644 --- a/pkg/headless/action_types.go +++ b/pkg/headless/action_types.go @@ -12,64 +12,64 @@ import ( type ActionType int8 const ( - ActionNavigate ActionType = iota + 1 // navigate to a URL - ActionScript // execute JavaScript - ActionClick // left-click an element - ActionRightClick // right-click an element - ActionTextInput // type text into an input - ActionScreenshot // capture screenshot - ActionTimeInput // set a time input value - ActionSelectInput // select an option - ActionFilesInput // set file input - ActionWaitDOM // wait for DOMContentLoaded - ActionWaitFCP // wait for First Contentful Paint - ActionWaitFMP // wait for First Meaningful Paint - ActionWaitIdle // wait for network idle - ActionWaitLoad // wait for page load - ActionWaitStable // wait for page stability - ActionGetResource // fetch a sub-resource - ActionExtract // extract element content - ActionSetMethod // override request method - ActionAddHeader // append a request header - ActionSetHeader // replace a request header - ActionDeleteHeader // remove a request header - ActionSetBody // override request body - ActionWaitEvent // wait for a DOM/CDP event - ActionKeyboard // press a key combination - ActionDebug // log debug info - ActionSleep // sleep for duration - ActionWaitVisible // wait for element visibility - ActionDialog // handle JS dialog (deprecated, use waitdialog) - ActionWaitDialog // wait for JS dialog and capture type+message + ActionNavigate ActionType = iota + 1 // navigate to a URL + ActionScript // execute JavaScript + ActionClick // left-click an element + ActionRightClick // right-click an element + ActionTextInput // type text into an input + ActionScreenshot // capture screenshot + ActionTimeInput // set a time input value + ActionSelectInput // select an option + ActionFilesInput // set file input + ActionWaitDOM // wait for DOMContentLoaded + ActionWaitFCP // wait for First Contentful Paint + ActionWaitFMP // wait for First Meaningful Paint + ActionWaitIdle // wait for network idle + ActionWaitLoad // wait for page load + ActionWaitStable // wait for page stability + ActionGetResource // fetch a sub-resource + ActionExtract // extract element content + ActionSetMethod // override request method + ActionAddHeader // append a request header + ActionSetHeader // replace a request header + ActionDeleteHeader // remove a request header + ActionSetBody // override request body + ActionWaitEvent // wait for a DOM/CDP event + ActionKeyboard // press a key combination + ActionDebug // log debug info + ActionSleep // sleep for duration + ActionWaitVisible // wait for element visibility + ActionDialog // handle JS dialog (deprecated, use waitdialog) + ActionWaitDialog // wait for JS dialog and capture type+message ) var actionTypeNames = map[ActionType]string{ - ActionNavigate: "navigate", - ActionScript: "script", - ActionClick: "click", - ActionRightClick: "rightclick", - ActionTextInput: "text", - ActionScreenshot: "screenshot", - ActionTimeInput: "time", - ActionSelectInput: "select", - ActionFilesInput: "files", - ActionWaitDOM: "waitdom", - ActionWaitFCP: "waitfcp", - ActionWaitFMP: "waitfmp", - ActionWaitIdle: "waitidle", - ActionWaitLoad: "waitload", - ActionWaitStable: "waitstable", - ActionGetResource: "getresource", - ActionExtract: "extract", - ActionSetMethod: "setmethod", - ActionAddHeader: "addheader", - ActionSetHeader: "setheader", + ActionNavigate: "navigate", + ActionScript: "script", + ActionClick: "click", + ActionRightClick: "rightclick", + ActionTextInput: "text", + ActionScreenshot: "screenshot", + ActionTimeInput: "time", + ActionSelectInput: "select", + ActionFilesInput: "files", + ActionWaitDOM: "waitdom", + ActionWaitFCP: "waitfcp", + ActionWaitFMP: "waitfmp", + ActionWaitIdle: "waitidle", + ActionWaitLoad: "waitload", + ActionWaitStable: "waitstable", + ActionGetResource: "getresource", + ActionExtract: "extract", + ActionSetMethod: "setmethod", + ActionAddHeader: "addheader", + ActionSetHeader: "setheader", ActionDeleteHeader: "deleteheader", - ActionSetBody: "setbody", - ActionWaitEvent: "waitevent", - ActionKeyboard: "keyboard", - ActionDebug: "debug", - ActionSleep: "sleep", + ActionSetBody: "setbody", + ActionWaitEvent: "waitevent", + ActionKeyboard: "keyboard", + ActionDebug: "debug", + ActionSleep: "sleep", ActionWaitVisible: "waitvisible", ActionDialog: "dialog", ActionWaitDialog: "waitdialog", diff --git a/pkg/headless/engine.go b/pkg/headless/engine.go index 62b6a0d0..1ad16d41 100644 --- a/pkg/headless/engine.go +++ b/pkg/headless/engine.go @@ -31,12 +31,12 @@ type Engine struct { // HeadlessOptions configures the headless engine. type HeadlessOptions struct { - Proxy string - UserAgent string - Headers map[string]string - ShowBrowser bool - PageTimeout int // seconds, default 30 - DisableCookie bool + Proxy string + UserAgent string + Headers map[string]string + ShowBrowser bool + PageTimeout int // seconds, default 30 + DisableCookie bool } // EngineOption configures Engine creation. diff --git a/pkg/headless/engine_test.go b/pkg/headless/engine_test.go index 6791f16b..94309b76 100644 --- a/pkg/headless/engine_test.go +++ b/pkg/headless/engine_test.go @@ -137,8 +137,8 @@ func TestCompileAllNucleiTemplates(t *testing.T) { // variables defined by the HTTP section that doesn't exist in our headless-only engine. // These are expected to fail compilation and are excluded from the compile test. skipCompile := map[string]bool{ - "CVE-2025-25062.yaml": true, // mixed HTTP+headless, variables from HTTP section - "retool-dom-xss.yaml": true, // DSL matcher references runtime variables + "CVE-2025-25062.yaml": true, // mixed HTTP+headless, variables from HTTP section + "retool-dom-xss.yaml": true, // DSL matcher references runtime variables } templates := findAllTemplates(t) diff --git a/pkg/headless/http_client.go b/pkg/headless/http_client.go index b790b365..0b84becc 100644 --- a/pkg/headless/http_client.go +++ b/pkg/headless/http_client.go @@ -26,14 +26,14 @@ func newHTTPClient(proxy string, timeout time.Duration) *http.Client { InsecureSkipVerify: true, MinVersion: tls.VersionTLS10, }, - DialContext: (&net.Dialer{Timeout: timeout}).DialContext, - MaxIdleConns: 500, - MaxIdleConnsPerHost: 500, - MaxConnsPerHost: 500, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - DisableKeepAlives: false, - ForceAttemptHTTP2: true, + DialContext: (&net.Dialer{Timeout: timeout}).DialContext, + MaxIdleConns: 500, + MaxIdleConnsPerHost: 500, + MaxConnsPerHost: 500, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + DisableKeepAlives: false, + ForceAttemptHTTP2: true, } if proxy != "" { diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 9dc16ea9..0474a1ea 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -12,7 +12,7 @@ import ( "github.com/chainreactors/aiscan/agent" inboxpkg "github.com/chainreactors/aiscan/agent/inbox" tmuxpkg "github.com/chainreactors/aiscan/agent/tmux" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" @@ -35,8 +35,8 @@ type AgentRuntime struct { systemPrompt string option *cfg.Option config agent.Config - bus *eventbus.Bus[aop.Event] - kernelBus *eventbus.Bus[aop.Event] + bus *eventbus.Bus[*aop.Event] + kernelBus *eventbus.Bus[*aop.Event] sessionEvents *sessionEmitter output *tui.AgentOutput configFile string @@ -192,12 +192,12 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L } } - publicBus := eventbus.New[aop.Event]() + publicBus := eventbus.New[*aop.Event]() if rt.output != nil { publicBus.Subscribe(rt.output.HandleEvent) } rt.bus = publicBus - rt.kernelBus = eventbus.New[aop.Event]() + rt.kernelBus = eventbus.New[*aop.Event]() rt.sessionEvents = newSessionEmitter(publicBus) rt.kernelBus.Subscribe(rt.sessionEvents.emit) @@ -219,15 +219,16 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L } rt.config = agent.Config{ - Provider: rt.app.Provider, - Tools: rt.app.Commands, - Model: rt.app.ProviderConfig.Model, - MaxTokens: rt.app.ProviderConfig.MaxTokens, - ContextWindow: rt.app.ProviderConfig.ContextWindow, - Logger: logger, - CacheRetention: agent.CacheShort, - Bus: rt.kernelBus, - Hooks: rt.app.Hooks, + Provider: rt.app.Provider, + Tools: rt.app.Commands, + Model: rt.app.ProviderConfig.Model, + MaxTokens: rt.app.ProviderConfig.MaxTokens, + ContextWindow: rt.app.ProviderConfig.ContextWindow, + Logger: logger, + CacheRetention: agent.CacheShort, + Bus: rt.kernelBus, + Hooks: rt.app.Hooks, + CaptureProviderFrames: option.CaptureProviderFrames, } if option.SaveSession { @@ -471,7 +472,7 @@ func runOneShotMode(ctx context.Context, option *cfg.Option, logger telemetry.Lo return err } run, err := session.Run(ctx, RunInput{ - Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}, + Content: []*aop.Content{aop.Text(task)}, MaxTurns: rt.config.MaxTurns, EvalCriteria: option.EvalCriteria, EvalMaxRounds: option.EvalMaxRetries, }) if err != nil { diff --git a/pkg/runner/runtime_protocol.go b/pkg/runner/runtime_protocol.go index aeb9a4ec..6c1ac1c3 100644 --- a/pkg/runner/runtime_protocol.go +++ b/pkg/runner/runtime_protocol.go @@ -3,107 +3,157 @@ package runner import ( "context" "encoding/json" - "fmt" "strings" - "github.com/chainreactors/aiscan/pkg/webproto" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + protobuf "google.golang.org/protobuf/proto" ) -func RuntimeCommandSpecs() []webproto.CommandSpec { - return []webproto.CommandSpec{ +const AIScanRunOptionsNamespace = "io.chainreactors.aiscan.run" + +func RuntimeCommandSpecs() []*transport.CommandSpec { + return []*transport.CommandSpec{ {Name: "/status", Description: "Show Runtime session and provider status"}, {Name: "/clear", Description: "Clear the current Agent context"}, {Name: "/compact", Usage: "/compact [focus]", Description: "Compact the current Agent context"}, } } -// HandleProtocol handles the transport-neutral Agent Runtime control frames. -// The caller owns framing and I/O; AgentRuntime owns all Session and Run state. -func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { - if rt == nil || send == nil { - return false +func (rt *AgentRuntime) OpenAOPSession(req *aop.OpenSessionRequest) *aop.OpenSessionResponse { + response := &aop.OpenSessionResponse{} + if req != nil { + response.RequestId = req.RequestId } - sendError := func(turnID, taskID string, err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - send(webproto.Message{Type: webproto.TypeError, TurnID: turnID, TaskID: taskID, Payload: payload}) + if rt == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { + response.Outcome = &aop.OpenSessionResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id is required")} + return response } + session, err := rt.EnsureSession(SessionOptions{ID: req.SessionId, ParentSessionID: req.ParentSessionId, ParentToolCallID: req.ParentToolCallId}) + if err != nil { + response.Outcome = &aop.OpenSessionResponse_Rejected{Rejected: rejection("FAILED_PRECONDITION", err.Error())} + return response + } + response.Outcome = &aop.OpenSessionResponse_Accepted{Accepted: &aop.Session{Id: session.ID(), State: "open", Participant: req.Participant, Title: req.Title}} + return response +} - switch msg.Type { - case webproto.TypeSessionOpen: - var payload webproto.SessionOpenPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendError("", "", err) - return true - } - session, err := rt.EnsureSession(SessionOptions{ - ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, - }) - if err != nil { - sendError("", "", err) - return true - } - encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: session.ID()}) - send(webproto.Message{Type: webproto.TypeSessionOpened, Payload: encoded}) - return true - - case webproto.TypeSessionClose: - var payload webproto.SessionLifecyclePayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendError("", "", err) - return true - } - reason := SessionCloseReason(payload.Reason) - if err := rt.CloseSession(ctx, payload.SessionID, reason); err != nil { - sendError("", "", err) - return true +func (rt *AgentRuntime) RunAOPTurn(ctx context.Context, req *aop.RunTurnRequest) *aop.RunTurnResponse { + response := &aop.RunTurnResponse{} + if req != nil { + response.RequestId = req.RequestId + } + if rt == nil || req == nil || (!req.ContinueSession && req.Input == nil) || strings.TrimSpace(req.SessionId) == "" || strings.TrimSpace(req.TurnId) == "" { + response.Outcome = &aop.RunTurnResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id, turn_id, and input are required unless continue_session is true")} + return response + } + options := new(transport.RunOptions) + for _, extension := range req.Extensions { + if extension.GetNamespace() == AIScanRunOptionsNamespace { + if err := aop.DecodeProtoJSON(extension.GetValue(), options); err != nil { + response.Outcome = &aop.RunTurnResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "invalid AIScan run options: "+err.Error())} + return response + } + break } - encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: string(reason)}) - send(webproto.Message{Type: webproto.TypeSessionClosed, Payload: encoded}) - return true + } + var message *aop.Message + if req.Input != nil { + message = protobuf.Clone(req.Input).(*aop.Message) + } + _, err := rt.RunSession(ctx, req.SessionId, RunInput{ + TurnID: req.TurnId, Message: message, Continue: req.ContinueSession, + MaxTurns: int(req.MaxTurns), EvalCriteria: options.EvalCriteria, EvalMaxRounds: int(options.EvalMaxRounds), + }) + if err != nil { + response.Outcome = &aop.RunTurnResponse_Rejected{Rejected: rejection("FAILED_PRECONDITION", err.Error())} + return response + } + response.Outcome = &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: req.SessionId, TurnId: req.TurnId, State: "running"}} + return response +} - case webproto.TypeRun: - if strings.TrimSpace(msg.TurnID) == "" { - sendError("", "", fmt.Errorf("run turn_id is required")) - return true - } - var payload webproto.RunPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendError(msg.TurnID, "", err) - return true - } - _, err := rt.RunSession(ctx, payload.SessionID, RunInput{ - TurnID: msg.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, - EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, Continue: payload.Continue, - }) - if err != nil { - sendError(msg.TurnID, "", err) - } - return true +func (rt *AgentRuntime) CancelAOPTurn(req *aop.CancelTurnRequest) *aop.CancelTurnResponse { + response := &aop.CancelTurnResponse{} + if req != nil { + response.RequestId = req.RequestId + } + if rt == nil || req == nil || strings.TrimSpace(req.SessionId) == "" || strings.TrimSpace(req.TurnId) == "" { + response.Outcome = &aop.CancelTurnResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id and turn_id are required")} + return response + } + if err := rt.CancelSessionRun(req.SessionId, req.TurnId); err != nil { + response.Outcome = &aop.CancelTurnResponse_Rejected{Rejected: rejection("NOT_FOUND", err.Error())} + return response + } + response.Outcome = &aop.CancelTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: req.SessionId, TurnId: req.TurnId, State: "canceled"}} + return response +} - case webproto.TypeRunCancel: - if err := rt.CancelRun(msg.TurnID); err != nil { - sendError(msg.TurnID, "", err) - } - return true +func (rt *AgentRuntime) CloseAOPSession(ctx context.Context, req *aop.CloseSessionRequest) *aop.CloseSessionResponse { + response := &aop.CloseSessionResponse{} + if req != nil { + response.RequestId = req.RequestId + } + if rt == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { + response.Outcome = &aop.CloseSessionResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id is required")} + return response + } + if err := rt.CloseSession(ctx, req.SessionId, SessionCloseReason(req.Reason)); err != nil { + response.Outcome = &aop.CloseSessionResponse_Rejected{Rejected: rejection("FAILED_PRECONDITION", err.Error())} + return response + } + response.Outcome = &aop.CloseSessionResponse_Accepted{Accepted: &aop.Session{Id: req.SessionId, State: "closed"}} + return response +} - case webproto.TypeCommand: - var payload webproto.CommandPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendError("", msg.TaskID, err) - return true +// HandleServerFrame is the generated-message control loop shared by stdio and +// other transports that host an AgentRuntime directly. +func (rt *AgentRuntime) HandleServerFrame(ctx context.Context, frame *transport.ServerFrame, send func(*transport.AgentFrame)) bool { + if rt == nil || frame == nil || send == nil { + return false + } + correlation := frame.CorrelationId + switch payload := frame.Payload.(type) { + case *transport.ServerFrame_OpenSession: + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_OpenSession{OpenSession: rt.OpenAOPSession(payload.OpenSession)}}) + case *transport.ServerFrame_RunTurn: + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_RunTurn{RunTurn: rt.RunAOPTurn(ctx, payload.RunTurn)}}) + case *transport.ServerFrame_CancelTurn: + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CancelTurn{CancelTurn: rt.CancelAOPTurn(payload.CancelTurn)}}) + case *transport.ServerFrame_CloseSession: + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CloseSession{CloseSession: rt.CloseAOPSession(ctx, payload.CloseSession)}}) + case *transport.ServerFrame_Command: + request := payload.Command + if request == nil || strings.TrimSpace(request.Line) == "" { + send(operationError(correlation, request.GetTaskId(), "command line is required")) + break } rt.operations.Add(1) go func() { defer rt.operations.Done() - result, err := rt.CommandSession(ctx, payload.SessionID, payload.Line) + result, err := rt.CommandSession(ctx, request.SessionId, request.Line) if err != nil { - sendError("", msg.TaskID, err) + send(operationError(correlation, request.TaskId, err.Error())) return } - encoded, _ := json.Marshal(result) - send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) + encoded, err := json.Marshal(result) + if err != nil { + send(operationError(correlation, request.TaskId, err.Error())) + return + } + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CommandResult{CommandResult: &transport.CommandResult{TaskId: request.TaskId, Result: encoded, MediaType: "application/json"}}}) }() - return true + default: + return false } - return false + return true +} + +func rejection(code, message string) *aop.Rejection { + return &aop.Rejection{Code: code, Message: message} +} + +func operationError(correlation, taskID, message string) *transport.AgentFrame { + return &transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_OperationError{OperationError: &transport.OperationError{TaskId: taskID, Code: "INVALID_ARGUMENT", Message: message}}} } diff --git a/pkg/runner/runtime_protocol_test.go b/pkg/runner/runtime_protocol_test.go index 096af634..5ae492be 100644 --- a/pkg/runner/runtime_protocol_test.go +++ b/pkg/runner/runtime_protocol_test.go @@ -2,63 +2,106 @@ package runner import ( "context" - "encoding/json" "strings" "testing" + "time" - "github.com/chainreactors/aiscan/pkg/webproto" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" ) -func TestProtocolErrorsKeepDistinctCorrelationIDs(t *testing.T) { +func TestServerFrameErrorsKeepDistinctCorrelationIDs(t *testing.T) { rt := newBareRuntime(t, nil, nil) - var runError webproto.Message - if !rt.HandleProtocol(context.Background(), webproto.Message{ - Type: webproto.TypeRun, TurnID: "turn-1", Payload: json.RawMessage(`{`), - }, func(message webproto.Message) { runError = message }) { + var runResponse *transport.AgentFrame + if !rt.HandleServerFrame(context.Background(), &transport.ServerFrame{ + CorrelationId: "turn-correlation", + Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{RequestId: "run-1"}}, + }, func(frame *transport.AgentFrame) { runResponse = frame }) { t.Fatal("run frame was not handled") } - if runError.Type != webproto.TypeError || runError.TurnID != "turn-1" || runError.TaskID != "" { - t.Fatalf("run error correlation = %+v", runError) + if runResponse.CorrelationId != "turn-correlation" || runResponse.GetRunTurn().GetRejected() == nil { + t.Fatalf("run response = %+v", runResponse) } - var commandError webproto.Message - if !rt.HandleProtocol(context.Background(), webproto.Message{ - Type: webproto.TypeCommand, TaskID: "command-1", Payload: json.RawMessage(`{`), - }, func(message webproto.Message) { commandError = message }) { + var commandResponse *transport.AgentFrame + if !rt.HandleServerFrame(context.Background(), &transport.ServerFrame{ + CorrelationId: "command-correlation", + Payload: &transport.ServerFrame_Command{Command: &transport.CommandRequest{ + TaskId: "command-1", + }}, + }, func(frame *transport.AgentFrame) { commandResponse = frame }) { t.Fatal("command frame was not handled") } - if commandError.Type != webproto.TypeError || commandError.TaskID != "command-1" || commandError.TurnID != "" { - t.Fatalf("command error correlation = %+v", commandError) + if commandResponse.CorrelationId != "command-correlation" || commandResponse.GetOperationError().GetTaskId() != "command-1" { + t.Fatalf("command response = %+v", commandResponse) } } -func TestProtocolRequiresTurnID(t *testing.T) { +func TestServerFrameRequiresTurnID(t *testing.T) { rt := newBareRuntime(t, nil, nil) - var response webproto.Message - rt.HandleProtocol(context.Background(), webproto.Message{ - Type: webproto.TypeRun, Payload: webproto.MustJSON(webproto.RunPayload{SessionID: "session-1"}), - }, func(message webproto.Message) { response = message }) - var payload webproto.ErrorPayload - _ = json.Unmarshal(response.Payload, &payload) - if response.Type != webproto.TypeError || !strings.Contains(payload.Message, "turn_id is required") { - t.Fatalf("response = %+v payload=%+v", response, payload) + var response *transport.AgentFrame + rt.HandleServerFrame(context.Background(), &transport.ServerFrame{ + Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{ + RequestId: "run-1", SessionId: "session-1", Input: &aop.Message{Role: "user"}, + }}, + }, func(frame *transport.AgentFrame) { response = frame }) + rejected := response.GetRunTurn().GetRejected() + if rejected == nil || !strings.Contains(rejected.Message, "turn_id") { + t.Fatalf("response = %+v", response) } } -func TestProtocolSessionOpenIsIdempotent(t *testing.T) { +func TestServerFrameSessionOpenIsIdempotent(t *testing.T) { rt := newBareRuntime(t, nil, nil) - request := webproto.Message{ - Type: webproto.TypeSessionOpen, - Payload: webproto.MustJSON(webproto.SessionOpenPayload{SessionID: "session-1"}), + request := &transport.ServerFrame{ + CorrelationId: "open-1", + Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ + RequestId: "open-1", SessionId: "session-1", + }}, } for i := 0; i < 2; i++ { - var response webproto.Message - if !rt.HandleProtocol(context.Background(), request, func(message webproto.Message) { response = message }) { - t.Fatal("session.open was not handled") + var response *transport.AgentFrame + if !rt.HandleServerFrame(context.Background(), request, func(frame *transport.AgentFrame) { response = frame }) { + t.Fatal("session open was not handled") } - if response.Type != webproto.TypeSessionOpened { + if response.GetOpenSession().GetAccepted().GetId() != "session-1" { t.Fatalf("open %d response = %+v", i, response) } } } + +func TestCancelAOPTurnRequiresMatchingSession(t *testing.T) { + provider := &runtimeSemanticProvider{started: make(chan struct{}), release: make(chan struct{})} + rt := newBareRuntime(t, nil, provider) + defer close(provider.release) + if response := rt.OpenAOPSession(&aop.OpenSessionRequest{SessionId: "session-1"}); response.GetAccepted() == nil { + t.Fatalf("open = %v", response) + } + run := rt.RunAOPTurn(context.Background(), &aop.RunTurnRequest{ + SessionId: "session-1", TurnId: "turn-1", + Input: &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}}, + }) + if run.GetAccepted() == nil { + t.Fatalf("run = %v", run) + } + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("run did not start") + } + wrong := rt.CancelAOPTurn(&aop.CancelTurnRequest{SessionId: "session-2", TurnId: "turn-1"}) + if wrong.GetRejected().GetCode() != "NOT_FOUND" { + t.Fatalf("wrong-session cancel = %v", wrong) + } + rt.mu.RLock() + stillActive := rt.runs["turn-1"] != nil + rt.mu.RUnlock() + if !stillActive { + t.Fatal("wrong-session cancel stopped the turn") + } + matched := rt.CancelAOPTurn(&aop.CancelTurnRequest{SessionId: "session-1", TurnId: "turn-1"}) + if matched.GetAccepted().GetTurnId() != "turn-1" { + t.Fatalf("matching cancel = %v", matched) + } +} diff --git a/pkg/runner/runtime_semantics_test.go b/pkg/runner/runtime_semantics_test.go index 7d5a7166..7dad8864 100644 --- a/pkg/runner/runtime_semantics_test.go +++ b/pkg/runner/runtime_semantics_test.go @@ -10,11 +10,12 @@ import ( "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/agent/inbox" - "github.com/chainreactors/aiscan/core/aop" - xcommand "github.com/chainreactors/aiscan/core/aop/x/command" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" + "google.golang.org/protobuf/proto" ) type runtimeSemanticProvider struct { @@ -55,15 +56,15 @@ func (p *runtimeSemanticProvider) callCount() int { func TestSessionRunHasOneReliableTurnLifecycle(t *testing.T) { provider := &runtimeSemanticProvider{} rt := newBareRuntime(t, nil, provider) - var all []aop.Event - unsubscribe := rt.Subscribe(func(event aop.Event) { all = append(all, event) }) + var all []*aop.Event + unsubscribe := rt.Subscribe(func(event *aop.Event) { all = append(all, event) }) defer unsubscribe() session, err := rt.OpenSession(context.Background(), SessionOptions{ID: "session-1"}) if err != nil { t.Fatal(err) } - run, err := session.Run(context.Background(), RunInput{TurnID: "turn-1", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "hello"}}}) + run, err := session.Run(context.Background(), RunInput{TurnID: "turn-1", Content: []*aop.Content{aop.Text("hello")}}) if err != nil { t.Fatal(err) } @@ -74,25 +75,25 @@ func TestSessionRunHasOneReliableTurnLifecycle(t *testing.T) { t.Fatal(err) } - var turnEvents []aop.Event + var turnEvents []*aop.Event for _, event := range all { - if event.TurnID != "turn-1" { + if event.TurnId != "turn-1" { continue } turnEvents = append(turnEvents, event) - if event.SessionID != "session-1" || event.TurnID != "turn-1" { + if event.SessionId != "session-1" || event.TurnId != "turn-1" { t.Fatalf("run event identity = %+v", event) } } - if len(turnEvents) < 2 || turnEvents[0].Type != aop.TypeTurnStart || turnEvents[len(turnEvents)-1].Type != aop.TypeTurnEnd { + if len(turnEvents) < 2 || turnEvents[0].GetTurnStarted() == nil || turnEvents[len(turnEvents)-1].GetTurnEnded() == nil { t.Fatalf("turn events = %+v", turnEvents) } starts, ends := 0, 0 for _, event := range turnEvents { - if event.Type == aop.TypeTurnStart { + if event.GetTurnStarted() != nil { starts++ } - if event.Type == aop.TypeTurnEnd { + if event.GetTurnEnded() != nil { ends++ } } @@ -102,11 +103,52 @@ func TestSessionRunHasOneReliableTurnLifecycle(t *testing.T) { if err := rt.CloseSession(context.Background(), "session-1", SessionCloseCompleted); err != nil { t.Fatal(err) } - if all[0].Type != aop.TypeSessionStart || all[len(all)-1].Type != aop.TypeSessionEnd { + if all[0].GetSessionStarted() == nil || all[len(all)-1].GetSessionEnded() == nil { t.Fatalf("session lifecycle = %+v", all) } } +func TestRunAOPTurnPreservesClientMessageIdentity(t *testing.T) { + rt := newBareRuntime(t, nil, &runtimeSemanticProvider{}) + events := make(chan *aop.Event, 16) + unsubscribe := rt.Subscribe(func(event *aop.Event) { events <- proto.Clone(event).(*aop.Event) }) + defer unsubscribe() + + opened := rt.OpenAOPSession(&aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1"}) + if opened.GetAccepted() == nil { + t.Fatalf("OpenAOPSession = %v", opened) + } + input := &aop.Message{ + Id: "client-message-1", Role: "user", Name: "operator", + Content: []*aop.Content{aop.Text("preserve my identity")}, + } + run := rt.RunAOPTurn(context.Background(), &aop.RunTurnRequest{ + RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", Input: input, + }) + if run.GetAccepted() == nil { + t.Fatalf("RunAOPTurn = %v", run) + } + + var emitted *aop.Message + deadline := time.After(time.Second) + for { + select { + case event := <-events: + if message := event.GetMessage(); message != nil && message.Id == input.Id { + emitted = message + } + if event.TurnId == "turn-1" && event.GetTurnEnded() != nil { + if !proto.Equal(emitted, input) { + t.Fatalf("emitted input = %v, want %v", emitted, input) + } + return + } + case <-deadline: + t.Fatal("turn did not finish") + } + } +} + func TestConsoleRuntimeAdapterPreservesTotalContextTokens(t *testing.T) { provider := &runtimeSemanticProvider{usage: &agent.Usage{ PromptTokens: 8192, @@ -136,8 +178,8 @@ func TestSessionContextCancellationStopsActiveRun(t *testing.T) { t.Fatal(err) } run, err := session.Run(context.Background(), RunInput{ - TurnID: "turn-1", - Parts: []aop.MessagePart{{Type: aop.PartText, Text: "hello"}}, + TurnID: "turn-1", + Content: []*aop.Content{aop.Text("hello")}, }) if err != nil { t.Fatal(err) @@ -163,9 +205,9 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { t.Fatal(err) } before := session.MessagesSnapshot() - var commandEvent aop.Event - rt.Subscribe(func(event aop.Event) { - if event.Type == aop.TypeMessage && event.TurnID == "" { + var commandEvent *aop.Event + rt.Subscribe(func(event *aop.Event) { + if event.GetMessage() != nil && event.TurnId == "" { commandEvent = event } }) @@ -173,13 +215,13 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { if err != nil { t.Fatal(err) } - if len(result.Parts) != 1 || !strings.Contains(result.Parts[0].Text, "COMMAND_OK") { + if len(result.Content) != 1 || !strings.Contains(result.Content[0].GetText().GetText(), "COMMAND_OK") { t.Fatalf("command result = %+v", result) } - if commandEvent.Type != aop.TypeMessage || commandEvent.TurnID != "" { + if commandEvent == nil || commandEvent.GetMessage() == nil || commandEvent.TurnId != "" { t.Fatalf("command AOP event = %+v", commandEvent) } - detail, ok, err := xcommand.GetDetail(commandEvent) + detail, ok, err := ext.GetCommandDetail(commandEvent) if err != nil || !ok || detail.Line != "!printf COMMAND_OK" || detail.Presentation != CommandPresentationPreformatted { t.Fatalf("command extension = %+v ok=%v err=%v", detail, ok, err) } @@ -192,13 +234,13 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { provider := &runtimeSemanticProvider{started: make(chan struct{}), release: make(chan struct{})} rt := newBareRuntime(t, nil, provider) - var events []aop.Event - rt.Subscribe(func(event aop.Event) { events = append(events, event) }) + var events []*aop.Event + rt.Subscribe(func(event *aop.Event) { events = append(events, event) }) session, err := rt.OpenSession(context.Background(), SessionOptions{ID: "session-1"}) if err != nil { t.Fatal(err) } - run, err := session.Run(context.Background(), RunInput{TurnID: "turn-1", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "start"}}}) + run, err := session.Run(context.Background(), RunInput{TurnID: "turn-1", Content: []*aop.Content{aop.Text("start")}}) if err != nil { t.Fatal(err) } @@ -219,13 +261,13 @@ func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { } starts, ends := 0, 0 for _, event := range events { - if event.TurnID != "turn-1" { + if event.TurnId != "turn-1" { continue } - if event.Type == aop.TypeTurnStart { + if event.GetTurnStarted() != nil { starts++ } - if event.Type == aop.TypeTurnEnd { + if event.GetTurnEnded() != nil { ends++ } } @@ -241,9 +283,9 @@ func TestIdleAsyncInputCreatesAutomaticRun(t *testing.T) { if err != nil { t.Fatal(err) } - ended := make(chan aop.Event, 1) - rt.Subscribe(func(event aop.Event) { - if event.SessionID == "session-1" && event.Type == aop.TypeTurnEnd { + ended := make(chan *aop.Event, 1) + rt.Subscribe(func(event *aop.Event) { + if event.SessionId == "session-1" && event.GetTurnEnded() != nil { ended <- event } }) @@ -252,7 +294,7 @@ func TestIdleAsyncInputCreatesAutomaticRun(t *testing.T) { } select { case event := <-ended: - if event.TurnID == "" { + if event.TurnId == "" { t.Fatal("automatic Run has no turn_id") } case <-time.After(time.Second): diff --git a/pkg/runner/runtime_session.go b/pkg/runner/runtime_session.go index f114c163..b7b532a8 100644 --- a/pkg/runner/runtime_session.go +++ b/pkg/runner/runtime_session.go @@ -12,13 +12,15 @@ import ( "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/agent/evaluator" inboxpkg "github.com/chainreactors/aiscan/agent/inbox" - "github.com/chainreactors/aiscan/core/aop" - xcommand "github.com/chainreactors/aiscan/core/aop/x/command" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) const DefaultSessionPendingLimit = 64 @@ -41,8 +43,8 @@ const ( type RunInput struct { TurnID string - Parts []aop.MessagePart - NoEcho bool + Message *aop.Message + Content []*aop.Content MaxTurns int EvalCriteria string EvalMaxRounds int @@ -59,9 +61,9 @@ type RunResult struct { } type CommandResult struct { - Command string `json:"command"` - Presentation string `json:"presentation,omitempty"` - Parts []aop.MessagePart `json:"parts,omitempty"` + Command string `json:"command"` + Presentation string `json:"presentation,omitempty"` + Content []*aop.Content `json:"-"` } const ( @@ -74,12 +76,13 @@ type Session struct { } type Run struct { - turnID string - done chan struct{} - cancel context.CancelFunc - mu sync.Mutex - result RunResult - err error + sessionID string + turnID string + done chan struct{} + cancel context.CancelFunc + mu sync.Mutex + result RunResult + err error } func (r *Run) TurnID() string { @@ -119,29 +122,35 @@ type commandOutcome struct { } type sessionEmitter struct { - bus *eventbus.Bus[aop.Event] + bus *eventbus.Bus[*aop.Event] mu sync.Mutex - seq map[string]int + seq map[string]uint64 } -func newSessionEmitter(bus *eventbus.Bus[aop.Event]) *sessionEmitter { - return &sessionEmitter{bus: bus, seq: make(map[string]int)} +func newSessionEmitter(bus *eventbus.Bus[*aop.Event]) *sessionEmitter { + return &sessionEmitter{bus: bus, seq: make(map[string]uint64)} } -func (e *sessionEmitter) emit(event aop.Event) { - if event.TS == "" { - event.TS = time.Now().UTC().Format(time.RFC3339Nano) +func (e *sessionEmitter) emit(event *aop.Event) { + if event.EmittedAt == nil { + event.EmittedAt = timestamppb.Now() } e.mu.Lock() - e.seq[event.SessionID]++ - event.Seq = e.seq[event.SessionID] + e.seq[event.SessionId]++ + event.Seq = e.seq[event.SessionId] + if event.Id == "" { + event.Id = fmt.Sprintf("runtime-%d", event.Seq) + } e.mu.Unlock() e.bus.Emit(event) } -func (e *sessionEmitter) lifecycle(typ, sessionID, agentName string, data any) { - raw, _ := json.Marshal(data) - e.emit(aop.Event{Type: typ, SessionID: sessionID, Agent: agentName, Data: raw}) +func (e *sessionEmitter) sessionStarted(sessionID, agentName string, started *aop.SessionStarted) { + e.emit(&aop.Event{SessionId: sessionID, Emitter: agentName, Payload: &aop.Event_SessionStarted{SessionStarted: started}}) +} + +func (e *sessionEmitter) sessionEnded(sessionID, agentName, reason string) { + e.emit(&aop.Event{SessionId: sessionID, Emitter: agentName, Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: reason}}}) } type turnEmitter struct { @@ -152,17 +161,15 @@ type turnEmitter struct { } func (e *turnEmitter) start() { - raw, _ := json.Marshal(aop.TurnStartData{}) - e.emitter.emit(aop.Event{Type: aop.TypeTurnStart, SessionID: e.sessionID, TurnID: e.turnID, Agent: e.agentName, Data: raw}) + e.emitter.emit(&aop.Event{SessionId: e.sessionID, TurnId: e.turnID, Emitter: e.agentName, Payload: &aop.Event_TurnStarted{TurnStarted: &aop.TurnStarted{}}}) } func (e *turnEmitter) end(result RunResult, runErr error) { - data := aop.TurnEndData{Stop: string(result.Stop), Usage: runtimeUsageData(result.Usage), ContextTokens: result.ContextTokens} + ended := &aop.TurnEnded{StopReason: string(result.Stop), Usage: runtimeUsageData(result.Usage), ContextTokens: uint64(max(result.ContextTokens, 0))} if runErr != nil { - data.Error = runErr.Error() + ended.Error = &aop.ProtocolError{Message: runErr.Error()} } - raw, _ := json.Marshal(data) - e.emitter.emit(aop.Event{Type: aop.TypeTurnEnd, SessionID: e.sessionID, TurnID: e.turnID, Agent: e.agentName, Data: raw}) + e.emitter.emit(&aop.Event{SessionId: e.sessionID, TurnId: e.turnID, Emitter: e.agentName, Payload: &aop.Event_TurnEnded{TurnEnded: ended}}) } type commandSession struct { @@ -276,7 +283,7 @@ func (s *commandSession) executeBash(ctx context.Context, line, command string) func commandText(line, presentation, text string) commandOutcome { result := CommandResult{Command: line, Presentation: presentation} if text != "" { - result.Parts = []aop.MessagePart{{Type: aop.PartText, Text: text}} + result.Content = []*aop.Content{aop.Text(text)} } return commandOutcome{result: result} } @@ -430,8 +437,8 @@ func (rt *AgentRuntime) OpenSession(ctx context.Context, options SessionOptions) }) } go rt.runSession(state) - rt.sessionEvents.lifecycle(aop.TypeSessionStart, id, agentName, aop.SessionStartData{ - Model: rt.config.Model, ParentSessionID: options.ParentSessionID, ParentToolCallID: options.ParentToolCallID, + rt.sessionEvents.sessionStarted(id, agentName, &aop.SessionStarted{ + Model: rt.config.Model, ParentSessionId: options.ParentSessionID, ParentToolCallId: options.ParentToolCallID, }) return public, nil } @@ -510,11 +517,11 @@ func (rt *AgentRuntime) CloseSession(ctx context.Context, sessionID string, reas } state.scheduler.Stop() state.inbox.Close() - rt.sessionEvents.lifecycle(aop.TypeSessionEnd, state.id, state.agentName, aop.SessionEndData{Reason: string(reason)}) + rt.sessionEvents.sessionEnded(state.id, state.agentName, string(reason)) return nil } -func (rt *AgentRuntime) Subscribe(fn func(aop.Event)) func() { +func (rt *AgentRuntime) Subscribe(fn func(*aop.Event)) func() { if rt == nil || rt.bus == nil || fn == nil { return func() {} } @@ -565,6 +572,22 @@ func (rt *AgentRuntime) CancelRun(turnID string) error { return nil } +func (rt *AgentRuntime) CancelSessionRun(sessionID, turnID string) error { + if rt == nil { + return fmt.Errorf("agent runtime is not configured") + } + sessionID = strings.TrimSpace(sessionID) + turnID = strings.TrimSpace(turnID) + rt.mu.RLock() + run := rt.runs[turnID] + rt.mu.RUnlock() + if run == nil || run.sessionID != sessionID { + return fmt.Errorf("turn %q is not active in session %q", turnID, sessionID) + } + run.cancel() + return nil +} + // WaitOperations waits for all Runs and asynchronous control operations that // were admitted before the call. Transports use it to drain before shutdown. func (rt *AgentRuntime) WaitOperations() { @@ -588,7 +611,7 @@ func (s *Session) Command(ctx context.Context, line string) (CommandResult, erro op := &sessionOperation{ execute: func(runCtx context.Context) { outcome := s.state.commands.execute(runCtx, line) - if outcome.err == nil && len(outcome.result.Parts) > 0 { + if outcome.err == nil && len(outcome.result.Content) > 0 { s.state.emitCommandResult(outcome.result) } done <- outcome @@ -618,7 +641,7 @@ func (s *Session) MessagesSnapshot() []agent.ChatMessage { } func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, error) { - if !input.automatic && !input.Continue && !hasRunInput(input.Parts) { + if !input.automatic && !input.Continue && !hasRunInput(runInputContent(input)) { return nil, fmt.Errorf("run input is empty") } turnID := strings.TrimSpace(input.TurnID) @@ -629,7 +652,7 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro ctx = context.Background() } runCtx, runCancel := context.WithCancel(ctx) - run := &Run{turnID: turnID, done: make(chan struct{}), cancel: runCancel} + run := &Run{sessionID: s.id, turnID: turnID, done: make(chan struct{}), cancel: runCancel} s.runtime.mu.Lock() if _, exists := s.runtime.runs[turnID]; exists { s.runtime.mu.Unlock() @@ -681,12 +704,12 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro return run, nil } -func hasRunInput(parts []aop.MessagePart) bool { - for _, part := range parts { - if part.Type == aop.PartText && strings.TrimSpace(part.Text) != "" { +func hasRunInput(content []*aop.Content) bool { + for _, part := range content { + if strings.TrimSpace(part.GetText().GetText()) != "" { return true } - if part.Type == aop.PartImage && part.Image != nil { + if part.GetMedia() != nil { return true } } @@ -700,12 +723,19 @@ func (s *sessionState) executeRun(ctx context.Context, turnID string, input RunI if input.EvalCriteria == "" { input.EvalCriteria = s.commands.evalCriteria } - if len(input.Parts) == 1 && input.Parts[0].Type == aop.PartText { - input.Parts[0].Text = skills.ExpandCommand(input.Parts[0].Text, s.runtime.app.Skills) + message := input.Message + if message == nil { + message = &aop.Message{Role: "user", Content: input.Content} + } else { + message = proto.Clone(message).(*aop.Message) + } + if message.Role == "" { + message.Role = "user" + } + if len(message.Content) == 1 && message.Content[0].GetText() != nil { + message.Content[0].GetText().Text = skills.ExpandCommand(message.Content[0].GetText().Text, s.runtime.app.Skills) } - message := aop.MessageData{Role: "user", Parts: input.Parts} agentInput := agent.InputFromAOPMessage(message) - agentInput.NoEcho = input.NoEcho if input.EvalCriteria != "" { provider, model, logger := s.runtime.providerSnapshot() evalConfig := evaluator.NewLoopConfigWithInput(provider, model, logger, agentInput, input.EvalCriteria, input.EvalMaxRounds) @@ -717,6 +747,13 @@ func (s *sessionState) executeRun(ctx context.Context, turnID string, input RunI return s.agent.Run(ctx, agentInput, agent.WithTurnID(turnID), agent.WithRunMaxTurns(input.MaxTurns)) } +func runInputContent(input RunInput) []*aop.Content { + if input.Message != nil { + return input.Message.Content + } + return input.Content +} + func (s *sessionState) startAutomaticRun() { s.mu.Lock() closed := s.closed @@ -800,11 +837,10 @@ func (rt *AgentRuntime) runSession(session *sessionState) { } func (s *sessionState) emitCommandResult(result CommandResult) { - raw, _ := json.Marshal(aop.MessageData{ - MessageID: s.runtime.nextRuntimeID("command"), Role: "assistant", Parts: result.Parts, - }) - event := aop.Event{Type: aop.TypeMessage, SessionID: s.id, Agent: s.agentName, Data: raw} - _ = xcommand.SetDetail(&event, xcommand.Detail{Line: result.Command, Presentation: result.Presentation}) + event := &aop.Event{SessionId: s.id, Emitter: s.agentName, Payload: &aop.Event_Message{Message: &aop.Message{ + Id: s.runtime.nextRuntimeID("command"), Role: "assistant", Content: result.Content, + }}} + _ = ext.SetCommandDetail(event, ext.CommandDetail{Line: result.Command, Presentation: result.Presentation}) s.runtime.sessionEvents.emit(event) } @@ -860,13 +896,13 @@ func (rt *AgentRuntime) providerSnapshot() (agent.Provider, string, telemetry.Lo return rt.config.Provider, rt.config.Model, rt.config.Logger } -func runtimeUsageData(usage agent.Usage) *aop.UsageData { +func runtimeUsageData(usage agent.Usage) *aop.TokenUsage { if usage == (agent.Usage{}) { return nil } - return &aop.UsageData{ - InputTokens: usage.PromptTokens, OutputTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens, - CacheReadTokens: usage.CacheReadTokens, CacheWriteTokens: usage.CacheWriteTokens, + return &aop.TokenUsage{ + InputTokens: uint64(max(usage.PromptTokens, 0)), OutputTokens: uint64(max(usage.CompletionTokens, 0)), TotalTokens: uint64(max(usage.TotalTokens, 0)), + Detail: map[string]uint64{"cache_read": uint64(max(usage.CacheReadTokens, 0)), "cache_write": uint64(max(usage.CacheWriteTokens, 0))}, } } @@ -889,7 +925,7 @@ func (rt *AgentRuntime) consoleAppInfoForSession(session *Session) tui.AppInfo { info.Run = func(ctx context.Context, prompt string, continuation bool) (*agent.Result, error) { input := RunInput{Continue: continuation} if !continuation { - input.Parts = []aop.MessagePart{{Type: aop.PartText, Text: prompt}} + input.Content = []*aop.Content{aop.Text(prompt)} } run, err := session.Run(ctx, input) if err != nil { diff --git a/pkg/runner/runtime_session_isolation_test.go b/pkg/runner/runtime_session_isolation_test.go index ec67b017..fbf50472 100644 --- a/pkg/runner/runtime_session_isolation_test.go +++ b/pkg/runner/runtime_session_isolation_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/capability" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" @@ -21,8 +21,8 @@ func newBareRuntime(t *testing.T, reg *commands.CommandRegistry, provider agent. if reg == nil { reg = commands.NewRegistry() } - publicBus := eventbus.New[aop.Event]() - kernelBus := eventbus.New[aop.Event]() + publicBus := eventbus.New[*aop.Event]() + kernelBus := eventbus.New[*aop.Event]() events := newSessionEmitter(publicBus) kernelBus.Subscribe(events.emit) rt := &AgentRuntime{ diff --git a/pkg/runner/stdio.go b/pkg/runner/stdio.go index c7c9d34a..e0d2b7df 100644 --- a/pkg/runner/stdio.go +++ b/pkg/runner/stdio.go @@ -3,20 +3,19 @@ package runner import ( "bufio" "context" - "encoding/json" "fmt" "io" "strings" "sync" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" - "github.com/chainreactors/aiscan/pkg/webproto" + "google.golang.org/protobuf/encoding/protojson" ) -// RunStdio hosts the same explicit Session/Run/Command protocol used by the -// WebSocket adapter. Both stdin and stdout are webproto.Message JSONL streams. +// RunStdio carries generated ServerFrame/AgentFrame messages as protobuf JSONL. func RunStdio(ctx context.Context, option *cfg.Option, logger telemetry.Logger, input io.Reader, output io.Writer) error { host := newStdioHost(ctx, option, logger, output) if err := host.init(); err != nil { @@ -46,7 +45,7 @@ type stdioHost struct { logger telemetry.Logger encMu sync.Mutex - enc *json.Encoder + output io.Writer encErr error rt *AgentRuntime @@ -54,7 +53,7 @@ type stdioHost struct { func newStdioHost(ctx context.Context, option *cfg.Option, logger telemetry.Logger, output io.Writer) *stdioHost { return &stdioHost{ - ctx: ctx, option: option, logger: logger, enc: json.NewEncoder(output), + ctx: ctx, option: option, logger: logger, output: output, } } @@ -64,11 +63,8 @@ func (h *stdioHost) init() error { return err } h.rt = rt - rt.Subscribe(func(event aop.Event) { - payload, err := json.Marshal(event) - if err == nil { - _ = h.emit(webproto.Message{Type: webproto.TypeAOP, TurnID: event.TurnID, Payload: payload}) - } + rt.Subscribe(func(event *aop.Event) { + _ = h.emit(&transport.AgentFrame{CorrelationId: event.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) }) return nil } @@ -79,22 +75,26 @@ func (h *stdioHost) close() { } } -func (h *stdioHost) emit(message webproto.Message) error { +func (h *stdioHost) emit(message *transport.AgentFrame) error { h.encMu.Lock() defer h.encMu.Unlock() if h.encErr != nil { return h.encErr } - if err := h.enc.Encode(message); err != nil { + data, err := protojson.Marshal(message) + if err == nil { + data = append(data, '\n') + _, err = h.output.Write(data) + } + if err != nil { h.encErr = err return err } return nil } -func (h *stdioHost) emitError(turnID string, err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - _ = h.emit(webproto.Message{Type: webproto.TypeError, TurnID: turnID, Payload: payload}) +func (h *stdioHost) emitError(correlationID string, err error) { + _ = h.emit(operationError(correlationID, correlationID, err.Error())) } func (h *stdioHost) err() error { @@ -107,13 +107,13 @@ func (h *stdioHost) err() error { } func (h *stdioHost) accept(line string) { - var message webproto.Message - if err := json.Unmarshal([]byte(line), &message); err != nil { + message := new(transport.ServerFrame) + if err := protojson.Unmarshal([]byte(line), message); err != nil { h.emitError("", fmt.Errorf("decode frame: %w", err)) return } - if h.rt == nil || !h.rt.HandleProtocol(h.ctx, message, func(response webproto.Message) { _ = h.emit(response) }) { - h.emitError(message.TurnID, fmt.Errorf("unsupported frame type %q", message.Type)) + if h.rt == nil || !h.rt.HandleServerFrame(h.ctx, message, func(response *transport.AgentFrame) { _ = h.emit(response) }) { + h.emitError(message.CorrelationId, fmt.Errorf("unsupported server frame")) } } diff --git a/pkg/runner/stdio_concurrency_test.go b/pkg/runner/stdio_concurrency_test.go index 42100898..93489961 100644 --- a/pkg/runner/stdio_concurrency_test.go +++ b/pkg/runner/stdio_concurrency_test.go @@ -3,14 +3,13 @@ package runner import ( "bytes" "context" - "encoding/json" "sync" "testing" "time" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/pkg/webproto" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" ) // stdioGateProvider blocks every call until the gate closes, recording the @@ -78,9 +77,8 @@ func newRuntimeStdioHost(t *testing.T, output *bytes.Buffer, prov agent.Provider h.rt = newBareRuntime(t, nil, prov) h.rt.config.Model = "test" h.rt.config.MaxTurns = 4 - h.rt.Subscribe(func(event aop.Event) { - payload, _ := json.Marshal(event) - _ = h.emit(webproto.Message{Type: webproto.TypeAOP, TurnID: event.TurnID, Payload: payload}) + h.rt.Subscribe(func(event *aop.Event) { + _ = h.emit(&transport.AgentFrame{CorrelationId: event.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) }) return h } @@ -133,23 +131,23 @@ func TestStdioSessionsRunConcurrently(t *testing.T) { close(prov.gate) h.drain() - h.accept(protocolLine(t, webproto.Message{Type: webproto.TypeSessionClose, Payload: mustJSON(t, webproto.SessionLifecyclePayload{SessionID: "s1", Reason: "completed"})})) - h.accept(protocolLine(t, webproto.Message{Type: webproto.TypeSessionClose, Payload: mustJSON(t, webproto.SessionLifecyclePayload{SessionID: "s2", Reason: "completed"})})) + h.accept(closeSessionLine(t, "s1", "completed")) + h.accept(closeSessionLine(t, "s2", "completed")) // Interleaved output must stay valid AOP: every line decodes, and both // sessions produced their session brackets. - events := decodeAOPMessages(t, decodeProtocolLines(t, &output)) + events := decodeAOPMessages(decodeAgentFrames(t, &output)) starts := map[string]bool{} ends := map[string]bool{} for _, e := range events { - if e.SessionID != "s1" && e.SessionID != "s2" { + if e.SessionId != "s1" && e.SessionId != "s2" { t.Fatalf("event with foreign session: %+v", e) } - switch e.Type { - case aop.TypeSessionStart: - starts[e.SessionID] = true - case aop.TypeSessionEnd: - ends[e.SessionID] = true + switch e.Payload.(type) { + case *aop.Event_SessionStarted: + starts[e.SessionId] = true + case *aop.Event_SessionEnded: + ends[e.SessionId] = true } } if !starts["s1"] || !starts["s2"] || !ends["s1"] || !ends["s2"] { diff --git a/pkg/runner/stdio_test.go b/pkg/runner/stdio_test.go index d13edce0..91d91a32 100644 --- a/pkg/runner/stdio_test.go +++ b/pkg/runner/stdio_test.go @@ -1,26 +1,27 @@ package runner import ( + "bufio" "bytes" "context" - "encoding/json" "errors" "io" "strings" "testing" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/telemetry" - "github.com/chainreactors/aiscan/pkg/webproto" + "google.golang.org/protobuf/encoding/protojson" ) func newTestStdioHost(output io.Writer) *stdioHost { return newStdioHost(context.Background(), nil, telemetry.NopLogger(), output) } -func protocolLine(t *testing.T, message webproto.Message) string { +func protocolLine(t *testing.T, frame *transport.ServerFrame) string { t.Helper() - data, err := json.Marshal(message) + data, err := protojson.Marshal(frame) if err != nil { t.Fatal(err) } @@ -28,13 +29,30 @@ func protocolLine(t *testing.T, message webproto.Message) string { } func openSessionLine(t *testing.T, sessionID string) string { - return protocolLine(t, webproto.Message{Type: webproto.TypeSessionOpen, Payload: mustJSON(t, webproto.SessionOpenPayload{SessionID: sessionID})}) + return protocolLine(t, &transport.ServerFrame{ + CorrelationId: "open-" + sessionID, + Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ + RequestId: "open-" + sessionID, SessionId: sessionID, + }}, + }) } func runLine(t *testing.T, sessionID, turnID, text string) string { - return protocolLine(t, webproto.Message{ - Type: webproto.TypeRun, TurnID: turnID, - Payload: mustJSON(t, webproto.RunPayload{SessionID: sessionID, Parts: []aop.MessagePart{{Type: aop.PartText, Text: text}}}), + return protocolLine(t, &transport.ServerFrame{ + CorrelationId: turnID, + Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{ + RequestId: turnID, SessionId: sessionID, TurnId: turnID, + Input: &aop.Message{Id: "input-" + turnID, Role: "user", Content: []*aop.Content{aop.Text(text)}}, + }}, + }) +} + +func closeSessionLine(t *testing.T, sessionID, reason string) string { + return protocolLine(t, &transport.ServerFrame{ + CorrelationId: "close-" + sessionID, + Payload: &transport.ServerFrame_CloseSession{CloseSession: &aop.CloseSessionRequest{ + RequestId: "close-" + sessionID, SessionId: sessionID, Reason: reason, + }}, }) } @@ -42,24 +60,22 @@ func TestStdioAcceptRejectsMalformedJSON(t *testing.T) { var output bytes.Buffer h := newTestStdioHost(&output) h.accept("not json") - messages := decodeProtocolLines(t, &output) - if len(messages) != 1 || messages[0].Type != webproto.TypeError { - t.Fatalf("messages = %#v", messages) + frames := decodeAgentFrames(t, &output) + if len(frames) != 1 || frames[0].GetOperationError() == nil { + t.Fatalf("frames = %#v", frames) } - var data webproto.ErrorPayload - _ = json.Unmarshal(messages[0].Payload, &data) - if !strings.Contains(data.Message, "decode frame") { - t.Fatalf("error data = %+v", data) + if !strings.Contains(frames[0].GetOperationError().Message, "decode frame") { + t.Fatalf("error = %+v", frames[0].GetOperationError()) } } func TestStdioAcceptRejectsUnsupportedFrame(t *testing.T) { var output bytes.Buffer h := newTestStdioHost(&output) - h.accept(protocolLine(t, webproto.Message{Type: "future.frame"})) - messages := decodeProtocolLines(t, &output) - if len(messages) != 1 || messages[0].Type != webproto.TypeError { - t.Fatalf("messages = %#v", messages) + h.accept(protocolLine(t, &transport.ServerFrame{CorrelationId: "future"})) + frames := decodeAgentFrames(t, &output) + if len(frames) != 1 || frames[0].GetOperationError() == nil || frames[0].CorrelationId != "future" { + t.Fatalf("frames = %#v", frames) } } @@ -68,9 +84,9 @@ func TestStdioRunRequiresOpenSession(t *testing.T) { h := newRuntimeStdioHost(t, &output, nil) defer h.rt.Close() h.accept(runLine(t, "s1", "turn-1", "hello")) - messages := decodeProtocolLines(t, &output) - if len(messages) != 1 || messages[0].Type != webproto.TypeError { - t.Fatalf("messages = %#v", messages) + frames := decodeAgentFrames(t, &output) + if len(frames) != 1 || frames[0].GetRunTurn().GetRejected() == nil { + t.Fatalf("frames = %#v", frames) } } @@ -81,37 +97,34 @@ func TestStdioRunRejectsEmptyPrompt(t *testing.T) { h.accept(openSessionLine(t, "s1")) h.accept(runLine(t, "s1", "turn-1", " ")) h.drain() - messages := decodeProtocolLines(t, &output) - if messages[len(messages)-1].Type != webproto.TypeError { - t.Fatalf("messages = %#v", messages) + frames := decodeAgentFrames(t, &output) + if frames[len(frames)-1].GetRunTurn().GetRejected() == nil { + t.Fatalf("frames = %#v", frames) } } -func TestStdioCommandUsesTaskIDCorrelation(t *testing.T) { +func TestStdioCommandUsesIndependentCorrelationID(t *testing.T) { var output bytes.Buffer h := newRuntimeStdioHost(t, &output, nil) defer h.rt.Close() h.accept(openSessionLine(t, "s1")) - h.accept(protocolLine(t, webproto.Message{ - Type: webproto.TypeCommand, - TaskID: "command-1", - Payload: mustJSON(t, webproto.CommandPayload{ - SessionID: "s1", - Line: "/help", - }), + h.accept(protocolLine(t, &transport.ServerFrame{ + CorrelationId: "command-correlation", + Payload: &transport.ServerFrame_Command{Command: &transport.CommandRequest{ + TaskId: "command-1", SessionId: "s1", Line: "/help", + }}, })) h.drain() - messages := decodeProtocolLines(t, &output) - for _, message := range messages { - if message.Type != webproto.TypeCommandResult { + for _, frame := range decodeAgentFrames(t, &output) { + if frame.GetCommandResult() == nil { continue } - if message.TaskID != "command-1" || message.TurnID != "" { - t.Fatalf("command result correlation = %+v", message) + if frame.CorrelationId != "command-correlation" || frame.GetCommandResult().TaskId != "command-1" { + t.Fatalf("command result correlation = %+v", frame) } return } - t.Fatalf("messages = %#v", messages) + t.Fatal("command result missing") } func TestStdioHostReportsEncoderFailure(t *testing.T) { @@ -124,47 +137,32 @@ func TestStdioHostReportsEncoderFailure(t *testing.T) { func TestStdioDrainWithoutRuns(t *testing.T) { var output bytes.Buffer - h := newTestStdioHost(&output) - h.drain() -} - -func mustJSON(t *testing.T, value any) json.RawMessage { - t.Helper() - data, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return data + newTestStdioHost(&output).drain() } -func decodeProtocolLines(t *testing.T, input *bytes.Buffer) []webproto.Message { +func decodeAgentFrames(t *testing.T, input *bytes.Buffer) []*transport.AgentFrame { t.Helper() - var messages []webproto.Message - decoder := json.NewDecoder(input) - for { - var message webproto.Message - if err := decoder.Decode(&message); errors.Is(err, io.EOF) { - break - } else if err != nil { + var frames []*transport.AgentFrame + scanner := bufio.NewScanner(bytes.NewReader(input.Bytes())) + for scanner.Scan() { + frame := new(transport.AgentFrame) + if err := protojson.Unmarshal(scanner.Bytes(), frame); err != nil { t.Fatal(err) } - messages = append(messages, message) + frames = append(frames, frame) + } + if err := scanner.Err(); err != nil { + t.Fatal(err) } - return messages + return frames } -func decodeAOPMessages(t *testing.T, messages []webproto.Message) []aop.Event { - t.Helper() - var events []aop.Event - for _, message := range messages { - if message.Type != webproto.TypeAOP { - continue - } - var event aop.Event - if err := json.Unmarshal(message.Payload, &event); err != nil { - t.Fatal(err) +func decodeAOPMessages(frames []*transport.AgentFrame) []*aop.Event { + var events []*aop.Event + for _, frame := range frames { + if event := frame.GetEvent(); event != nil { + events = append(events, event) } - events = append(events, event) } return events } diff --git a/pkg/runner/subagent_handoff.go b/pkg/runner/subagent_handoff.go index 79b60442..7263b159 100644 --- a/pkg/runner/subagent_handoff.go +++ b/pkg/runner/subagent_handoff.go @@ -9,14 +9,14 @@ import ( "time" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/aop/x/delegation" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/ioa/protocols" ) -func subscribeIOAHandoffContext(ctx context.Context, bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) func() { +func subscribeIOAHandoffContext(ctx context.Context, bus *eventbus.Bus[*aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) func() { if bus == nil || client == nil || spaceName == "" { return func() {} } @@ -31,16 +31,16 @@ func subscribeIOAHandoffContext(ctx context.Context, bus *eventbus.Bus[aop.Event client: client, spaceName: spaceName, logger: logger, - events: make(chan aop.Event, 256), + events: make(chan *aop.Event, 256), pending: make(map[string]*handoffState), bySession: make(map[string]string), } - unsub := bus.Subscribe(func(event aop.Event) { + unsub := bus.Subscribe(func(event *aop.Event) { select { case r.events <- event: case <-ctx.Done(): default: - r.logger.Warnf("ioa handoff queue full, dropping %s", event.Type) + r.logger.Warnf("ioa handoff queue full, dropping %s", aop.Kind(event)) } }) go r.run(ctx) @@ -66,7 +66,7 @@ type ioaHandoffRecorder struct { client protocols.ClientAPI spaceName string logger telemetry.Logger - events chan aop.Event + events chan *aop.Event mu sync.Mutex spaceID string @@ -80,24 +80,24 @@ func (r *ioaHandoffRecorder) run(ctx context.Context) { case <-ctx.Done(): return case event := <-r.events: - switch event.Type { - case aop.TypeSessionStart: + switch event.Payload.(type) { + case *aop.Event_SessionStarted: r.onSessionStart(event) - case aop.TypeMessage: + case *aop.Event_Message: r.onMessage(event) - case aop.TypeTurnEnd: + case *aop.Event_TurnEnded: r.onTurnEnd(event) } } } } -func (r *ioaHandoffRecorder) onSessionStart(event aop.Event) { - data, err := aop.DecodeData[aop.SessionStartData](event) - if err != nil || data.ParentToolCallID == "" { +func (r *ioaHandoffRecorder) onSessionStart(event *aop.Event) { + data := event.GetSessionStarted() + if data.ParentToolCallId == "" { return } - detail, ok, err := delegation.Get(event) + detail, ok, err := ext.GetDelegation(event) if err != nil || !ok { return } @@ -106,9 +106,9 @@ func (r *ioaHandoffRecorder) onSessionStart(event aop.Event) { typeName: detail.AgentType, mode: handoffMode(detail), model: data.Model, - parentSessionID: data.ParentSessionID, - toolCallID: data.ParentToolCallID, - sessionID: event.SessionID, + parentSessionID: data.ParentSessionId, + toolCallID: data.ParentToolCallId, + sessionID: event.SessionId, } title, message := formatSubAgentHandoff(true, state.name, "delegated", detail.Task, nil) msgID, err := r.send("delegate", "delegated", state, title, message, "") @@ -123,21 +123,21 @@ func (r *ioaHandoffRecorder) onSessionStart(event aop.Event) { r.mu.Unlock() } -func (r *ioaHandoffRecorder) onMessage(event aop.Event) { +func (r *ioaHandoffRecorder) onMessage(event *aop.Event) { r.mu.Lock() - toolCallID, ok := r.bySession[event.SessionID] + toolCallID, ok := r.bySession[event.SessionId] r.mu.Unlock() if !ok { return } - data, err := aop.DecodeData[aop.MessageData](event) - if err != nil || data.Role != "assistant" { + data := event.GetMessage() + if data.Role != "assistant" { return } var sb strings.Builder - for _, part := range data.Parts { - if part.Type == aop.PartText { - sb.WriteString(part.Text) + for _, part := range data.Content { + if text := part.GetText().GetText(); text != "" { + sb.WriteString(text) } } if sb.Len() == 0 { @@ -150,24 +150,21 @@ func (r *ioaHandoffRecorder) onMessage(event aop.Event) { r.mu.Unlock() } -func (r *ioaHandoffRecorder) onTurnEnd(event aop.Event) { +func (r *ioaHandoffRecorder) onTurnEnd(event *aop.Event) { r.mu.Lock() - toolCallID, ok := r.bySession[event.SessionID] + toolCallID, ok := r.bySession[event.SessionId] var state *handoffState if ok { state = r.pending[toolCallID] delete(r.pending, toolCallID) - delete(r.bySession, event.SessionID) + delete(r.bySession, event.SessionId) } r.mu.Unlock() if state == nil { return } - data, err := aop.DecodeData[aop.TurnEndData](event) - if err != nil { - return - } - status := data.Stop + data := event.GetTurnEnded() + status := data.StopReason if status == string(agent.StopReasonError) { status = "failed" } @@ -175,8 +172,8 @@ func (r *ioaHandoffRecorder) onTurnEnd(event aop.Event) { status = "completed" } var runErr error - if data.Error != "" { - runErr = errors.New(data.Error) + if data.Error != nil { + runErr = errors.New(data.Error.Message) } title, message := formatSubAgentHandoff(false, state.name, status, state.output, runErr) if _, err := r.send("return", status, state, title, message, state.msgID); err != nil { @@ -235,11 +232,11 @@ func (r *ioaHandoffRecorder) resolveSpace(ctx context.Context) (string, error) { return r.spaceID, nil } -func handoffMode(detail delegation.DelegationDetail) string { - if detail.ContextMode == delegation.DelegationDetailContextModeFork { +func handoffMode(detail ext.DelegationDetail) string { + if detail.ContextMode == ext.DelegationContextFork { return "fork" } - if detail.RunMode == delegation.DelegationDetailRunModeForeground { + if detail.RunMode == ext.DelegationRunForeground { return "sync" } return "async" diff --git a/pkg/runner/subagent_handoff_test.go b/pkg/runner/subagent_handoff_test.go index 554b4598..27f61cc7 100644 --- a/pkg/runner/subagent_handoff_test.go +++ b/pkg/runner/subagent_handoff_test.go @@ -2,13 +2,12 @@ package runner import ( "context" - "encoding/json" "sync" "testing" "time" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/aop/x/delegation" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/ioa/protocols" ) @@ -60,40 +59,37 @@ func waitHandoffBodies(t *testing.T, client *handoffClient, count int) (int, []p return 0, bodies } -func handoffEvent(t *testing.T, typ, sessionID, agentName string, data any) aop.Event { +func handoffEvent(t *testing.T, sessionID, agentName string, event *aop.Event) *aop.Event { t.Helper() - raw, err := json.Marshal(data) - if err != nil { - t.Fatal(err) - } - return aop.Event{Type: typ, SessionID: sessionID, Agent: agentName, Data: raw} + event.SessionId = sessionID + event.Emitter = agentName + return event } func TestIOAHandoffFromAOPBus(t *testing.T) { client := &handoffClient{} - bus := eventbus.New[aop.Event]() + bus := eventbus.New[*aop.Event]() cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) defer cancel() - start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ + start := handoffEvent(t, "child-session", "worker", &aop.Event{Payload: &aop.Event_SessionStarted{SessionStarted: &aop.SessionStarted{ Model: "test-model", - ParentSessionID: "parent-session", - ParentToolCallID: "spawn-1", - }) - if err := delegation.Set(&start, delegation.DelegationDetail{ + ParentSessionId: "parent-session", + ParentToolCallId: "spawn-1", + }}}) + if err := ext.SetDelegation(start, ext.DelegationDetail{ Task: "inspect target", AgentName: "worker", - RunMode: delegation.DelegationDetailRunModeForeground, + RunMode: ext.DelegationRunForeground, }); err != nil { t.Fatal(err) } bus.Emit(start) - bus.Emit(handoffEvent(t, aop.TypeMessage, "child-session", "worker", aop.MessageData{ - MessageID: "m-1", Role: "assistant", - Parts: []aop.MessagePart{{Type: aop.PartText, Text: "inspection complete"}}, - })) - bus.Emit(handoffEvent(t, aop.TypeTurnEnd, "child-session", "worker", aop.TurnEndData{Stop: "completed"})) + bus.Emit(handoffEvent(t, "child-session", "worker", &aop.Event{Payload: &aop.Event_Message{Message: &aop.Message{ + Id: "m-1", Role: "assistant", Content: []*aop.Content{aop.Text("inspection complete")}, + }}})) + bus.Emit(handoffEvent(t, "child-session", "worker", &aop.Event{Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}})) spaceCalls, bodies := waitHandoffBodies(t, client, 2) if spaceCalls != 1 { @@ -136,23 +132,24 @@ func TestIOAHandoffFromAOPBus(t *testing.T) { func TestIOAHandoffFailedRun(t *testing.T) { client := &handoffClient{} - bus := eventbus.New[aop.Event]() + bus := eventbus.New[*aop.Event]() cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) defer cancel() - start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ - ParentSessionID: "parent-session", - ParentToolCallID: "spawn-2", - }) - if err := delegation.Set(&start, delegation.DelegationDetail{ + start := handoffEvent(t, "child-session", "worker", &aop.Event{Payload: &aop.Event_SessionStarted{SessionStarted: &aop.SessionStarted{ + ParentSessionId: "parent-session", ParentToolCallId: "spawn-2", + }}}) + if err := ext.SetDelegation(start, ext.DelegationDetail{ Task: "inspect target", AgentName: "worker", - RunMode: delegation.DelegationDetailRunModeBackground, + RunMode: ext.DelegationRunBackground, }); err != nil { t.Fatal(err) } bus.Emit(start) - bus.Emit(handoffEvent(t, aop.TypeTurnEnd, "child-session", "worker", aop.TurnEndData{Stop: "error", Error: "boom"})) + bus.Emit(handoffEvent(t, "child-session", "worker", &aop.Event{Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{ + StopReason: "error", Error: &aop.ProtocolError{Message: "boom"}, + }}})) _, bodies := waitHandoffBodies(t, client, 2) retMeta, ok := bodies[1].Meta["subagent"].(map[string]any) @@ -166,12 +163,12 @@ func TestIOAHandoffFailedRun(t *testing.T) { func TestIOAHandoffIgnoresNonDelegationSessions(t *testing.T) { client := &handoffClient{} - bus := eventbus.New[aop.Event]() + bus := eventbus.New[*aop.Event]() cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) defer cancel() - bus.Emit(handoffEvent(t, aop.TypeSessionStart, "root-session", "aiscan", aop.SessionStartData{Model: "test-model"})) - bus.Emit(handoffEvent(t, aop.TypeTurnEnd, "root-session", "aiscan", aop.TurnEndData{Stop: "completed"})) + bus.Emit(handoffEvent(t, "root-session", "aiscan", &aop.Event{Payload: &aop.Event_SessionStarted{SessionStarted: &aop.SessionStarted{Model: "test-model"}}})) + bus.Emit(handoffEvent(t, "root-session", "aiscan", &aop.Event{Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}})) deadline := time.Now().Add(200 * time.Millisecond) for time.Now().Before(deadline) { diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go index cede239f..8cd8a401 100644 --- a/pkg/transport/transport.go +++ b/pkg/transport/transport.go @@ -7,7 +7,7 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/runner" - "github.com/chainreactors/aiscan/pkg/webagent" + webagent "github.com/chainreactors/aiscan/pkg/web/agent" ) // Run selects exactly one Agent transport. Session, provider and PTY state stay @@ -20,6 +20,8 @@ func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger, input switch selected { case cfg.AgentTransportWeb: return webagent.RunWebSocket(ctx, option, logger) + case cfg.AgentTransportGRPC: + return webagent.RunGRPC(ctx, option, logger) case cfg.AgentTransportStdio: return runner.RunStdio(ctx, option, logger, input, output) default: diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index 75dee449..329b1d22 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -7,10 +7,10 @@ import ( "strings" "github.com/chainreactors/aiscan/agent" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/aiscan/skills" ) @@ -217,14 +217,14 @@ func redactURLUserinfoFallback(raw string) string { // WebMenuSpecs extracts the web-visible command metadata from a Command list. // Run-control commands (/stop, /followup, /eval, /loop, /exit) are excluded // because the web expresses those through UI controls, not slash text. -func WebMenuSpecs(cmds []Command) []webproto.CommandSpec { +func WebMenuSpecs(cmds []Command) []*transport.CommandSpec { hidden := map[string]bool{"/stop": true, "/continue": true, "/followup": true, "/eval": true, "/loop": true, "/exit": true} - var specs []webproto.CommandSpec + var specs []*transport.CommandSpec for _, c := range cmds { if c.Hidden || hidden[c.Name] { continue } - specs = append(specs, webproto.CommandSpec{ + specs = append(specs, &transport.CommandSpec{ Name: c.Name, Aliases: c.Aliases, Description: c.Description, diff --git a/pkg/tui/format.go b/pkg/tui/format.go index a60a0f4b..1be36d52 100644 --- a/pkg/tui/format.go +++ b/pkg/tui/format.go @@ -12,7 +12,7 @@ import ( "unicode/utf8" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/core/util" "github.com/charmbracelet/glamour" @@ -231,17 +231,20 @@ func formatTokenUsage(u *agent.Usage) string { // Message summarisation helpers // --------------------------------------------------------------------------- -func summarizeMessageData(msg aop.MessageData) (role string, contentLen int, reasoningLen int, preview string) { +func summarizeMessageData(msg *aop.Message) (role string, contentLen int, reasoningLen int, preview string) { + if msg == nil { + return "", 0, 0, "" + } role = msg.Role - for _, p := range msg.Parts { - switch p.Type { - case aop.PartText: - contentLen += len(p.Text) + for _, content := range msg.Content { + switch value := content.Value.(type) { + case *aop.Content_Text: + contentLen += len(value.Text.Text) if preview == "" { - preview = truncate.Clip(p.Text, agentDebugPreviewLimit) + preview = truncate.Clip(value.Text.Text, agentDebugPreviewLimit) } - case aop.PartReasoning: - reasoningLen += len(p.Text) + case *aop.Content_Reasoning: + reasoningLen += len(value.Reasoning.Text) } } return role, contentLen, reasoningLen, preview diff --git a/pkg/tui/output.go b/pkg/tui/output.go index 133ff521..5159f6d4 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -10,9 +10,8 @@ import ( "time" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" - xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" - xeval "github.com/chainreactors/aiscan/core/aop/x/eval" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/truncate" @@ -62,7 +61,7 @@ type AgentOutput struct { // to StreamWriter), the current turn's last complete assistant message, // and usage totals for the turn-end / session-end stat lines. deltas map[string]*deltaAccumulator - lastAssistant aop.MessageData + lastAssistant *aop.Message hasAssistant bool turnUsage *agent.Usage totalUsage agent.Usage @@ -425,7 +424,7 @@ func (o *AgentOutput) SetInteractiveInputActive(active bool) { // Event handling // --------------------------------------------------------------------------- -func (o *AgentOutput) HandleEvent(event aop.Event) { +func (o *AgentOutput) HandleEvent(event *aop.Event) { if o == nil { return } @@ -434,37 +433,38 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { if o.aborted { return } - switch event.Type { - case aop.TypeSessionStart: + switch payload := event.Payload.(type) { + case *aop.Event_SessionStarted: o.agentStart = time.Now() - case aop.TypeTurnStart: + case *aop.Event_TurnStarted: o.agentStart = time.Now() o.runCount++ o.stream.NewTurn() o.turnUsage = nil o.totalUsage = agent.Usage{} o.turnToolCalls = 0 - o.lastAssistant = aop.MessageData{} + o.lastAssistant = nil o.hasAssistant = false if o.canAnimate() { o.live.BeginTurn(o.runCount) } - case aop.TypeMessageDelta: - data, err := aop.DecodeData[aop.MessageDeltaData](event) - if err != nil || data.MessageID == "" { + case *aop.Event_MessageDelta: + data := payload.MessageDelta + if data.MessageId == "" { return } - acc := o.deltas[data.MessageID] + acc := o.deltas[data.MessageId] if acc == nil { acc = &deltaAccumulator{} - o.deltas[data.MessageID] = acc + o.deltas[data.MessageId] = acc } - if data.PartType == aop.PartReasoning { - acc.reasoning += data.Delta - } else { - acc.text += data.Delta + switch value := data.Value.(type) { + case *aop.MessageDelta_Reasoning: + acc.reasoning += value.Reasoning + case *aop.MessageDelta_Text: + acc.text += value.Text } o.live.SetOutputEstimate(estimateStreamTokens(acc.text, acc.reasoning)) contentDelta := o.stream.WouldPrintContentDelta(&acc.text) @@ -491,17 +491,14 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { o.live.NoteDelta(contentDelta) } - case aop.TypeMessage: - data, err := aop.DecodeData[aop.MessageData](event) - if err != nil { - return - } - delete(o.deltas, data.MessageID) + case *aop.Event_Message: + data := payload.Message + delete(o.deltas, data.Id) if data.Role == "assistant" { o.lastAssistant = data o.hasAssistant = true - if event.TurnID == "" { - if content := strings.TrimSpace(messagePartText(data, aop.PartText)); content != "" { + if event.TurnId == "" { + if content := strings.TrimSpace(messagePartText(data, false)); content != "" { if rendered := renderAgentMarkdown(content, o.Markdown()); rendered != "" { fmt.Fprintln(o.Stdout(), rendered) } @@ -509,20 +506,18 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { } } - case aop.TypeToolCall: - data, err := aop.DecodeData[aop.ToolCallData](event) - if err != nil { - return - } + case *aop.Event_ToolCall: + data := payload.ToolCall + args, _ := aop.DecodeJSON[any](data.Arguments) o.turnToolCalls++ o.live.SetTurnToolCalls(o.turnToolCalls) if o.policy.ToolCalls == cfg.OutputCallsHidden || o.quiet() { return } ev := &toolEvent{ - id: data.ToolCallID, - name: data.ToolName, - args: marshalToolArgs(data.Args), + id: data.Id, + name: data.Name, + args: marshalToolArgs(args), startedAt: time.Now(), } if o.canAnimate() { @@ -552,11 +547,8 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { } } - case aop.TypeToolResult: - data, err := aop.DecodeData[aop.ToolResultData](event) - if err != nil { - return - } + case *aop.Event_ToolResult: + data := payload.ToolResult o.toolCallCount++ if data.IsError { o.toolErrorCount++ @@ -565,9 +557,9 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { return } ev := &toolEvent{ - id: data.ToolCallID, - name: data.ToolName, - result: flattenToolResult(data.Content), + id: data.CallId, + name: data.Name, + result: flattenToolResult(data.Output), isError: data.IsError, done: true, elapsed: time.Duration(data.DurationMs) * time.Millisecond, @@ -588,17 +580,14 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { } } - case aop.TypeUsage: - data, err := aop.DecodeData[aop.UsageData](event) - if err != nil { - return - } + case *aop.Event_Usage: + data := payload.Usage usage := agent.Usage{ - PromptTokens: data.InputTokens, - CompletionTokens: data.OutputTokens, - TotalTokens: data.TotalTokens, - CacheReadTokens: data.CacheReadTokens, - CacheWriteTokens: data.CacheWriteTokens, + PromptTokens: int(data.InputTokens), + CompletionTokens: int(data.OutputTokens), + TotalTokens: int(data.TotalTokens), + CacheReadTokens: int(data.Detail["cache_read"]), + CacheWriteTokens: int(data.Detail["cache_write"]), } o.turnUsage = &usage o.totalUsage.PromptTokens += usage.PromptTokens @@ -613,44 +602,38 @@ func (o *AgentOutput) HandleEvent(event aop.Event) { o.live.Render() } - case aop.TypeTurnEnd: - data, err := aop.DecodeData[aop.TurnEndData](event) - if err != nil { - return - } - o.contextTokens = data.ContextTokens + case *aop.Event_TurnEnded: + data := payload.TurnEnded + o.contextTokens = int(data.ContextTokens) o.live.FinishTurn(o.contextTokens) o.stopLive() o.turnEnd(o.runCount) o.agentEnd(data) - case aop.TypeSessionEnd: + case *aop.Event_SessionEnded: o.stopLive() - case aop.TypeStatus: - data, err := aop.DecodeData[aop.StatusData](event) - if err != nil { - return - } + case *aop.Event_Status: + data := payload.Status switch data.State { - case xeval.StateStart: - detail, _, _ := xeval.GetDetail(event) + case ext.EvalStateStart: + detail, _, _ := ext.GetEvalDetail(event) o.stopLive() - o.evalStart(detail.Round) - case xeval.StateEnd: - detail, _, _ := xeval.GetDetail(event) + o.evalStart(int(detail.Round)) + case ext.EvalStateEnd: + detail, _, _ := ext.GetEvalDetail(event) o.stopLive() - o.evalEnd(detail.Round, detail.Pass, detail.Reason) - case xeval.StateError: - detail, _, _ := xeval.GetDetail(event) + o.evalEnd(int(detail.Round), detail.Pass, detail.Reason) + case ext.EvalStateError: + detail, _, _ := ext.GetEvalDetail(event) o.stopLive() - o.evalError(detail.Round, detail.Error) - case xcompact.StateStart: + o.evalError(int(detail.Round), detail.Error) + case ext.CompactStateStart: o.stopLive() o.compactStart() - case xcompact.StateEnd: - detail, _, _ := xcompact.GetDetail(event) + case ext.CompactStateEnd: + detail, _, _ := ext.GetCompactDetail(event) o.stopLive() - o.compactEnd(detail.TokensBefore, detail.TokensAfter, detail.KeptMessages) - case xcompact.StateError: + o.compactEnd(int(detail.TokensBefore), int(detail.TokensAfter), int(detail.KeptMessages)) + case ext.CompactStateError: o.stopLive() o.compactError() } @@ -825,7 +808,7 @@ func (o *AgentOutput) beginRun() { o.toolCallCount = 0 o.toolErrorCount = 0 o.deltas = make(map[string]*deltaAccumulator) - o.lastAssistant = aop.MessageData{} + o.lastAssistant = nil o.hasAssistant = false o.turnUsage = nil o.totalUsage = agent.Usage{} @@ -864,12 +847,12 @@ func (o *AgentOutput) turnEnd(turn int) { w := o.Stderr() if o.policy.ShowReasoning() && o.stream.ReasoningPrinted() == 0 { - if reasoning := strings.TrimSpace(messagePartText(o.lastAssistant, aop.PartReasoning)); reasoning != "" { + if reasoning := strings.TrimSpace(messagePartText(o.lastAssistant, true)); reasoning != "" { o.renderThinkingBlock(w, reasoning) } } if o.stream.ContentPrinted() == 0 { - if content := strings.TrimSpace(messagePartText(o.lastAssistant, aop.PartText)); content != "" { + if content := strings.TrimSpace(messagePartText(o.lastAssistant, false)); content != "" { if rendered := renderAgentMarkdown(content, o.Markdown()); rendered != "" { fmt.Fprintln(o.Stdout(), rendered) } @@ -898,13 +881,13 @@ func (o *AgentOutput) turnEnd(turn int) { } } -func (o *AgentOutput) agentEnd(data aop.TurnEndData) { +func (o *AgentOutput) agentEnd(data *aop.TurnEnded) { o.stream.EnsureNewline() w := o.Stderr() if w != nil && o.debug { elapsed := time.Since(o.agentStart) parts := []string{ - fmt.Sprintf("agent %s", data.Stop), + fmt.Sprintf("agent %s", data.StopReason), } if o.toolCallCount > 0 { toolPart := fmt.Sprintf("tools=%d", o.toolCallCount) @@ -917,8 +900,8 @@ func (o *AgentOutput) agentEnd(data aop.TurnEndData) { parts = append(parts, formatTokenUsage(&o.totalUsage)) } parts = append(parts, util.FormatDuration(elapsed)) - if data.Error != "" { - parts = append(parts, fmt.Sprintf("err=%q", data.Error)) + if data.Error != nil { + parts = append(parts, fmt.Sprintf("err=%q", data.Error.Message)) } fmt.Fprintln(w, o.dim(" ["+strings.Join(parts, " | ")+"]")) } @@ -927,15 +910,15 @@ func (o *AgentOutput) agentEnd(data aop.TurnEndData) { } lastRole, lastContentLen, lastReasoningLen, lastPreview := summarizeMessageData(o.lastAssistant) hint := "" - if data.Stop == string(agent.StopReasonCompleted) && lastRole == "assistant" { + if data.StopReason == string(agent.StopReasonCompleted) && lastRole == "assistant" { hint = " hint=no_tool_calls_no_pending_work" } errText := "" - if data.Error != "" { - errText = fmt.Sprintf(" err=%q", data.Error) + if data.Error != nil { + errText = fmt.Sprintf(" err=%q", data.Error.Message) } fmt.Fprintf(w, "%s[debug] [agent] stop=%s last_role=%s content=%d reasoning=%d tools=%d preview=%q%s%s%s\n", - o.color.Code(output.ANSIDim), data.Stop, + o.color.Code(output.ANSIDim), data.StopReason, lastRole, lastContentLen, lastReasoningLen, o.turnToolCalls, lastPreview, hint, errText, o.color.Code(output.ANSIReset)) } @@ -1088,36 +1071,38 @@ func marshalToolArgs(args any) string { // flattenToolResult reduces a tool.result Content variant (plain string or // ToolResultContent) to its display text; images are not rendered in the TUI. -func flattenToolResult(content any) string { - switch v := content.(type) { - case nil: - return "" - case string: - return v - case aop.ToolResultContent: - return v.Content - case *aop.ToolResultContent: - return v.Content - default: - data, err := json.Marshal(v) - if err != nil { - return fmt.Sprint(v) +func flattenToolResult(content []*aop.Content) string { + var parts []string + for _, part := range content { + if text := part.GetText().GetText(); text != "" { + parts = append(parts, text) + continue + } + if opaque := part.GetOpaque(); opaque != nil { + parts = append(parts, string(opaque.Value.GetData())) } - return string(data) } + return strings.Join(parts, "\n") } // messagePartText joins the text of all parts of one type in a message. -func messagePartText(msg aop.MessageData, partType string) string { +func messagePartText(msg *aop.Message, reasoning bool) string { + if msg == nil { + return "" + } var sb strings.Builder - for _, p := range msg.Parts { - if p.Type != partType || p.Text == "" { + for _, part := range msg.Content { + text := part.GetText().GetText() + if reasoning { + text = part.GetReasoning().GetText() + } + if text == "" { continue } if sb.Len() > 0 { sb.WriteString("\n") } - sb.WriteString(p.Text) + sb.WriteString(text) } return sb.String() } diff --git a/pkg/tui/output_test.go b/pkg/tui/output_test.go index 3561c038..2e9bd002 100644 --- a/pkg/tui/output_test.go +++ b/pkg/tui/output_test.go @@ -2,7 +2,6 @@ package tui import ( "bytes" - "encoding/json" "io" "regexp" "strings" @@ -11,8 +10,8 @@ import ( "time" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" - xeval "github.com/chainreactors/aiscan/core/aop/x/eval" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" ) @@ -50,69 +49,50 @@ func stripANSI(s string) string { // AOP event builders // --------------------------------------------------------------------------- -func aopTestEvent(typ string, data any) aop.Event { - raw, err := json.Marshal(data) - if err != nil { - panic(err) - } - return aop.Event{Type: typ, TurnID: "run-test", Data: raw} +func turnStartEvent(turn int) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_TurnStarted{TurnStarted: &aop.TurnStarted{}}} } -func turnStartEvent(turn int) aop.Event { - return aopTestEvent(aop.TypeTurnStart, aop.TurnStartData{}) +func turnEndEvent(turn, contextTokens int) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{ + StopReason: string(agent.StopReasonCompleted), ContextTokens: uint64(contextTokens), + }}} } -func turnEndEvent(turn, contextTokens int) aop.Event { - return aopTestEvent(aop.TypeTurnEnd, aop.TurnEndData{Stop: string(agent.StopReasonCompleted), ContextTokens: contextTokens}) +func textDeltaEvent(messageID, delta string) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_MessageDelta{MessageDelta: &aop.MessageDelta{ + MessageId: messageID, Value: &aop.MessageDelta_Text{Text: delta}, + }}} } -func textDeltaEvent(messageID, delta string) aop.Event { - return aopTestEvent(aop.TypeMessageDelta, aop.MessageDeltaData{ - MessageID: messageID, - PartType: aop.PartText, - Delta: delta, - }) +func reasoningDeltaEvent(messageID, delta string) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_MessageDelta{MessageDelta: &aop.MessageDelta{ + MessageId: messageID, Value: &aop.MessageDelta_Reasoning{Reasoning: delta}, + }}} } -func reasoningDeltaEvent(messageID, delta string) aop.Event { - return aopTestEvent(aop.TypeMessageDelta, aop.MessageDeltaData{ - MessageID: messageID, - PartType: aop.PartReasoning, - Delta: delta, - }) +func messageEvent(messageID, role string, content ...*aop.Content) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_Message{Message: &aop.Message{ + Id: messageID, Role: role, Content: content, + }}} } -func messageEvent(messageID, role string, parts ...aop.MessagePart) aop.Event { - return aopTestEvent(aop.TypeMessage, aop.MessageData{ - MessageID: messageID, - Role: role, - Parts: parts, - }) +func toolCallEvent(id, name, args string) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_ToolCall{ToolCall: &aop.ToolCall{ + Id: id, Name: name, Arguments: &aop.EncodedValue{Data: []byte(args), MediaType: aop.JSONMediaType}, + }}} } -func toolCallEvent(id, name, args string) aop.Event { - return aopTestEvent(aop.TypeToolCall, aop.ToolCallData{ - ToolCallID: id, - ToolName: name, - Args: args, - }) +func toolResultEvent(id, name, result string, isError bool) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{ + CallId: id, Name: name, Output: []*aop.Content{aop.Text(result)}, IsError: isError, + }}} } -func toolResultEvent(id, name, result string, isError bool) aop.Event { - return aopTestEvent(aop.TypeToolResult, aop.ToolResultData{ - ToolCallID: id, - ToolName: name, - Content: result, - IsError: isError, - }) -} - -func usageEvent(input, outputTok, total int) aop.Event { - return aopTestEvent(aop.TypeUsage, aop.UsageData{ - InputTokens: input, - OutputTokens: outputTok, - TotalTokens: total, - }) +func usageEvent(input, outputTok, total int) *aop.Event { + return &aop.Event{TurnId: "run-test", Payload: &aop.Event_Usage{Usage: &aop.TokenUsage{ + InputTokens: uint64(input), OutputTokens: uint64(outputTok), TotalTokens: uint64(total), + }}} } func testOutput(stderr io.Writer, verbosity int, debug bool) *AgentOutput { @@ -256,7 +236,7 @@ func TestNonTTYMessageUpdateBuffersUntilTurnEnd(t *testing.T) { t.Fatalf("non-TTY update streamed stdout before turn end: %q", stdout.String()) } - o.HandleEvent(messageEvent("m-1", "assistant", aop.MessagePart{Type: aop.PartText, Text: content})) + o.HandleEvent(messageEvent("m-1", "assistant", aop.Text(content))) o.HandleEvent(turnEndEvent(1, 0)) if !strings.Contains(stdout.String(), content) { t.Fatalf("non-TTY turn end did not render content: stdout=%q stderr=%q", stdout.String(), stderr.String()) @@ -684,7 +664,7 @@ func TestReadlineThinkingAppendsWithoutSyntheticNewlines(t *testing.T) { } reasoning := "The user wants me to inspect the image\nthen report" - o.HandleEvent(messageEvent("m-1", "assistant", aop.MessagePart{Type: aop.PartReasoning, Text: reasoning})) + o.HandleEvent(messageEvent("m-1", "assistant", aop.Reasoning(reasoning))) o.HandleEvent(turnEndEvent(1, 0)) if len(committed) != 2 || committed[1] != "then report" { t.Fatalf("final reasoning commits = %#v", committed) @@ -711,7 +691,7 @@ func TestReadlineDefaultDoesNotCommitThinking(t *testing.T) { reasoning := "private chain of thought\nsecond line" o.HandleEvent(turnStartEvent(1)) o.HandleEvent(reasoningDeltaEvent("m-1", reasoning)) - o.HandleEvent(messageEvent("m-1", "assistant", aop.MessagePart{Type: aop.PartReasoning, Text: reasoning})) + o.HandleEvent(messageEvent("m-1", "assistant", aop.Reasoning(reasoning))) o.HandleEvent(turnEndEvent(1, 0)) if joined := strings.Join(committed, "\n"); strings.Contains(joined, "private chain of thought") { @@ -740,7 +720,7 @@ func TestReadlineShowsAndCommitsIntermediateAssistantTextBeforeTool(t *testing.T o.HandleEvent(turnStartEvent(1)) o.HandleEvent(textDeltaEvent("m-1", text)) - o.HandleEvent(messageEvent("m-1", "assistant", aop.MessagePart{Type: aop.PartText, Text: text})) + o.HandleEvent(messageEvent("m-1", "assistant", aop.Text(text))) o.HandleEvent(toolCallEvent("call-1", "bash", `{"command":"scan image.png"}`)) if len(committed) != 1 || !strings.Contains(committed[0], text) { t.Fatalf("intermediate assistant text was not committed before tool: %#v", committed) @@ -766,8 +746,8 @@ func TestReadlineCommitsFinalTextForImageResponse(t *testing.T) { o.HandleEvent(turnStartEvent(1)) o.HandleEvent(messageEvent("m-1", "assistant", - aop.MessagePart{Type: aop.PartText, Text: "The screenshot shows an exposed admin login."}, - aop.MessagePart{Type: aop.PartText, Text: "No credentials are visible."}, + aop.Text("The screenshot shows an exposed admin login."), + aop.Text("No credentials are visible."), )) o.HandleEvent(turnEndEvent(1, 0)) @@ -784,7 +764,7 @@ func TestThinkingBlockFinalRenderingHasNoTags(t *testing.T) { reasoning := "checking target scope\nprobing admin route" o.HandleEvent(turnStartEvent(1)) - o.HandleEvent(messageEvent("m-1", "assistant", aop.MessagePart{Type: aop.PartReasoning, Text: reasoning})) + o.HandleEvent(messageEvent("m-1", "assistant", aop.Reasoning(reasoning))) o.HandleEvent(turnEndEvent(1, 0)) got := stripANSI(stderr.String()) @@ -829,7 +809,7 @@ func TestAgentOutputToolDebugDetails(t *testing.T) { if !strings.Contains(got, "read") || !strings.Contains(got, "docs/usage.md") { t.Fatalf("stderr missing read summary: %q", got) } - if !strings.Contains(got, `raw: {"path":"docs/usage.md","limit":20}`) { + if !strings.Contains(got, `raw: {`) || !strings.Contains(got, `"path":"docs/usage.md"`) || !strings.Contains(got, `"limit":20`) { t.Fatalf("stderr missing compact args in debug mode: %q", got) } if !strings.Contains(got, "file content") { @@ -957,8 +937,8 @@ func TestAgentOutputSeparatesReasoningAndFinalAnswerStreams(t *testing.T) { o.HandleEvent(reasoningDeltaEvent("m-1", reasoning)) o.HandleEvent(textDeltaEvent("m-1", answer+"\n\n")) o.HandleEvent(messageEvent("m-1", "assistant", - aop.MessagePart{Type: aop.PartReasoning, Text: reasoning}, - aop.MessagePart{Type: aop.PartText, Text: answer}, + aop.Reasoning(reasoning), + aop.Text(answer), )) o.HandleEvent(turnEndEvent(1, 0)) @@ -1136,8 +1116,8 @@ func TestEvalEndRendering(t *testing.T) { var stderr syncedBuffer o := testOutput(&stderr, 1, false) - passed := aopTestEvent(aop.TypeStatus, aop.StatusData{State: xeval.StateEnd}) - _ = xeval.SetDetail(&passed, xeval.Detail{Round: 1, Pass: true, Reason: "all checks passed"}) + passed := &aop.Event{Payload: &aop.Event_Status{Status: &aop.Status{State: ext.EvalStateEnd}}} + _ = ext.SetEvalDetail(passed, ext.EvalDetail{Round: 1, Pass: true, Reason: "all checks passed"}) o.HandleEvent(passed) got := stripANSI(stderr.String()) if !strings.Contains(got, "✓") || !strings.Contains(got, "eval") || !strings.Contains(got, "pass") { @@ -1151,8 +1131,8 @@ func TestEvalEndRendering(t *testing.T) { } stderr.Reset() - failed := aopTestEvent(aop.TypeStatus, aop.StatusData{State: xeval.StateEnd}) - _ = xeval.SetDetail(&failed, xeval.Detail{Round: 2, Pass: false, Reason: "port 443 not scanned"}) + failed := &aop.Event{Payload: &aop.Event_Status{Status: &aop.Status{State: ext.EvalStateEnd}}} + _ = ext.SetEvalDetail(failed, ext.EvalDetail{Round: 2, Pass: false, Reason: "port 443 not scanned"}) o.HandleEvent(failed) got = stripANSI(stderr.String()) if !strings.Contains(got, "⟳") || !strings.Contains(got, "fail") { @@ -1177,7 +1157,7 @@ func TestCompleteMessageClearsDeltaAccumulator(t *testing.T) { o.HandleEvent(turnStartEvent(1)) o.HandleEvent(textDeltaEvent("m-1", "hello")) - o.HandleEvent(messageEvent("m-1", "assistant", aop.MessagePart{Type: aop.PartText, Text: "hello"})) + o.HandleEvent(messageEvent("m-1", "assistant", aop.Text("hello"))) if len(o.deltas) != 0 { t.Fatalf("delta accumulator not cleared on complete message: %d entries", len(o.deltas)) } diff --git a/pkg/tui/remote_console.go b/pkg/tui/remote_console.go index 1a03fcbd..f2f2dd82 100644 --- a/pkg/tui/remote_console.go +++ b/pkg/tui/remote_console.go @@ -8,14 +8,14 @@ import ( "sync" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" cfg "github.com/chainreactors/aiscan/core/config" rlterm "github.com/chainreactors/tui/readline/terminal" ) // AOPEventSubscriber connects a console-local renderer to the runtime AOP // bus and returns an unsubscribe function owned by that console attachment. -type AOPEventSubscriber func(func(aop.Event)) func() +type AOPEventSubscriber func(func(*aop.Event)) func() // RunRemoteAgentConsoleWithControl adapts a byte-stream terminal while keeping // event rendering scoped to the attached agent session. @@ -48,8 +48,8 @@ func subscribeAgentOutput(output *AgentOutput, session *agent.Agent, subscribers return func() {} } sessionID := session.SessionID() - return subscribers[0](func(event aop.Event) { - if sessionID == "" || event.SessionID == sessionID { + return subscribers[0](func(event *aop.Event) { + if sessionID == "" || event.SessionId == sessionID { output.HandleEvent(event) } }) diff --git a/pkg/tui/remote_console_test.go b/pkg/tui/remote_console_test.go index d4433705..49bdac08 100644 --- a/pkg/tui/remote_console_test.go +++ b/pkg/tui/remote_console_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" ) func TestSubscribeAgentOutputRestoresSessionEvents(t *testing.T) { @@ -15,22 +15,22 @@ func TestSubscribeAgentOutputRestoresSessionEvents(t *testing.T) { defer output.live.Stop() session := agent.NewAgent(agent.Config{SessionID: "main-repl"}) - var handler func(aop.Event) + var handler func(*aop.Event) unsubscribed := false - unsubscribe := subscribeAgentOutput(output, session, func(fn func(aop.Event)) func() { + unsubscribe := subscribeAgentOutput(output, session, func(fn func(*aop.Event)) func() { handler = fn return func() { unsubscribed = true } }) other := turnStartEvent(1) - other.SessionID = "other-session" + other.SessionId = "other-session" handler(other) if liveRunning(output.live) { t.Fatal("output consumed an event from another runtime session") } current := turnStartEvent(1) - current.SessionID = "main-repl" + current.SessionId = "main-repl" handler(current) if !liveRunning(output.live) { t.Fatal("session turn.start did not restore the thinking status") diff --git a/pkg/webagent/agent.go b/pkg/web/agent/agent.go similarity index 71% rename from pkg/webagent/agent.go rename to pkg/web/agent/agent.go index 9244c832..6b22970c 100644 --- a/pkg/webagent/agent.go +++ b/pkg/web/agent/agent.go @@ -1,8 +1,7 @@ -package webagent +package agent import ( "context" - "encoding/base64" "encoding/json" "fmt" "os" @@ -10,16 +9,24 @@ import ( "strings" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/runner" - "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" ) func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { + return runRemoteAgent(ctx, option, logger, false) +} + +func RunGRPC(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { + return runRemoteAgent(ctx, option, logger, true) +} + +func runRemoteAgent(ctx context.Context, option *cfg.Option, logger telemetry.Logger, grpcTransport bool) error { if option.WebURL != "" { remoteOpt, err := fetchRemoteConfig(option.WebURL) if err != nil { @@ -67,9 +74,13 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg go func() { defer close(connectionDone) _ = application.WaitEngines(ctx) - logger.Debugf("websocket transport connection to %s", option.WebURL) + transportName := "websocket" + if grpcTransport { + transportName = "grpc" + } + logger.Debugf("%s transport connection to %s", transportName, option.WebURL) - _ = connect(ctx, connectionConfig{ + connection := connectionConfig{ ServerURL: option.WebURL, Name: runner.ResolveIOANodeName(option), Registry: application.Commands, @@ -80,10 +91,15 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg Chat: chatHandler, Node: identityRef, Runtime: DefaultRuntime(), - Status: func() webproto.AgentStatus { return agentStatus(option, application) }, - Menu: func() []webproto.CommandSpec { return agentCommandCatalog(application) }, + Status: func() *transport.AgentStatus { return agentStatus(option, application) }, + Menu: func() []*transport.CommandSpec { return agentCommandCatalog(application) }, PTYRouter: func() (*pty.Router, error) { return NewPTYRouter(application.Commands), nil }, - }) + } + if grpcTransport { + _ = connectGenerated(ctx, connection, true) + } else { + _ = connect(ctx, connection) + } }() if application.Provider == nil { @@ -98,7 +114,7 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg return err } if task == "" { - logger.Infof("websocket transport connected; remote REPL and PTY are available") + logger.Infof("remote transport connected; remote REPL and PTY are available") <-ctx.Done() <-connectionDone return nil @@ -108,7 +124,7 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg if err != nil { return err } - run, err := rt.RunSession(ctx, "startup", runner.RunInput{TurnID: "startup", Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}}) + run, err := rt.RunSession(ctx, "startup", runner.RunInput{TurnID: "startup", Content: []*aop.Content{aop.Text(task)}}) if err == nil { _, err = run.Wait() } @@ -130,26 +146,65 @@ type chatAgentHandler struct { logger telemetry.Logger } -func (h *chatAgentHandler) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { - return h.rt != nil && h.rt.HandleProtocol(ctx, msg, send) +func (h *chatAgentHandler) OpenSession(ctx context.Context, req *aop.OpenSessionRequest) *aop.OpenSessionResponse { + return h.rt.OpenAOPSession(req) +} + +func (h *chatAgentHandler) RunTurn(ctx context.Context, req *aop.RunTurnRequest) *aop.RunTurnResponse { + return h.rt.RunAOPTurn(ctx, req) +} + +func (h *chatAgentHandler) CancelTurn(req *aop.CancelTurnRequest) *aop.CancelTurnResponse { + return h.rt.CancelAOPTurn(req) +} + +func (h *chatAgentHandler) CloseSession(ctx context.Context, req *aop.CloseSessionRequest) *aop.CloseSessionResponse { + return h.rt.CloseAOPSession(ctx, req) +} + +func (h *chatAgentHandler) Command(ctx context.Context, req *transport.CommandRequest) (*transport.CommandResult, error) { + if h.rt == nil || req == nil || strings.TrimSpace(req.Line) == "" { + return nil, fmt.Errorf("command line is required") + } + result, err := h.rt.CommandSession(ctx, req.SessionId, req.Line) + if err != nil { + return nil, err + } + encoded, err := json.Marshal(result) + if err != nil { + return nil, err + } + return &transport.CommandResult{TaskId: req.TaskId, Result: encoded, MediaType: "application/json"}, nil } -func (h *chatAgentHandler) HandleUpload(msg webproto.Message, send func(webproto.Message)) { - handleFileUpload(msg, send) +func (h *chatAgentHandler) Upload(req *transport.FileUploadRequest) (*transport.FileResult, error) { + if req == nil { + return nil, fmt.Errorf("upload request is required") + } + filename := filepath.Base(strings.TrimSpace(req.Filename)) + if filename == "." || filename == "" { + filename = "upload" + } + dir := filepath.Join(os.TempDir(), "aiscan-uploads") + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + dest := filepath.Join(dir, filename) + if err := os.WriteFile(dest, req.Data, 0o644); err != nil { + return nil, err + } + return &transport.FileResult{TaskId: req.TaskId, Filename: filename, Path: dest, Size: int64(len(req.Data))}, nil } -func (h *chatAgentHandler) HandleConfigReload(serverURL string, send func(webproto.Message)) { +func (h *chatAgentHandler) ReloadConfig(serverURL string) (*transport.ConfigReloadResult, *transport.AgentStatus) { provider, model, err := reloadAgentConfig(serverURL, h.rt, h.app, h.logger) - result := webproto.ConfigReloadResult{OK: err == nil, Model: model} + result := &transport.ConfigReloadResult{Ok: err == nil, Model: model} if err != nil { result.Error = err.Error() - } else { - result.Provider = provider.Name() - statusPayload, _ := json.Marshal(agentStatus(h.option, h.app)) - send(webproto.Message{Type: "agent.status", Payload: statusPayload}) + return result, nil } - resultPayload, _ := json.Marshal(result) - send(webproto.Message{Type: "config.result", Payload: resultPayload}) + result.Provider = provider.Name() + return result, agentStatus(h.option, h.app) } // --------------------------------------------------------------------------- @@ -191,53 +246,6 @@ func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, app *runner.Ap return provider, model, nil } -// --------------------------------------------------------------------------- -// File upload -// --------------------------------------------------------------------------- - -func handleFileUpload(msg webproto.Message, send func(webproto.Message)) { - var payload webproto.FileUploadPayload - if len(msg.Payload) > 0 { - _ = json.Unmarshal(msg.Payload, &payload) - } - if payload.Filename == "" { - payload.Filename = "upload" - } - - data, err := base64.StdEncoding.DecodeString(msg.DataB64) - if err != nil { - send(webproto.Message{ - Type: "complete", - TaskID: msg.TaskID, - Payload: webproto.MustJSON(webproto.FileUploadResult{Filename: payload.Filename, Error: "decode failed: " + err.Error()}), - }) - return - } - - dir := filepath.Join(os.TempDir(), "aiscan-uploads") - _ = os.MkdirAll(dir, 0o755) - dest := filepath.Join(dir, payload.Filename) - - if err := os.WriteFile(dest, data, 0o644); err != nil { - send(webproto.Message{ - Type: "complete", - TaskID: msg.TaskID, - Payload: webproto.MustJSON(webproto.FileUploadResult{Filename: payload.Filename, Error: "write failed: " + err.Error()}), - }) - return - } - - send(webproto.Message{ - Type: "complete", - TaskID: msg.TaskID, - Payload: webproto.MustJSON(webproto.FileUploadResult{ - Filename: payload.Filename, - Path: dest, - Size: int64(len(data)), - }), - }) -} - // --------------------------------------------------------------------------- // Identity and command catalog (agent-specific, needs runner.AgentRuntime) // --------------------------------------------------------------------------- @@ -246,7 +254,7 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) { // hub on register: the static agent-scope menu commands plus one per loaded (and // non-internal) skill. The hub merges it with its hub-scope commands to build // the web "/" menu and /help, so the menu reflects what this agent can run. -func agentCommandCatalog(app *runner.App) []webproto.CommandSpec { +func agentCommandCatalog(app *runner.App) []*transport.CommandSpec { specs := runner.RuntimeCommandSpecs() if app == nil || app.Skills == nil { return specs @@ -255,7 +263,7 @@ func agentCommandCatalog(app *runner.App) []webproto.CommandSpec { if strings.TrimSpace(sk.Name) == "" || sk.Internal { continue } - specs = append(specs, webproto.CommandSpec{ + specs = append(specs, &transport.CommandSpec{ Name: "/skill:" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"), Description: sk.Description, }) @@ -263,8 +271,8 @@ func agentCommandCatalog(app *runner.App) []webproto.CommandSpec { return specs } -func agentStatus(option *cfg.Option, app *runner.App) webproto.AgentStatus { - var status webproto.AgentStatus +func agentStatus(option *cfg.Option, app *runner.App) *transport.AgentStatus { + status := new(transport.AgentStatus) if option != nil { status.Space = option.Space } diff --git a/pkg/web/agent/agent_test.go b/pkg/web/agent/agent_test.go new file mode 100644 index 00000000..85c98a2b --- /dev/null +++ b/pkg/web/agent/agent_test.go @@ -0,0 +1,23 @@ +package agent + +import ( + "testing" + + cfg "github.com/chainreactors/aiscan/core/config" +) + +func TestWebNodeRefUsesWebIdentity(t *testing.T) { + ref, err := webNodeRef(&cfg.Option{ + AgentOptions: cfg.AgentOptions{WebURL: "https://secret@example.test/hub"}, + IOAOptions: cfg.IOAOptions{IOANodeName: "worker-1"}, + }) + if err != nil { + t.Fatal(err) + } + if ref.ID != "worker-1" || ref.Authority != "https://example.test/hub" { + t.Fatalf("node ref = %#v", ref) + } + if _, err := webNodeRef(&cfg.Option{AgentOptions: cfg.AgentOptions{WebURL: "https://example.test"}}); err == nil { + t.Fatal("expected missing ioa.node_name error") + } +} diff --git a/pkg/webagent/aop_tool.go b/pkg/web/agent/aop_tool.go similarity index 56% rename from pkg/webagent/aop_tool.go rename to pkg/web/agent/aop_tool.go index 9a6ff8ad..53cc70ab 100644 --- a/pkg/webagent/aop_tool.go +++ b/pkg/web/agent/aop_tool.go @@ -1,19 +1,16 @@ -package webagent +package agent import ( "bytes" "context" - "encoding/json" - "fmt" "strings" "time" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" ) type aopToolExecutor interface { @@ -33,67 +30,22 @@ type foregroundTool interface { RunForegroundTool(context.Context, string, commands.BashExecOptions) (tool.Result, error) } -// HandleToolCallEvent executes one direct AOP tool.call and returns its -// terminal tool.result through the same AOP envelope. -func HandleToolCallEvent(ctx context.Context, msg webproto.Message, event aop.Event, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent], send func(webproto.Message)) { - sendError := func(err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload}) - } - if !event.Valid() { - sendError(fmt.Errorf("invalid inbound AOP event")) - return - } - if event.Type != aop.TypeToolCall { - sendError(fmt.Errorf("unsupported inbound AOP event %q", event.Type)) - return - } - call, err := aop.DecodeData[aop.ToolCallData](event) - if err != nil { - sendError(fmt.Errorf("decode tool.call: %w", err)) - return - } - if msg.TaskID == "" || call.ToolCallID != msg.TaskID { - sendError(fmt.Errorf("tool.call correlation requires task_id == tool_call_id")) - return - } - if strings.TrimSpace(call.ToolName) == "" { - sendError(fmt.Errorf("tool.call tool_name is required")) - return - } - if call.WorkDir != "" { - ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: call.WorkDir}) - } - callID := msg.TaskID - ctx = output.ContextWithCallID(ctx, callID) - - started := time.Now() - result, execErr := executeCall(ctx, executor, call, dataBus, callID) - data := aop.ToolResultDataFromResult(call, result, execErr, time.Since(started)) - raw, _ := json.Marshal(data) - event.Type = aop.TypeToolResult - event.TS = time.Now().UTC().Format(time.RFC3339Nano) - event.Data = raw - payload, _ := json.Marshal(event) - send(webproto.Message{Type: webproto.TypeAOP, TaskID: callID, TurnID: event.TurnID, Payload: payload}) -} - // executeCall runs the tool call. Tools with foreground capability stream // stdout lines as tool.data progress events on dataBus while running; all // other tools take the plain ExecuteTool path. -func executeCall(ctx context.Context, executor aopToolExecutor, call aop.ToolCallData, dataBus *eventbus.Bus[output.ToolDataEvent], callID string) (tool.Result, error) { - arguments, err := json.Marshal(call.Args) - if err != nil { +func executeCall(ctx context.Context, executor aopToolExecutor, call *aop.ToolCall, dataBus *eventbus.Bus[output.ToolDataEvent], callID string) (tool.Result, error) { + arguments := call.GetArguments().GetData() + if len(arguments) == 0 { arguments = []byte("{}") } if resolver, ok := executor.(toolResolver); ok { - if resolved, ok := resolver.GetTool(call.ToolName); ok { + if resolved, ok := resolver.GetTool(call.Name); ok { if fg, ok := resolved.(foregroundTool); ok { args, err := tool.ParseArgs[commands.BashArgs](string(arguments)) if err != nil { return tool.Result{}, err } - progress := newProgressStreamer(dataBus, call.ToolName, callID) + progress := newProgressStreamer(dataBus, call.Name, callID) result, err := fg.RunForegroundTool(ctx, args.Command, commands.BashExecOptions{ Timeout: time.Duration(args.Timeout) * time.Second, OnOutput: progress.Write, @@ -103,7 +55,7 @@ func executeCall(ctx context.Context, executor aopToolExecutor, call aop.ToolCal } } } - return executor.ExecuteTool(ctx, call.ToolName, string(arguments)) + return executor.ExecuteTool(ctx, call.Name, string(arguments)) } // progressStreamer splits raw command output into lines and publishes each diff --git a/pkg/web/agent/aop_tool_test.go b/pkg/web/agent/aop_tool_test.go new file mode 100644 index 00000000..fa7a25b6 --- /dev/null +++ b/pkg/web/agent/aop_tool_test.go @@ -0,0 +1,151 @@ +package agent + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "testing" + "time" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/tool" + "github.com/chainreactors/aiscan/pkg/commands" +) + +type aopTestExecutor struct{} + +func (aopTestExecutor) ExecuteTool(_ context.Context, name, arguments string) (tool.Result, error) { + return tool.TextResult(name + ":" + arguments), nil +} + +type structuredResultExecutor struct { + err error +} + +func (e structuredResultExecutor) ExecuteTool(context.Context, string, string) (tool.Result, error) { + return tool.Result{ + Content: []tool.ContentBlock{ + tool.TextBlock("partial"), + tool.ImageBlock("image/png", base64.StdEncoding.EncodeToString([]byte("image"))), + }, + Details: map[string]any{"ports": float64(3)}, + IsError: e.err == nil, + Terminate: true, + }, e.err +} + +func TestExecuteToolRequestPreservesStructuredResult(t *testing.T) { + event, err := executeToolRequest(context.Background(), toolRequest(t, "call-structured", "scan", nil), structuredResultExecutor{}, nil) + if err != nil { + t.Fatal(err) + } + result := event.GetToolResult() + if !result.IsError || !result.Terminate || result.DurationMs > uint64(time.Minute.Milliseconds()) { + t.Fatalf("result flags = %+v", result) + } + if len(result.Output) != 2 || result.Output[0].GetText().GetText() != "partial" || string(result.Output[1].GetMedia().GetResource().GetData()) != "image" { + t.Fatalf("result output = %+v", result.Output) + } + detail, err := aop.DecodeJSON[map[string]float64](result.Detail) + if err != nil || detail["ports"] != 3 { + t.Fatalf("detail = %+v, err=%v", detail, err) + } +} + +func TestExecuteToolRequestUsesExecutionErrorText(t *testing.T) { + event, err := executeToolRequest(context.Background(), toolRequest(t, "call-error", "scan", nil), structuredResultExecutor{err: errors.New("failed")}, nil) + if err != nil { + t.Fatal(err) + } + result := event.GetToolResult() + if !result.IsError || result.Output[0].GetText().GetText() != "failed" { + t.Fatalf("result = %+v", result) + } +} + +func toolRequest(t *testing.T, id, name string, arguments map[string]any) *transport.ToolCallRequest { + t.Helper() + value, err := aop.JSONValue(arguments) + if err != nil { + t.Fatal(err) + } + return &transport.ToolCallRequest{TaskId: id, SessionId: "session-1", TurnId: "turn-1", Call: &aop.ToolCall{Id: id, Name: name, Arguments: value}} +} + +func TestExecuteToolRequest(t *testing.T) { + event, err := executeToolRequest(context.Background(), toolRequest(t, "call-1", "echo", map[string]any{"value": "hello"}), aopTestExecutor{}, nil) + if err != nil { + t.Fatal(err) + } + result := event.GetToolResult() + if result.CallId != "call-1" || result.Name != "echo" || !strings.Contains(result.Output[0].GetText().Text, "echo") { + t.Fatalf("result = %+v", result) + } +} + +func TestExecuteToolRequestRejectsMismatchedCorrelation(t *testing.T) { + request := toolRequest(t, "call-1", "echo", map[string]any{"value": "hello"}) + request.TaskId = "other" + if _, err := executeToolRequest(context.Background(), request, aopTestExecutor{}, nil); err == nil { + t.Fatal("expected correlation error") + } +} + +type recordingBash struct { + command string + options commands.BashExecOptions +} + +func (*recordingBash) Name() string { return "bash" } +func (*recordingBash) Description() string { return "test bash" } +func (*recordingBash) Definition() tool.Definition { + return tool.Def("bash", "test bash", struct { + Command string `json:"command"` + }{}) +} +func (*recordingBash) Execute(context.Context, string) (tool.Result, error) { + return tool.Result{}, nil +} +func (b *recordingBash) RunForegroundTool(_ context.Context, command string, options commands.BashExecOptions) (tool.Result, error) { + b.command = command + b.options = options + options.OnOutput([]byte("streamed\n")) + result := tool.TextResult("streamed") + result.Details = &output.Result{Summary: output.Summary{Targets: 2}} + return result, nil +} + +func TestExecuteToolRequestForeground(t *testing.T) { + registry := commands.NewRegistry() + bash := &recordingBash{} + registry.RegisterTool(bash) + dataBus := eventbus.New[output.ToolDataEvent]() + var progress []output.ToolDataEvent + dataBus.Subscribe(func(event output.ToolDataEvent) { + if event.Kind == output.ToolDataProgress { + progress = append(progress, event) + } + }) + event, err := executeToolRequest(context.Background(), toolRequest(t, "task-1", "bash", map[string]any{"command": "echo test", "timeout": 7}), registry, dataBus) + if err != nil { + t.Fatal(err) + } + if bash.command != "echo test" || bash.options.Timeout != 7*time.Second { + t.Fatalf("bash options = %+v", bash.options) + } + if len(progress) != 1 || progress[0].Data != "streamed" || progress[0].CallID != "task-1" { + t.Fatalf("progress = %+v", progress) + } + result := event.GetToolResult() + if result.IsError || result.Output[0].GetText().Text != "streamed" { + t.Fatalf("result = %+v", result) + } + structured, err := aop.DecodeJSON[output.Result](result.Detail) + if err != nil || structured.Summary.Targets != 2 { + t.Fatalf("detail = %+v, err=%v", structured, err) + } +} diff --git a/pkg/web/agent/connection.go b/pkg/web/agent/connection.go new file mode 100644 index 00000000..fa378935 --- /dev/null +++ b/pkg/web/agent/connection.go @@ -0,0 +1,50 @@ +package agent + +import ( + "context" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/ioa/protocols" + "github.com/chainreactors/utils/pty" +) + +const DefaultWSPath = "/api/agent/ws" + +type connectionConfig struct { + ServerURL string + WSPath string + Name string + Token string + + Registry *commands.CommandRegistry + AgentSubscribe func(func(*aop.Event)) func() + DataBus *eventbus.Bus[output.ToolDataEvent] + SCO *output.SCOSidecar + Logger telemetry.Logger + Chat chatHandler + Node protocols.NodeRef + Runtime *transport.AgentRuntimeInfo + Status func() *transport.AgentStatus + Menu func() []*transport.CommandSpec + RunnerFileRPC bool + PTYRouter func() (*pty.Router, error) +} + +type chatHandler interface { + OpenSession(context.Context, *aop.OpenSessionRequest) *aop.OpenSessionResponse + RunTurn(context.Context, *aop.RunTurnRequest) *aop.RunTurnResponse + CancelTurn(*aop.CancelTurnRequest) *aop.CancelTurnResponse + CloseSession(context.Context, *aop.CloseSessionRequest) *aop.CloseSessionResponse + Command(context.Context, *transport.CommandRequest) (*transport.CommandResult, error) + Upload(*transport.FileUploadRequest) (*transport.FileResult, error) + ReloadConfig(string) (*transport.ConfigReloadResult, *transport.AgentStatus) +} + +func connect(ctx context.Context, config connectionConfig) error { + return connectGenerated(ctx, config, false) +} diff --git a/pkg/web/agent/connection_lifecycle_test.go b/pkg/web/agent/connection_lifecycle_test.go new file mode 100644 index 00000000..0d0580a1 --- /dev/null +++ b/pkg/web/agent/connection_lifecycle_test.go @@ -0,0 +1,82 @@ +package agent + +import ( + "context" + "fmt" + "io" + "sync" + "testing" + "time" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/ioa/protocols" +) + +type disconnectChatHandler struct { + started chan struct{} + canceled chan struct{} + once sync.Once +} + +func (h *disconnectChatHandler) OpenSession(context.Context, *aop.OpenSessionRequest) *aop.OpenSessionResponse { + return nil +} +func (h *disconnectChatHandler) RunTurn(ctx context.Context, request *aop.RunTurnRequest) *aop.RunTurnResponse { + h.once.Do(func() { close(h.started) }) + go func() { <-ctx.Done(); close(h.canceled) }() + return &aop.RunTurnResponse{RequestId: request.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: request.SessionId, TurnId: request.TurnId}}} +} +func (*disconnectChatHandler) CancelTurn(*aop.CancelTurnRequest) *aop.CancelTurnResponse { return nil } +func (*disconnectChatHandler) CloseSession(context.Context, *aop.CloseSessionRequest) *aop.CloseSessionResponse { + return nil +} +func (*disconnectChatHandler) Command(context.Context, *transport.CommandRequest) (*transport.CommandResult, error) { + return nil, fmt.Errorf("unused") +} +func (*disconnectChatHandler) Upload(*transport.FileUploadRequest) (*transport.FileResult, error) { + return nil, fmt.Errorf("unused") +} +func (*disconnectChatHandler) ReloadConfig(string) (*transport.ConfigReloadResult, *transport.AgentStatus) { + return nil, nil +} + +type disconnectStream struct { + ctx context.Context + handler *disconnectChatHandler + index int +} + +func (s *disconnectStream) Context() context.Context { return s.ctx } +func (*disconnectStream) Send(*transport.AgentFrame) error { return nil } +func (s *disconnectStream) Recv() (*transport.ServerFrame, error) { + s.index++ + switch s.index { + case 1: + return &transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{AgentId: "worker"}}}, nil + case 2: + return &transport.ServerFrame{CorrelationId: "turn-1", Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{RequestId: "turn-1", SessionId: "chat-1", TurnId: "turn-1", Input: &aop.Message{Role: "user"}}}}, nil + default: + select { + case <-s.handler.started: + return nil, io.EOF + case <-time.After(time.Second): + return nil, fmt.Errorf("chat handler did not start") + } + } +} + +func TestAgentConnectionCancelsChatWhenStreamDisconnects(t *testing.T) { + handler := &disconnectChatHandler{started: make(chan struct{}), canceled: make(chan struct{})} + err := serveAgentConnection(context.Background(), connectionConfig{Name: "worker", Registry: commands.NewRegistry(), Chat: handler, Node: protocols.NodeRef{ID: "worker", Authority: "local"}}, telemetry.NopLogger(), &disconnectStream{ctx: context.Background(), handler: handler}) + if err == nil { + t.Fatal("connection returned nil after disconnect") + } + select { + case <-handler.canceled: + case <-time.After(500 * time.Millisecond): + t.Fatal("chat context remained alive after disconnect") + } +} diff --git a/pkg/web/agent/exec_test.go b/pkg/web/agent/exec_test.go new file mode 100644 index 00000000..a47d666c --- /dev/null +++ b/pkg/web/agent/exec_test.go @@ -0,0 +1,37 @@ +package agent + +import ( + "context" + "runtime" + "testing" + + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" +) + +func TestExecRequestCompletesWithOutput(t *testing.T) { + command := "printf hello" + if runtime.GOOS == "windows" { + command = "echo|set /p=hello" + } + var frames []*transport.AgentFrame + handleExecRequest(context.Background(), &transport.ExecRequest{TaskId: "exec-1", Command: command, TimeoutSeconds: 5}, t.TempDir(), func(frame *transport.AgentFrame) { frames = append(frames, frame) }) + if len(frames) != 2 || string(frames[0].GetExecOutput().Data) != "hello" || frames[1].GetExecResult().State != "completed" { + t.Fatalf("unexpected frames: %#v", frames) + } +} + +func TestExecRequestReportsExitCode(t *testing.T) { + command := "exit 7" + if runtime.GOOS == "windows" { + command = "exit /b 7" + } + var result *transport.ExecResult + handleExecRequest(context.Background(), &transport.ExecRequest{TaskId: "exec-2", Command: command, TimeoutSeconds: 5}, t.TempDir(), func(frame *transport.AgentFrame) { + if frame.GetExecResult() != nil { + result = frame.GetExecResult() + } + }) + if result == nil || result.ExitCode != 7 { + t.Fatalf("result = %+v, want exit code 7", result) + } +} diff --git a/pkg/web/agent/file_test.go b/pkg/web/agent/file_test.go new file mode 100644 index 00000000..edd131f7 --- /dev/null +++ b/pkg/web/agent/file_test.go @@ -0,0 +1,59 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" + + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" +) + +func TestDefaultAgentRuntimeDoesNotAdvertiseRunnerFileRPCs(t *testing.T) { + for _, capability := range DefaultRuntime().Capabilities { + if capability == "file.list" || capability == "file.mkdir" { + t.Fatalf("regular agent advertised runner-only capability %q", capability) + } + } +} + +func TestFileListReturnsStructuredEntries(t *testing.T) { + base := t.TempDir() + if err := os.WriteFile(filepath.Join(base, "note.txt"), []byte("body"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(base, "nested"), 0o755); err != nil { + t.Fatal(err) + } + value := fileList(&transport.FileListRequest{TaskId: "list-1", Path: "."}, base) + if value.err != nil { + t.Fatal(value.err) + } + if value.result.Path != "." || len(value.result.Entries) != 2 { + t.Fatalf("result = %+v", value.result) + } + byName := map[string]*transport.FileEntry{} + for _, entry := range value.result.Entries { + byName[entry.Name] = entry + } + if byName["note.txt"].IsDirectory || byName["note.txt"].Size != 4 { + t.Fatalf("file entry = %+v", byName["note.txt"]) + } + if !byName["nested"].IsDirectory { + t.Fatalf("directory entry = %+v", byName["nested"]) + } +} + +func TestNativeFileRPCsResolveRelativeToRuntimeWorkdir(t *testing.T) { + base := t.TempDir() + if value := fileMkdir(&transport.FileMkdirRequest{TaskId: "mkdir-1", Path: "nested"}, base); value.err != nil { + t.Fatal(value.err) + } + path := filepath.Join("nested", "proof.txt") + if value := fileWrite(&transport.FileWriteRequest{TaskId: "write-1", Path: path, Data: []byte("hello")}, base); value.err != nil { + t.Fatal(value.err) + } + value := fileRead(&transport.FileReadRequest{TaskId: "read-1", Path: path}, base) + if value.err != nil || string(value.result.Data) != "hello" { + t.Fatalf("read data = %q, err = %v", value.result.Data, value.err) + } +} diff --git a/pkg/web/agent/identity.go b/pkg/web/agent/identity.go new file mode 100644 index 00000000..c080a17e --- /dev/null +++ b/pkg/web/agent/identity.go @@ -0,0 +1,103 @@ +package agent + +import ( + "fmt" + "net/url" + "os" + "os/user" + "runtime" + "strings" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/ioa/protocols" +) + +// DefaultRuntime returns OS process metadata without introducing another +// identity beside the IOA NodeRef. +func DefaultRuntime() *transport.AgentRuntimeInfo { + metadata, _ := aop.JSONValue(map[string]any{"client": "aiscan", "transport": "websocket"}) + runtimeInfo := &transport.AgentRuntimeInfo{ + Os: runtime.GOOS, + Arch: runtime.GOARCH, + Pid: int32(os.Getpid()), + Capabilities: []string{"repl", "pty", "tmux", "ioa"}, + Metadata: metadata, + } + if host, err := os.Hostname(); err == nil { + runtimeInfo.Hostname = host + } + if wd, err := os.Getwd(); err == nil { + runtimeInfo.WorkingDir = wd + } + if current, err := user.Current(); err == nil && current != nil { + runtimeInfo.Username = current.Username + } + return runtimeInfo +} + +// BuildHello builds the transport-native agent registration frame. +func BuildHello(name string, reg *commands.CommandRegistry, ref protocols.NodeRef, runtimeInfo *transport.AgentRuntimeInfo, statusFn func() *transport.AgentStatus, menuFn func() []*transport.CommandSpec, stats *transport.AgentStats) (*transport.AgentHello, error) { + if !ref.Valid() { + return nil, fmt.Errorf("valid node reference is required") + } + if runtimeInfo == nil || runtimeInfo.Os == "" { + runtimeInfo = DefaultRuntime() + } + var status *transport.AgentStatus + if statusFn != nil { + status = statusFn() + } + if status == nil { + status = &transport.AgentStatus{} + } + if stats == nil { + stats = &transport.AgentStats{} + } + var menu []*transport.CommandSpec + if menuFn != nil { + menu = menuFn() + } + hello := &transport.AgentHello{ + AgentId: ref.ID, Authority: ref.Authority, Name: name, + Commands: reg.Names(), CommandMenu: menu, Runtime: runtimeInfo, Status: status, Stats: stats, + } + for _, definition := range reg.ToolDefinitions() { + schema, _ := aop.JSONValue(definition.Function.Parameters) + hello.Tools = append(hello.Tools, &transport.ToolDefinition{ + Type: definition.Type, Name: definition.Function.Name, + Description: definition.Function.Description, InputSchema: schema, + }) + } + return hello, nil +} + +// SplitAccessKey lifts the access token out of a URL's userinfo +// (http://@host...), returning a userinfo-free URL plus the token. +// A URL without userinfo (or an unparseable one) comes back unchanged +// with an empty token. +func SplitAccessKey(rawURL string) (dialURL, token string) { + u, err := url.Parse(rawURL) + if err != nil || u.User == nil { + return rawURL, "" + } + token = u.User.Username() + u.User = nil + return u.String(), token +} + +// HTTPToWS converts an HTTP(S) URL to a WS(S) URL. +func HTTPToWS(rawURL string) string { + u, err := url.Parse(strings.TrimRight(rawURL, "/")) + if err != nil { + return rawURL + } + switch u.Scheme { + case "https": + u.Scheme = "wss" + default: + u.Scheme = "ws" + } + return u.String() +} diff --git a/pkg/web/agent/proto_connection.go b/pkg/web/agent/proto_connection.go new file mode 100644 index 00000000..4b9ea65f --- /dev/null +++ b/pkg/web/agent/proto_connection.go @@ -0,0 +1,584 @@ +package agent + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/chainreactors/aiscan/agent" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/tool" + terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" + "github.com/chainreactors/utils/pty" + "github.com/gorilla/websocket" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/encoding/protojson" + protobuf "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type AgentServerStream interface { + Context() context.Context + Recv() (*transport.ServerFrame, error) + Send(*transport.AgentFrame) error +} + +type closeableAgentServerStream interface { + AgentServerStream + Close() error +} + +type webSocketServerStream struct { + ctx context.Context + conn *websocket.Conn + mu sync.Mutex +} + +func (s *webSocketServerStream) Context() context.Context { return s.ctx } +func (s *webSocketServerStream) Close() error { return s.conn.Close() } +func (s *webSocketServerStream) Recv() (*transport.ServerFrame, error) { + _, data, err := s.conn.ReadMessage() + if err != nil { + return nil, err + } + frame := new(transport.ServerFrame) + if err := protojson.Unmarshal(data, frame); err != nil { + return nil, fmt.Errorf("decode server frame: %w", err) + } + return frame, nil +} +func (s *webSocketServerStream) Send(frame *transport.AgentFrame) error { + data, err := protojson.Marshal(frame) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.conn.WriteMessage(websocket.TextMessage, data) +} + +type grpcServerStream struct { + transport.AgentTransportService_ConnectClient + conn *grpc.ClientConn +} + +func (s *grpcServerStream) Close() error { return s.conn.Close() } + +func dialProtoWebSocket(ctx context.Context, cc connectionConfig) (closeableAgentServerStream, error) { + dialURL, accessKey := SplitAccessKey(cc.ServerURL) + if cc.Token != "" { + accessKey = cc.Token + } + path := cc.WSPath + if path == "" { + path = DefaultWSPath + } + var headers http.Header + if accessKey != "" { + headers = http.Header{"Authorization": {"Bearer " + accessKey}} + } + conn, response, err := websocket.DefaultDialer.DialContext(ctx, HTTPToWS(dialURL)+path, headers) + if response != nil && response.Body != nil { + response.Body.Close() + } + if err != nil { + return nil, err + } + return &webSocketServerStream{ctx: ctx, conn: conn}, nil +} + +func dialProtoGRPC(ctx context.Context, cc connectionConfig) (closeableAgentServerStream, error) { + rawURL, accessKey := SplitAccessKey(cc.ServerURL) + if cc.Token != "" { + accessKey = cc.Token + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return nil, fmt.Errorf("invalid gRPC server URL %q", rawURL) + } + var creds credentials.TransportCredentials + if strings.EqualFold(u.Scheme, "https") { + creds = credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()}) + } else { + creds = insecure.NewCredentials() + } + conn, err := grpc.NewClient(u.Host, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, err + } + streamCtx := ctx + if accessKey != "" { + streamCtx = metadata.AppendToOutgoingContext(streamCtx, "authorization", "Bearer "+accessKey) + } + stream, err := transport.NewAgentTransportServiceClient(conn).Connect(streamCtx) + if err != nil { + conn.Close() + return nil, err + } + return &grpcServerStream{AgentTransportService_ConnectClient: stream, conn: conn}, nil +} + +func connectGenerated(ctx context.Context, cc connectionConfig, grpcTransport bool) error { + logger := cc.Logger + if logger == nil { + logger = telemetry.NopLogger() + } + attempt := 0 + for { + if ctx.Err() != nil { + return ctx.Err() + } + var stream closeableAgentServerStream + var err error + if grpcTransport { + stream, err = dialProtoGRPC(ctx, cc) + } else { + stream, err = dialProtoWebSocket(ctx, cc) + } + if err == nil { + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = stream.Close() + case <-done: + } + }() + err = serveAgentConnection(ctx, cc, logger, stream) + close(done) + _ = stream.Close() + } + if ctx.Err() != nil { + return ctx.Err() + } + delay := agent.RetryDelay(attempt) + attempt++ + logger.Warnf("connection lost (attempt %d), retrying in %v: %v", attempt, delay, err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } +} + +func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telemetry.Logger, stream AgentServerStream) error { + if cc.Registry == nil { + return fmt.Errorf("command registry is nil") + } + hello, err := BuildHello(cc.Name, cc.Registry, cc.Node, cc.Runtime, cc.Status, cc.Menu, &transport.AgentStats{}) + if err != nil { + return err + } + if err := stream.Send(&transport.AgentFrame{Payload: &transport.AgentFrame_Hello{Hello: hello}}); err != nil { + return err + } + accepted, err := stream.Recv() + if err != nil { + return err + } + if accepted.GetAccepted() == nil { + return fmt.Errorf("expected connection acceptance") + } + + connectionCtx, cancelConnection := context.WithCancel(ctx) + defer cancelConnection() + sendCh := make(chan *transport.AgentFrame, 64) + writeErr := make(chan error, 1) + send := func(frame *transport.AgentFrame) { + select { + case sendCh <- frame: + case <-connectionCtx.Done(): + } + } + go func() { + for { + select { + case frame := <-sendCh: + if err := stream.Send(frame); err != nil { + select { + case writeErr <- err: + default: + } + cancelConnection() + return + } + case <-connectionCtx.Done(): + return + } + } + }() + + stats := NewAgentStatsTracker() + if cc.AgentSubscribe != nil { + unsubscribe := cc.AgentSubscribe(func(event *aop.Event) { + if next, changed := stats.Observe(event); changed { + send(&transport.AgentFrame{Payload: &transport.AgentFrame_Stats{Stats: next}}) + } + send(&transport.AgentFrame{CorrelationId: event.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) + }) + defer unsubscribe() + } + if detach := attachToolEvents(cc.DataBus, cc.SCO, send); detach != nil { + defer detach() + } + if cc.Status != nil { + go func(last *transport.AgentStatus) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + next := cc.Status() + if !protobuf.Equal(next, last) { + send(&transport.AgentFrame{Payload: &transport.AgentFrame_Status{Status: next}}) + last = protobuf.Clone(next).(*transport.AgentStatus) + } + case <-connectionCtx.Done(): + return + } + } + }(protobuf.Clone(hello.GetStatus()).(*transport.AgentStatus)) + } + + var router *pty.Router + if cc.PTYRouter != nil { + router, err = cc.PTYRouter() + } else { + router = NewPTYRouter(cc.Registry) + } + if err != nil { + return err + } + defer router.Close() + if cc.PTYRouter == nil { + if manager := RegistryPTYManager(cc.Registry); manager != nil { + unsubscribe := SubscribePTYSessions(connectionCtx, manager, router, func(frame pty.Frame) { + send(&transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}) + }) + defer unsubscribe() + } + } + + var operationsMu sync.Mutex + operations := make(map[string]context.CancelFunc) + for { + frame, err := stream.Recv() + if err != nil { + select { + case writerErr := <-writeErr: + return writerErr + default: + } + return err + } + switch payload := frame.Payload.(type) { + case *transport.ServerFrame_OpenSession: + if cc.Chat != nil { + send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_OpenSession{OpenSession: cc.Chat.OpenSession(connectionCtx, payload.OpenSession)}}) + } + case *transport.ServerFrame_RunTurn: + if cc.Chat != nil { + send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_RunTurn{RunTurn: cc.Chat.RunTurn(connectionCtx, payload.RunTurn)}}) + } + case *transport.ServerFrame_CancelTurn: + if cc.Chat != nil { + send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CancelTurn{CancelTurn: cc.Chat.CancelTurn(payload.CancelTurn)}}) + } + case *transport.ServerFrame_CloseSession: + if cc.Chat != nil { + send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CloseSession{CloseSession: cc.Chat.CloseSession(connectionCtx, payload.CloseSession)}}) + } + case *transport.ServerFrame_Command: + go func(request *transport.CommandRequest, correlation string) { + if cc.Chat == nil { + send(operationFailure(request.GetTaskId(), "command handler is unavailable")) + return + } + result, err := cc.Chat.Command(connectionCtx, request) + if err != nil { + send(operationFailure(request.GetTaskId(), err.Error())) + return + } + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CommandResult{CommandResult: result}}) + }(payload.Command, frame.CorrelationId) + case *transport.ServerFrame_ToolCall: + request := payload.ToolCall + taskCtx, taskCancel := context.WithCancel(connectionCtx) + trackOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) + go func() { + defer finishOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) + event, err := executeToolRequest(taskCtx, request, cc.Registry, cc.DataBus) + if err != nil { + send(operationFailure(request.GetTaskId(), err.Error())) + return + } + send(&transport.AgentFrame{CorrelationId: request.GetTaskId(), Payload: &transport.AgentFrame_Event{Event: event}}) + }() + case *transport.ServerFrame_FileRead: + go sendFileResult(frame.CorrelationId, fileRead(payload.FileRead, cc.Runtime.GetWorkingDir()), send) + case *transport.ServerFrame_FileWrite: + go sendFileResult(frame.CorrelationId, fileWrite(payload.FileWrite, cc.Runtime.GetWorkingDir()), send) + case *transport.ServerFrame_FileList: + if cc.RunnerFileRPC { + go sendFileResult(frame.CorrelationId, fileList(payload.FileList, cc.Runtime.GetWorkingDir()), send) + } + case *transport.ServerFrame_FileMkdir: + if cc.RunnerFileRPC { + go sendFileResult(frame.CorrelationId, fileMkdir(payload.FileMkdir, cc.Runtime.GetWorkingDir()), send) + } + case *transport.ServerFrame_FileUpload: + go func(request *transport.FileUploadRequest, correlation string) { + if cc.Chat == nil { + send(operationFailure(request.GetTaskId(), "upload handler is unavailable")) + return + } + result, err := cc.Chat.Upload(request) + if err != nil { + send(operationFailure(request.GetTaskId(), err.Error())) + return + } + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_FileResult{FileResult: result}}) + }(payload.FileUpload, frame.CorrelationId) + case *transport.ServerFrame_Exec: + request := payload.Exec + taskCtx, taskCancel := context.WithCancel(connectionCtx) + trackOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) + go func() { + defer finishOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) + handleExecRequest(taskCtx, request, cc.Runtime.GetWorkingDir(), send) + }() + case *transport.ServerFrame_CancelOperation: + operationsMu.Lock() + operationCancel := operations[payload.CancelOperation.GetTaskId()] + operationsMu.Unlock() + if operationCancel != nil { + operationCancel() + } + case *transport.ServerFrame_ReloadConfig: + if cc.Chat != nil { + result, statusValue := cc.Chat.ReloadConfig(cc.ServerURL) + if statusValue != nil { + send(&transport.AgentFrame{Payload: &transport.AgentFrame_Status{Status: statusValue}}) + } + send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_ConfigReload{ConfigReload: result}}) + } + case *transport.ServerFrame_Terminal: + router.Handle(connectionCtx, terminalcodec.FromProto(payload.Terminal), func(out pty.Frame) { + send(&transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(out)}}) + }) + } + } +} + +func operationFailure(taskID, message string) *transport.AgentFrame { + return &transport.AgentFrame{CorrelationId: taskID, Payload: &transport.AgentFrame_OperationError{OperationError: &transport.OperationError{TaskId: taskID, Message: message}}} +} +func trackOperation(mu *sync.Mutex, operations map[string]context.CancelFunc, id string, cancel context.CancelFunc) { + mu.Lock() + operations[id] = cancel + mu.Unlock() +} +func finishOperation(mu *sync.Mutex, operations map[string]context.CancelFunc, id string, cancel context.CancelFunc) { + cancel() + mu.Lock() + delete(operations, id) + mu.Unlock() +} + +func executeToolRequest(ctx context.Context, request *transport.ToolCallRequest, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent]) (*aop.Event, error) { + if request == nil || request.Call == nil || request.TaskId == "" || request.Call.Id != request.TaskId { + return nil, fmt.Errorf("tool call correlation is invalid") + } + call := request.Call + if strings.TrimSpace(call.Name) == "" { + return nil, fmt.Errorf("tool name is required") + } + if call.WorkingDirectory != "" { + ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: call.WorkingDirectory}) + } + ctx = output.ContextWithCallID(ctx, request.TaskId) + started := time.Now() + result, execErr := executeCall(ctx, executor, call, dataBus, request.TaskId) + text := result.Text() + if execErr != nil { + text = execErr.Error() + } + content := []*aop.Content{aop.Text(text)} + for _, block := range result.Content { + if block.Type != "image" { + continue + } + data, err := base64.StdEncoding.DecodeString(block.Base64Data) + if err == nil { + content = append(content, aop.Image(block.MimeType, data)) + } + } + detail, _ := aop.JSONValue(result.Details) + event := &aop.Event{Id: request.TaskId, EmittedAt: timestamppb.Now(), SessionId: request.SessionId, TurnId: request.TurnId, Emitter: "aiscan.agent", Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{CallId: call.Id, Name: call.Name, Output: content, Detail: detail, Terminate: result.Terminate, IsError: execErr != nil || result.IsError, DurationMs: uint64(time.Since(started).Milliseconds())}}} + return event, nil +} + +type fileResultValue struct { + result *transport.FileResult + err error +} + +func resolveFileRPCPath(baseDir, path string) string { + if filepath.IsAbs(path) || baseDir == "" { + return filepath.Clean(path) + } + return filepath.Clean(filepath.Join(baseDir, path)) +} + +func sendFileResult(correlation string, value fileResultValue, send func(*transport.AgentFrame)) { + if value.err != nil { + taskID := "" + if value.result != nil { + taskID = value.result.TaskId + } + send(operationFailure(taskID, value.err.Error())) + return + } + send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_FileResult{FileResult: value.result}}) +} +func fileRead(req *transport.FileReadRequest, base string) fileResultValue { + result := &transport.FileResult{} + if req != nil { + result.TaskId = req.TaskId + result.Path = req.Path + } + if req == nil || req.Path == "" { + return fileResultValue{result: result, err: fmt.Errorf("file path is required")} + } + data, err := os.ReadFile(resolveFileRPCPath(base, req.Path)) + result.Data = data + result.Size = int64(len(data)) + return fileResultValue{result: result, err: err} +} +func fileWrite(req *transport.FileWriteRequest, base string) fileResultValue { + result := &transport.FileResult{} + if req != nil { + result.TaskId = req.TaskId + result.Path = req.Path + result.Size = int64(len(req.Data)) + } + if req == nil || req.Path == "" { + return fileResultValue{result: result, err: fmt.Errorf("file path is required")} + } + path := resolveFileRPCPath(base, req.Path) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fileResultValue{result: result, err: err} + } + return fileResultValue{result: result, err: os.WriteFile(path, req.Data, 0o644)} +} +func fileList(req *transport.FileListRequest, base string) fileResultValue { + result := &transport.FileResult{} + if req != nil { + result.TaskId = req.TaskId + result.Path = req.Path + } + if result.Path == "" { + result.Path = "." + } + entries, err := os.ReadDir(resolveFileRPCPath(base, result.Path)) + if err != nil { + return fileResultValue{result: result, err: err} + } + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return fileResultValue{result: result, err: err} + } + result.Entries = append(result.Entries, &transport.FileEntry{Name: entry.Name(), IsDirectory: entry.IsDir(), Size: info.Size()}) + } + return fileResultValue{result: result} +} +func fileMkdir(req *transport.FileMkdirRequest, base string) fileResultValue { + result := &transport.FileResult{} + if req != nil { + result.TaskId = req.TaskId + result.Path = req.Path + } + if req == nil || req.Path == "" { + return fileResultValue{result: result, err: fmt.Errorf("directory path is required")} + } + return fileResultValue{result: result, err: os.MkdirAll(resolveFileRPCPath(base, req.Path), 0o755)} +} + +func handleExecRequest(ctx context.Context, req *transport.ExecRequest, base string, send func(*transport.AgentFrame)) { + if req == nil || strings.TrimSpace(req.Command) == "" { + send(operationFailure(req.GetTaskId(), "command is required")) + return + } + runCtx := ctx + cancel := func() {} + if req.TimeoutSeconds > 0 { + runCtx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutSeconds)*time.Second) + } + defer cancel() + var command *exec.Cmd + if runtime.GOOS == "windows" { + command = exec.CommandContext(runCtx, "cmd.exe", "/C", req.Command) + } else { + command = exec.CommandContext(runCtx, "/bin/sh", "-c", req.Command) + } + if req.Cwd != "" { + command.Dir = resolveFileRPCPath(base, req.Cwd) + } else if base != "" { + command.Dir = base + } + command.Env = os.Environ() + for key, value := range req.Env { + command.Env = append(command.Env, key+"="+value) + } + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + if stdout.Len() > 0 { + send(&transport.AgentFrame{CorrelationId: req.TaskId, Payload: &transport.AgentFrame_ExecOutput{ExecOutput: &transport.ExecOutput{TaskId: req.TaskId, Stream: transport.ExecStream_EXEC_STREAM_STDOUT, Data: stdout.Bytes()}}}) + } + if stderr.Len() > 0 { + send(&transport.AgentFrame{CorrelationId: req.TaskId, Payload: &transport.AgentFrame_ExecOutput{ExecOutput: &transport.ExecOutput{TaskId: req.TaskId, Stream: transport.ExecStream_EXEC_STREAM_STDERR, Data: stderr.Bytes()}}}) + } + result := &transport.ExecResult{TaskId: req.TaskId, State: "completed"} + if err != nil { + var exitErr *exec.ExitError + switch { + case errors.Is(runCtx.Err(), context.DeadlineExceeded): + result.ExitCode = -1 + result.State = "killed" + result.KillCause = "timeout" + case errors.Is(runCtx.Err(), context.Canceled): + result.ExitCode = -1 + result.State = "killed" + result.KillCause = "canceled" + case errors.As(err, &exitErr): + result.ExitCode = int32(exitErr.ExitCode()) + default: + send(operationFailure(req.TaskId, err.Error())) + return + } + } + send(&transport.AgentFrame{CorrelationId: req.TaskId, Payload: &transport.AgentFrame_ExecResult{ExecResult: result}}) +} diff --git a/pkg/webagent/pty.go b/pkg/web/agent/pty.go similarity index 90% rename from pkg/webagent/pty.go rename to pkg/web/agent/pty.go index a7b557e2..b39abd25 100644 --- a/pkg/webagent/pty.go +++ b/pkg/web/agent/pty.go @@ -1,4 +1,4 @@ -package webagent +package agent import ( "context" @@ -7,7 +7,6 @@ import ( "github.com/chainreactors/aiscan/agent/tmux" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/utils/pty" ) @@ -44,7 +43,7 @@ func RegistryPTYManager(reg *commands.CommandRegistry) *tmux.Manager { // SubscribePTYSessions subscribes to PTY session changes and broadcasts // session state to all active PTY streams. -func SubscribePTYSessions(ctx context.Context, mgr *tmux.Manager, router *pty.Router, send func(webproto.Message)) func() { +func SubscribePTYSessions(ctx context.Context, mgr *tmux.Manager, router *pty.Router, send func(pty.Frame)) func() { if mgr == nil || router == nil || send == nil { return func() {} } @@ -94,13 +93,13 @@ func SubscribePTYSessions(ctx context.Context, mgr *tmux.Manager, router *pty.Ro } // BroadcastPTYSessions sends the current PTY session list to all active streams. -func BroadcastPTYSessions(mgr *tmux.Manager, router *pty.Router, send func(webproto.Message)) { +func BroadcastPTYSessions(mgr *tmux.Manager, router *pty.Router, send func(pty.Frame)) { streamIDs := router.StreamIDs() if len(streamIDs) == 0 { return } sessions := mgr.List() for _, streamID := range streamIDs { - send(webproto.NewPTYMessage(pty.Frame{Type: pty.FrameSessions, StreamID: streamID, Sessions: sessions})) + send(pty.Frame{Type: pty.FrameSessions, StreamID: streamID, Sessions: sessions}) } } diff --git a/pkg/webagent/remote.go b/pkg/web/agent/remote.go similarity index 88% rename from pkg/webagent/remote.go rename to pkg/web/agent/remote.go index 9c2504b2..c712e4de 100644 --- a/pkg/webagent/remote.go +++ b/pkg/web/agent/remote.go @@ -1,4 +1,4 @@ -package webagent +package agent import ( "context" @@ -9,7 +9,6 @@ import ( "time" cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/pkg/webproto" ) func fetchRemoteConfig(webURL string) (*cfg.Option, error) { @@ -34,15 +33,15 @@ func fetchRemoteConfig(webURL string) (*cfg.Option, error) { return nil, fmt.Errorf("remote config: HTTP %d", resp.StatusCode) } - var dc webproto.DistributeConfig + var dc cfg.DistributeConfig if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { return nil, fmt.Errorf("decode remote config: %w", err) } - webproto.MigrateLLMConfig(&dc.LLM, webproto.LLMProviderConfig{}) + cfg.MigrateLLMConfig(&dc.LLM, cfg.LLMProviderConfig{}) return distributeToOption(&dc), nil } -func distributeToOption(d *webproto.DistributeConfig) *cfg.Option { +func distributeToOption(d *cfg.DistributeConfig) *cfg.Option { opt := &cfg.Option{ LLMOptions: cfg.LLMOptions{ ActiveProfile: d.LLM.ActiveProfile, @@ -84,7 +83,7 @@ func distributeToOption(d *webproto.DistributeConfig) *cfg.Option { return opt } -func llmProviderEntries(profiles []webproto.LLMProviderConfig) []cfg.LLMProviderEntry { +func llmProviderEntries(profiles []cfg.LLMProviderConfig) []cfg.LLMProviderEntry { entries := make([]cfg.LLMProviderEntry, 0, len(profiles)) for _, p := range profiles { entries = append(entries, cfg.LLMProviderEntry{ diff --git a/pkg/webagent/remote_test.go b/pkg/web/agent/remote_test.go similarity index 84% rename from pkg/webagent/remote_test.go rename to pkg/web/agent/remote_test.go index 27a3bb07..8740f01d 100644 --- a/pkg/webagent/remote_test.go +++ b/pkg/web/agent/remote_test.go @@ -1,4 +1,4 @@ -package webagent +package agent import ( "encoding/json" @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/webproto" + cfg "github.com/chainreactors/aiscan/core/config" ) func TestFetchRemoteConfigUsesBearerTokenFromURL(t *testing.T) { @@ -17,13 +17,13 @@ func TestFetchRemoteConfigUsesBearerTokenFromURL(t *testing.T) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - var cfg webproto.DistributeConfig - cfg.LLM.ActiveProfile = "p1" - cfg.LLM.Providers = []webproto.LLMProviderConfig{ + var value cfg.DistributeConfig + value.LLM.ActiveProfile = "p1" + value.LLM.Providers = []cfg.LLMProviderConfig{ {ID: "p1", Provider: "openai", Model: "deepseek-chat", MaxTokens: 8192, ContextWindow: 128000}, {ID: "p2", Provider: "openai", Model: "gpt-5"}, } - _ = json.NewEncoder(w).Encode(cfg) + _ = json.NewEncoder(w).Encode(value) }) server := httptest.NewServer(mux) defer server.Close() diff --git a/pkg/web/agent/stream.go b/pkg/web/agent/stream.go new file mode 100644 index 00000000..3f4ebddd --- /dev/null +++ b/pkg/web/agent/stream.go @@ -0,0 +1,62 @@ +package agent + +import ( + "sync" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "google.golang.org/protobuf/proto" +) + +// AgentStatsTracker tracks agent event statistics for the WebSocket connection. +type AgentStatsTracker struct { + mu sync.Mutex + stats transport.AgentStats +} + +// NewAgentStatsTracker creates a new stats tracker. +func NewAgentStatsTracker() *AgentStatsTracker { + return &AgentStatsTracker{} +} + +// Snapshot returns the current stats snapshot. +func (t *AgentStatsTracker) Snapshot() *transport.AgentStats { + if t == nil { + return &transport.AgentStats{} + } + t.mu.Lock() + defer t.mu.Unlock() + return proto.Clone(&t.stats).(*transport.AgentStats) +} + +// Observe records an AOP event and returns updated stats if the stats changed. +func (t *AgentStatsTracker) Observe(e *aop.Event) (*transport.AgentStats, bool) { + if t == nil { + return &transport.AgentStats{}, false + } + t.mu.Lock() + defer t.mu.Unlock() + + t.stats.LastEvent = aop.Kind(e) + switch payload := e.Payload.(type) { + case *aop.Event_TurnStarted: + t.stats.Turns++ + case *aop.Event_Usage: + data := payload.Usage + t.stats.InputTokens += data.InputTokens + t.stats.OutputTokens += data.OutputTokens + t.stats.TotalTokens += data.TotalTokens + t.stats.CacheReadTokens += data.Detail["cache_read"] + t.stats.CacheWriteTokens += data.Detail["cache_write"] + case *aop.Event_ToolCall: + t.stats.ToolCalls++ + t.stats.RunningTools++ + case *aop.Event_ToolResult: + if t.stats.RunningTools > 0 { + t.stats.RunningTools-- + } + default: + return proto.Clone(&t.stats).(*transport.AgentStats), false + } + return proto.Clone(&t.stats).(*transport.AgentStats), true +} diff --git a/pkg/webagent/toolnode.go b/pkg/web/agent/toolnode.go similarity index 64% rename from pkg/webagent/toolnode.go rename to pkg/web/agent/toolnode.go index 23a6a7de..2964b17c 100644 --- a/pkg/webagent/toolnode.go +++ b/pkg/web/agent/toolnode.go @@ -1,18 +1,22 @@ -package webagent +package agent import ( "context" "encoding/json" "fmt" "os" + "runtime" "strings" + "time" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" + "google.golang.org/protobuf/types/known/timestamppb" ) // ToolNodeConfig configures a tool-only runner: an outbound WebSocket @@ -53,9 +57,17 @@ func RunToolNode(ctx context.Context, cfg ToolNodeConfig) error { if logger == nil { logger = telemetry.NopLogger() } - runtime := DefaultRuntime() - runtime.Capabilities = append(runtime.Capabilities, "file.read", "file.write", "file.list", "file.mkdir") - runtime.Meta = map[string]any{"version": cfg.Version, "mode": "tool"} + runnerRuntime := DefaultRuntime() + runnerRuntime.Capabilities = append(runnerRuntime.Capabilities, "file.read", "file.write", "file.list", "file.mkdir") + home, _ := os.UserHomeDir() + runnerRuntime.Metadata, _ = aop.JSONValue(map[string]any{ + "version": cfg.Version, + "mode": "tool", + "home": home, + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "cores": runtime.NumCPU(), + }) return connect(ctx, connectionConfig{ ServerURL: cfg.ServerURL, WSPath: cfg.WSPath, @@ -66,7 +78,7 @@ func RunToolNode(ctx context.Context, cfg ToolNodeConfig) error { SCO: cfg.SCO, Logger: logger, Node: protocols.NodeRef{ID: runnerID, Authority: authority}, - Runtime: runtime, + Runtime: runnerRuntime, RunnerFileRPC: true, }) } @@ -74,19 +86,30 @@ func RunToolNode(ctx context.Context, cfg ToolNodeConfig) error { // attachToolEvents forwards scanner telemetry (tool.data) and normalized SCO // nodes (tool.sco) onto the hub connection, correlated by call ID. Returns an // idempotent detach func, or nil when both sources are absent. -func attachToolEvents(dataBus *eventbus.Bus[output.ToolDataEvent], sco *output.SCOSidecar, send func(webproto.Message)) func() { +func attachToolEvents(dataBus *eventbus.Bus[output.ToolDataEvent], sco *output.SCOSidecar, send func(*transport.AgentFrame)) func() { if dataBus == nil && sco == nil { return nil } var unsub func() if dataBus != nil { unsub = dataBus.Subscribe(func(event output.ToolDataEvent) { - send(webproto.Message{Type: "tool.data", TaskID: event.CallID, Payload: webproto.MustJSON(event)}) + data, _ := aop.JSONValue(event.Data) + timestamp := event.Timestamp + if timestamp.IsZero() { + timestamp = time.Now() + } + send(&transport.AgentFrame{CorrelationId: event.CallID, Payload: &transport.AgentFrame_ToolTelemetry{ToolTelemetry: &transport.ToolTelemetry{ + Tool: event.Tool, Kind: event.Kind, Target: event.Target, Data: data, CallId: event.CallID, Timestamp: timestamppb.New(timestamp), + }}}) }) } if sco != nil { sco.OnNodes = func(callID string, nodes []json.RawMessage) { - send(webproto.Message{Type: "tool.sco", TaskID: callID, Payload: webproto.MustJSON(map[string]any{"nodes": nodes})}) + encoded := make([][]byte, 0, len(nodes)) + for _, node := range nodes { + encoded = append(encoded, append([]byte(nil), node...)) + } + send(&transport.AgentFrame{CorrelationId: callID, Payload: &transport.AgentFrame_ScoNodes{ScoNodes: &transport.ScoNodes{CallId: callID, Nodes: encoded}}}) } } var once bool diff --git a/pkg/web/agent/toolnode_test.go b/pkg/web/agent/toolnode_test.go new file mode 100644 index 00000000..e2a94869 --- /dev/null +++ b/pkg/web/agent/toolnode_test.go @@ -0,0 +1,218 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/gorilla/websocket" + "google.golang.org/protobuf/encoding/protojson" +) + +var testUpgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + +type hubScript struct { + t *testing.T + registered chan *transport.AgentHello + toolResult chan *aop.ToolResult + progress chan string + fileData chan []byte + toolData chan *transport.ToolTelemetry +} + +func newHubScript(t *testing.T) *hubScript { + return &hubScript{t: t, registered: make(chan *transport.AgentHello, 1), toolResult: make(chan *aop.ToolResult, 1), progress: make(chan string, 16), fileData: make(chan []byte, 1), toolData: make(chan *transport.ToolTelemetry, 4)} +} + +func readAgentFrame(conn *websocket.Conn) (*transport.AgentFrame, error) { + _, data, err := conn.ReadMessage() + if err != nil { + return nil, err + } + frame := new(transport.AgentFrame) + if err := protojson.Unmarshal(data, frame); err != nil { + return nil, err + } + return frame, nil +} +func writeServerFrame(conn *websocket.Conn, frame *transport.ServerFrame) error { + data, err := protojson.Marshal(frame) + if err != nil { + return err + } + return conn.WriteMessage(websocket.TextMessage, data) +} + +func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + h.t.Errorf("authorization = %q", got) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + conn, err := testUpgrader.Upgrade(w, r, nil) + if err != nil { + h.t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + first, err := readAgentFrame(conn) + if err != nil || first.GetHello() == nil { + h.t.Errorf("expected hello: %v %v", first, err) + return + } + h.registered <- first.GetHello() + if err := writeServerFrame(conn, &transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{AgentId: "runner-1"}}}); err != nil { + return + } + go h.drive(conn) + for { + frame, err := readAgentFrame(conn) + if err != nil { + return + } + switch payload := frame.Payload.(type) { + case *transport.AgentFrame_Event: + if result := payload.Event.GetToolResult(); result != nil { + h.toolResult <- result + } + case *transport.AgentFrame_ToolTelemetry: + telemetry := payload.ToolTelemetry + if telemetry.Kind == output.ToolDataProgress { + line, _ := aop.DecodeJSON[string](telemetry.Data) + h.progress <- line + } else { + h.toolData <- telemetry + } + case *transport.AgentFrame_FileResult: + h.fileData <- payload.FileResult.Data + } + } +} + +func (h *hubScript) drive(conn *websocket.Conn) { + arguments, _ := aop.JSONValue(map[string]any{"command": "echo hello"}) + _ = writeServerFrame(conn, &transport.ServerFrame{CorrelationId: "exec-1", Payload: &transport.ServerFrame_ToolCall{ToolCall: &transport.ToolCallRequest{TaskId: "exec-1", SessionId: "exec-1", TurnId: "exec-1", Call: &aop.ToolCall{Id: "exec-1", Name: "bash", Arguments: arguments}}}}) +} +func (h *hubScript) driveFileRead(conn *websocket.Conn, path string) { + _ = writeServerFrame(conn, &transport.ServerFrame{CorrelationId: "read-1", Payload: &transport.ServerFrame_FileRead{FileRead: &transport.FileReadRequest{TaskId: "read-1", Path: path}}}) +} + +func wait[T any](t *testing.T, ch <-chan T, what string) T { + t.Helper() + select { + case value := <-ch: + return value + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %s", what) + var zero T + return zero + } +} + +func TestRunToolNodeWireInterop(t *testing.T) { + registry := commands.NewRegistry() + registry.RegisterTool(&recordingBash{}) + dataBus := eventbus.New[output.ToolDataEvent]() + hub := newHubScript(t) + server := httptest.NewServer(http.HandlerFunc(hub.serveHTTP)) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- RunToolNode(ctx, ToolNodeConfig{ServerURL: server.URL, WSPath: "/ws/runner", ID: "runner-1", Token: "test-token", Registry: registry, DataBus: dataBus, Version: "test"}) + }() + hello := wait(t, hub.registered, "hello") + if hello.Name != "runner-1" || hello.AgentId != "runner-1" { + t.Fatalf("hello identity = %+v", hello) + } + if hello.Runtime.Os == "" { + t.Fatalf("runtime missing OS: %+v", hello.Runtime) + } + metadata, err := aop.DecodeJSON[map[string]any](hello.Runtime.Metadata) + if err != nil || metadata["home"] == "" { + t.Fatalf("runtime metadata = %+v, err=%v", metadata, err) + } + capabilities := map[string]bool{} + for _, capability := range hello.Runtime.Capabilities { + capabilities[capability] = true + } + if !capabilities["file.list"] || !capabilities["file.mkdir"] { + t.Fatalf("capabilities = %+v", hello.Runtime.Capabilities) + } + if len(hello.Tools) != 1 || hello.Tools[0].Name != "bash" { + t.Fatalf("tools = %+v", hello.Tools) + } + if line := wait(t, hub.progress, "tool progress"); line != "streamed" { + t.Fatalf("progress = %q", line) + } + result := wait(t, hub.toolResult, "tool result") + if result.IsError || result.CallId != "exec-1" || result.Name != "bash" { + t.Fatalf("tool result = %+v", result) + } + dataBus.Emit(output.ToolDataEvent{Tool: "gogo", Kind: "service", CallID: "exec-1"}) + if telemetry := wait(t, hub.toolData, "tool telemetry"); telemetry.CallId != "exec-1" { + t.Fatalf("telemetry = %+v", telemetry) + } + cancel() + select { + case <-errCh: + case <-time.After(5 * time.Second): + t.Fatal("tool node did not stop") + } +} + +func TestRunToolNodeFileRead(t *testing.T) { + registry := commands.NewRegistry() + registry.RegisterTool(&recordingBash{}) + path := filepath.Join(t.TempDir(), "note.txt") + if err := os.WriteFile(path, []byte("file-body"), 0o644); err != nil { + t.Fatal(err) + } + hub := newHubScript(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := testUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + first, err := readAgentFrame(conn) + if err != nil || first.GetHello() == nil { + return + } + hub.registered <- first.GetHello() + if writeServerFrame(conn, &transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{AgentId: "runner-1"}}}) != nil { + return + } + hub.driveFileRead(conn, path) + for { + frame, err := readAgentFrame(conn) + if err != nil { + return + } + if result := frame.GetFileResult(); result != nil { + hub.fileData <- result.Data + return + } + } + })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + _ = RunToolNode(ctx, ToolNodeConfig{ServerURL: server.URL, WSPath: "/ws/runner", ID: "runner-1", Registry: registry}) + }() + wait(t, hub.registered, "hello") + if data := wait(t, hub.fileData, "file result"); string(data) != "file-body" { + t.Fatalf("file data = %q", data) + } +} diff --git a/pkg/web/agent/upload_test.go b/pkg/web/agent/upload_test.go new file mode 100644 index 00000000..0b9b68cb --- /dev/null +++ b/pkg/web/agent/upload_test.go @@ -0,0 +1,26 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" + + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" +) + +func TestUploadWritesAbsolutePath(t *testing.T) { + const filename = "aiscan_test_upload_probe.txt" + const body = "codex public proof\nkey=appImage/probe" + dest := filepath.Join(os.TempDir(), "aiscan-uploads", filename) + t.Cleanup(func() { _ = os.Remove(dest) }) + result, err := (&chatAgentHandler{}).Upload(&transport.FileUploadRequest{TaskId: "task-1", SessionId: "sess-1", Filename: filename, Data: []byte(body)}) + if err != nil { + t.Fatal(err) + } + if result.Path != dest { + t.Fatalf("result = %+v, want path %q", result, dest) + } + if data, err := os.ReadFile(dest); err != nil || string(data) != body { + t.Fatalf("file on disk = %q, err=%v; want %q", data, err, body) + } +} diff --git a/pkg/web/agent_stream.go b/pkg/web/agent_stream.go new file mode 100644 index 00000000..1e7ddad5 --- /dev/null +++ b/pkg/web/agent_stream.go @@ -0,0 +1,196 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/ioa/protocols" + "github.com/gorilla/websocket" + "google.golang.org/protobuf/encoding/protojson" + protobuf "google.golang.org/protobuf/proto" +) + +type ServerAgentStream interface { + Context() context.Context + Recv() (*transport.AgentFrame, error) + Send(*transport.ServerFrame) error +} + +type agentTransportServer struct { + transport.UnimplementedAgentTransportServiceServer + pool *AgentPool +} + +func NewAgentTransportServer(pool *AgentPool) transport.AgentTransportServiceServer { + return &agentTransportServer{pool: pool} +} + +func (s *agentTransportServer) Connect(stream transport.AgentTransportService_ConnectServer) error { + if s.pool == nil { + return fmt.Errorf("agent pool is unavailable") + } + return s.pool.ServeAgentStream(stream) +} + +type webSocketAgentStream struct { + ctx context.Context + conn *websocket.Conn + mu sync.Mutex +} + +func (s *webSocketAgentStream) Context() context.Context { return s.ctx } + +func (s *webSocketAgentStream) Recv() (*transport.AgentFrame, error) { + _, data, err := s.conn.ReadMessage() + if err != nil { + return nil, err + } + frame := new(transport.AgentFrame) + if err := protojson.Unmarshal(data, frame); err != nil { + return nil, fmt.Errorf("decode agent frame: %w", err) + } + return frame, nil +} + +func (s *webSocketAgentStream) Send(frame *transport.ServerFrame) error { + data, err := protojson.Marshal(frame) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.conn.WriteMessage(websocket.TextMessage, data) +} + +func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { + conn, err := p.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + _ = p.ServeAgentStream(&webSocketAgentStream{ctx: r.Context(), conn: conn}) +} + +func (p *AgentPool) ServeAgentStream(stream ServerAgentStream) error { + if stream == nil { + return fmt.Errorf("agent stream is required") + } + first, err := stream.Recv() + if err != nil { + return err + } + hello := first.GetHello() + if hello == nil { + return fmt.Errorf("first agent frame must contain hello") + } + if hello.AgentId == "" || hello.Authority == "" { + return fmt.Errorf("hello agent_id and authority are required") + } + node := protocols.NodeRef{ID: hello.AgentId, Authority: hello.Authority} + id := agentKey(hello.AgentId, hello.Authority) + if id == "" { + return fmt.Errorf("agent identity is required") + } + name := hello.Name + if name == "" { + name = "agent" + } + runtimeInfo := &transport.AgentRuntimeInfo{} + if hello.Runtime != nil { + runtimeInfo = protobuf.Clone(hello.Runtime).(*transport.AgentRuntimeInfo) + } + statusValue := &transport.AgentStatus{} + if hello.Status != nil { + statusValue = protobuf.Clone(hello.Status).(*transport.AgentStatus) + } + statsValue := &transport.AgentStats{} + if hello.Stats != nil { + statsValue = protobuf.Clone(hello.Stats).(*transport.AgentStats) + } + + ctx, cancel := context.WithCancel(stream.Context()) + agent := &remoteAgent{ + id: id, name: name, commands: append([]string(nil), hello.Commands...), commandsMenu: cloneCommandSpecs(hello.CommandMenu), + close: cancel, sendCh: make(chan *transport.ServerFrame, 32), controlCh: make(chan *transport.ServerFrame, 32), + connectAt: time.Now(), node: node, runtime: runtimeInfo, status: statusValue, stats: statsValue, + tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: make(map[string]struct{}), + childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), + } + p.register(agent) + defer func() { + cancel() + p.unregister(agent) + close(agent.done) + }() + + if err := stream.Send(&transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{ + AgentId: agent.id, Name: agent.name, Capabilities: agent.runtime.GetCapabilities(), + }}}); err != nil { + return err + } + + writeErr := make(chan error, 1) + go func() { + for { + var frame *transport.ServerFrame + select { + case frame = <-agent.controlCh: + default: + select { + case frame = <-agent.controlCh: + case frame = <-agent.sendCh: + case <-ctx.Done(): + return + } + } + if frame == nil { + continue + } + if frame.GetReloadConfig() != nil { + agent.finishConfigReload() + } + if err := stream.Send(frame); err != nil { + select { + case writeErr <- err: + default: + } + cancel() + return + } + } + }() + + recvCh := make(chan *transport.AgentFrame) + recvErr := make(chan error, 1) + go func() { + for { + frame, err := stream.Recv() + if err != nil { + recvErr <- err + return + } + select { + case recvCh <- frame: + case <-ctx.Done(): + return + } + } + }() + + for { + select { + case frame := <-recvCh: + p.handleAgentFrame(agent, frame) + case err := <-recvErr: + return err + case err := <-writeErr: + return err + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/pkg/web/agent_stream_handler.go b/pkg/web/agent_stream_handler.go new file mode 100644 index 00000000..a0a659d2 --- /dev/null +++ b/pkg/web/agent_stream_handler.go @@ -0,0 +1,265 @@ +package web + +import ( + "context" + "encoding/json" + "time" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/core/output" + terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" + "github.com/chainreactors/utils/pty" + protobuf "google.golang.org/protobuf/proto" +) + +func (p *AgentPool) handleAgentFrame(agent *remoteAgent, frame *transport.AgentFrame) { + if agent == nil || frame == nil { + return + } + switch payload := frame.Payload.(type) { + case *transport.AgentFrame_Status: + status := payload.Status + if status == nil { + return + } + agent.mu.Lock() + if agent.status == nil { + agent.status = &transport.AgentStatus{} + } + if status.Provider != "" { + agent.status.Provider = status.Provider + } + if status.Model != "" { + agent.status.Model = status.Model + } + agent.status.Bound = status.Bound + agent.status.ConfigError = status.ConfigError + if status.Space != "" { + agent.status.Space = status.Space + } + agent.mu.Unlock() + + case *transport.AgentFrame_Stats: + agent.mu.Lock() + if payload.Stats == nil { + agent.stats = &transport.AgentStats{} + } else { + agent.stats = protobuf.Clone(payload.Stats).(*transport.AgentStats) + } + agent.mu.Unlock() + + case *transport.AgentFrame_OpenSession: + if accepted := payload.OpenSession.GetAccepted(); accepted != nil { + agent.mu.Lock() + agent.openSessions[accepted.Id] = struct{}{} + agent.mu.Unlock() + } + result := taskResult{} + if rejected := payload.OpenSession.GetRejected(); rejected != nil { + result.Err = rejected.Message + } + p.finishAgentTask(agent, frame.CorrelationId, result) + + case *transport.AgentFrame_CloseSession: + if accepted := payload.CloseSession.GetAccepted(); accepted != nil { + agent.mu.Lock() + delete(agent.openSessions, accepted.Id) + agent.mu.Unlock() + } + result := taskResult{} + if rejected := payload.CloseSession.GetRejected(); rejected != nil { + result.Err = rejected.Message + } + p.finishAgentTask(agent, frame.CorrelationId, result) + + case *transport.AgentFrame_RunTurn: + if rejected := payload.RunTurn.GetRejected(); rejected != nil { + p.finishAgentTask(agent, frame.CorrelationId, taskResult{Err: rejected.Message}) + } + + case *transport.AgentFrame_CancelTurn: + // Cancellation is acknowledged by the response; the local waiter was + // already closed when the cancel request was queued. + + case *transport.AgentFrame_Event: + p.forwardAOPFrame(agent, frame.CorrelationId, payload.Event) + + case *transport.AgentFrame_CommandResult: + result := payload.CommandResult + if result != nil { + p.finishAgentTask(agent, result.TaskId, taskResult{Result: append(json.RawMessage(nil), result.Result...)}) + } + + case *transport.AgentFrame_FileResult: + result := payload.FileResult + if result != nil { + p.finishAgentTask(agent, result.TaskId, taskResult{File: protobuf.Clone(result).(*transport.FileResult)}) + } + + case *transport.AgentFrame_ExecOutput: + // Exec output is streaming telemetry. Callers that need it consume the + // terminal result; no second output envelope is maintained. + + case *transport.AgentFrame_ExecResult: + result := payload.ExecResult + if result != nil { + encoded, _ := json.Marshal(result) + p.finishAgentTask(agent, result.TaskId, taskResult{Result: encoded}) + } + + case *transport.AgentFrame_OperationError: + failure := payload.OperationError + if failure != nil { + p.finishAgentTask(agent, failure.TaskId, taskResult{Err: failure.Message}) + } + + case *transport.AgentFrame_ConfigReload: + result := payload.ConfigReload + if result == nil { + return + } + agent.mu.Lock() + if result.Ok { + agent.status.Provider = result.Provider + agent.status.Model = result.Model + agent.status.ConfigError = "" + } else { + agent.status.ConfigError = result.Error + } + agent.mu.Unlock() + + case *transport.AgentFrame_Terminal: + if payload.Terminal != nil { + p.forwardPTYFrame(terminalcodec.FromProto(payload.Terminal)) + } + + case *transport.AgentFrame_ToolTelemetry: + p.handleToolTelemetry(agent, payload.ToolTelemetry) + + case *transport.AgentFrame_ScoNodes: + if p.sco != nil && payload.ScoNodes != nil && len(payload.ScoNodes.Nodes) > 0 { + nodes := make([]json.RawMessage, 0, len(payload.ScoNodes.Nodes)) + for _, node := range payload.ScoNodes.Nodes { + nodes = append(nodes, append(json.RawMessage(nil), node...)) + } + scanID := payload.ScoNodes.CallId + if scanID == "" { + scanID = frame.CorrelationId + } + if scanID == "" { + scanID = "standalone" + } + _ = p.sco.UpsertSCONodes(context.Background(), scanID, nodes) + } + } +} + +func (p *AgentPool) finishAgentTask(agent *remoteAgent, taskID string, result taskResult) { + if agent == nil || taskID == "" { + return + } + agent.mu.Lock() + ch, ok := agent.tasks[taskID] + result.Turn = agent.turns[taskID] + if ok { + delete(agent.tasks, taskID) + delete(agent.turns, taskID) + delete(agent.toolCalls, taskID) + delete(agent.childSessions, taskID) + } + agent.mu.Unlock() + if ok && ch != nil { + ch <- result + close(ch) + } +} + +func (p *AgentPool) forwardAOPFrame(agent *remoteAgent, correlationID string, event *aop.Event) { + if event == nil || event.SessionId == "" || event.Payload == nil { + return + } + if p.sessions != nil { + lookup := correlationID + if event.TurnId != "" { + lookup = event.TurnId + } + sessionID, ok := p.sessions.TaskSession(lookup) + if !ok { + // Session lifecycle frames are not part of a turn and therefore may + // legitimately arrive without a task correlation. Other uncorrelated + // AOP frames can belong to standalone scans and must not leak into the + // chat transcript merely because their scan ID occupies session_id. + switch event.Payload.(type) { + case *aop.Event_SessionStarted, *aop.Event_SessionEnded: + sessionID = event.SessionId + default: + sessionID = "" + } + } + if sessionID != "" { + p.sessions.BroadcastAOPEvent(sessionID, event) + } + } + switch event.Payload.(type) { + case *aop.Event_TurnEnded: + p.convergeTaskOnTurnEnd(agent, event.TurnId, event) + case *aop.Event_ToolResult: + p.convergeTaskOnToolResult(agent, correlationID, event) + } +} + +func (p *AgentPool) handleToolTelemetry(agent *remoteAgent, value *transport.ToolTelemetry) { + if value == nil { + return + } + var data any + if value.Data != nil { + data, _ = aop.DecodeJSON[any](value.Data) + } + event := output.ToolDataEvent{ + Tool: value.Tool, Kind: value.Kind, Target: value.Target, Data: data, CallID: value.CallId, + } + if value.Timestamp != nil { + event.Timestamp = value.Timestamp.AsTime() + } else { + event.Timestamp = time.Now() + } + if event.Kind != output.ToolDataProgress || p.hub == nil || event.CallID == "" { + return + } + line, ok := event.Data.(string) + if !ok { + return + } + line = output.StripANSI(line) + if line == "" { + return + } + p.hub.BroadcastScan(scanProgressEvent(event.CallID, line), false) +} + +func (p *AgentPool) forwardPTYFrame(frame pty.Frame) { + if frame.StreamID == "" { + return + } + p.ptyMu.RLock() + ch := p.ptySubs[frame.StreamID] + if ch != nil { + select { + case ch <- frame: + default: + p.ptyDrops.Add(1) + select { + case <-ch: + default: + } + select { + case ch <- frame: + default: + p.ptyDrops.Add(1) + } + } + } + p.ptyMu.RUnlock() +} diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 978992cb..3555d144 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -5,36 +5,119 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "sync" "sync/atomic" "time" - agentprovider "github.com/chainreactors/aiscan/agent/provider" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/webproto" + terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" "github.com/gorilla/websocket" + protobuf "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) // AgentInfo is the public view of a connected agent. type AgentInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Commands []string `json:"commands,omitempty"` - CommandsMenu []webproto.CommandSpec `json:"commands_menu,omitempty"` - Busy bool `json:"busy"` - ConnectAt time.Time `json:"connected_at"` - Node protocols.NodeRef `json:"node"` - Runtime webproto.AgentRuntime `json:"runtime,omitempty"` - Status webproto.AgentStatus `json:"status,omitempty"` - Stats webproto.AgentStats `json:"stats,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Commands []string `json:"commands,omitempty"` + CommandsMenu []*transport.CommandSpec `json:"commands_menu,omitempty"` + Busy bool `json:"busy"` + ConnectAt time.Time `json:"connected_at"` + Node protocols.NodeRef `json:"node"` + Runtime AgentRuntimeView `json:"runtime,omitempty"` + Status AgentStatusView `json:"status,omitempty"` + Stats AgentStatsView `json:"stats,omitempty"` +} + +type AgentRuntimeView struct { + Hostname string `json:"hostname,omitempty"` + Username string `json:"username,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + PID int32 `json:"pid,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +type AgentStatusView struct { + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Space string `json:"space,omitempty"` + Bound bool `json:"bound"` + ConfigError string `json:"config_error,omitempty"` +} + +type AgentStatsView struct { + Turns uint64 `json:"turns,omitempty"` + ToolCalls uint64 `json:"tool_calls,omitempty"` + RunningTools uint64 `json:"running_tools,omitempty"` + PromptTokens uint64 `json:"prompt_tokens,omitempty"` + CompletionTokens uint64 `json:"completion_tokens,omitempty"` + TotalTokens uint64 `json:"total_tokens,omitempty"` + CacheReadTokens uint64 `json:"cache_read_tokens,omitempty"` + CacheWriteTokens uint64 `json:"cache_write_tokens,omitempty"` + Assets uint64 `json:"assets,omitempty"` + Loots uint64 `json:"loots,omitempty"` + LastEvent string `json:"last_event,omitempty"` +} + +func cloneCommandSpecs(values []*transport.CommandSpec) []*transport.CommandSpec { + if len(values) == 0 { + return nil + } + out := make([]*transport.CommandSpec, 0, len(values)) + for _, value := range values { + if value != nil { + out = append(out, protobuf.Clone(value).(*transport.CommandSpec)) + } + } + return out +} + +func runtimeView(value *transport.AgentRuntimeInfo) AgentRuntimeView { + if value == nil { + return AgentRuntimeView{} + } + view := AgentRuntimeView{ + Hostname: value.Hostname, Username: value.Username, WorkingDir: value.WorkingDir, + OS: value.Os, Arch: value.Arch, PID: value.Pid, Capabilities: append([]string(nil), value.Capabilities...), + } + if value.Metadata != nil { + view.Meta, _ = aop.DecodeJSON[map[string]any](value.Metadata) + } + return view +} + +func statusView(value *transport.AgentStatus) AgentStatusView { + if value == nil { + return AgentStatusView{} + } + return AgentStatusView{Provider: value.Provider, Model: value.Model, Space: value.Space, Bound: value.Bound, ConfigError: value.ConfigError} +} + +func statsView(value *transport.AgentStats) AgentStatsView { + if value == nil { + return AgentStatsView{} + } + return AgentStatsView{ + Turns: value.Turns, ToolCalls: value.ToolCalls, RunningTools: value.RunningTools, + PromptTokens: value.InputTokens, CompletionTokens: value.OutputTokens, TotalTokens: value.TotalTokens, + CacheReadTokens: value.CacheReadTokens, CacheWriteTokens: value.CacheWriteTokens, + Assets: value.Assets, Loots: value.Loots, LastEvent: value.LastEvent, + } } type taskResult struct { Output string Result json.RawMessage + File *transport.FileResult Err string Turn int } @@ -43,15 +126,15 @@ type remoteAgent struct { id string name string commands []string - commandsMenu []webproto.CommandSpec - conn *websocket.Conn - sendCh chan webproto.Message - controlCh chan webproto.Message + commandsMenu []*transport.CommandSpec + close func() + sendCh chan *transport.ServerFrame + controlCh chan *transport.ServerFrame connectAt time.Time node protocols.NodeRef - runtime webproto.AgentRuntime - status webproto.AgentStatus - stats webproto.AgentStats + runtime *transport.AgentRuntimeInfo + status *transport.AgentStatus + stats *transport.AgentStats mu sync.Mutex tasks map[string]chan taskResult @@ -76,31 +159,30 @@ func (a *remoteAgent) info() AgentInfo { ID: a.id, Name: a.name, Commands: a.commands, - CommandsMenu: a.commandsMenu, + CommandsMenu: cloneCommandSpecs(a.commandsMenu), Busy: len(a.tasks) > 0, ConnectAt: a.connectAt, Node: a.node, - Runtime: a.runtime, - Status: a.status, - Stats: a.stats, + Runtime: runtimeView(a.runtime), + Status: statusView(a.status), + Stats: statsView(a.stats), } } // commandSpecs returns the agent's reported "/verb" catalog (its agent-scope // menu commands plus one per loaded skill). Immutable after register, so it // needs no lock. The hub merges it with its hub-scope commands in SessionMenu. -func (a *remoteAgent) commandSpecs() []webproto.CommandSpec { +func (a *remoteAgent) commandSpecs() []*transport.CommandSpec { if a == nil { return nil } - return a.commandsMenu + return cloneCommandSpecs(a.commandsMenu) } // SessionLookup resolves a task ID to its owning chat session. type SessionLookup interface { TaskSession(taskID string) (sessionID string, ok bool) - BroadcastDomainEvent(sessionID string, event DomainEvent) - BroadcastAOPEvent(sessionID string, event aop.Event) + BroadcastAOPEvent(sessionID string, event *aop.Event) } // RecordStore is the subset of Store needed for record persistence. @@ -159,8 +241,8 @@ func (p *AgentPool) SetSCOStore(store SCOStore) { // dangled every chat session bound to it — the session freezes the agent id at // creation, so on reconnect the stored id resolved to nothing and the chat // rejected every message as "not connected" even with the agent right back. -func agentKey(info webproto.RegisterPayload) string { - return info.Node.URI() +func agentKey(agentID, authority string) string { + return (protocols.NodeRef{ID: agentID, Authority: authority}).URI() } func (p *AgentPool) register(a *remoteAgent) { @@ -173,7 +255,9 @@ func (p *AgentPool) register(a *remoteAgent) { // Tear the stale connection down: its read loop then exits and its // identity-checked unregister no-ops, leaving `a` alone in the slot. if old != nil && old != a { - _ = old.conn.Close() + if old.close != nil { + old.close() + } } p.rebindPTY(a) } @@ -268,12 +352,12 @@ func (p *AgentPool) PickChat() *remoteAgent { // DispatchToolCall sends a canonical AOP tool.call to a tool-capable node. // The task completes only on the matching AOP tool.result. -func (p *AgentPool) DispatchToolCall(agentID, taskID string, call aop.ToolCallData) (<-chan taskResult, error) { +func (p *AgentPool) DispatchToolCall(agentID, taskID string, call *aop.ToolCall) (<-chan taskResult, error) { a := p.get(agentID) if a == nil { return nil, fmt.Errorf("agent %s not connected", agentID) } - call.ToolCallID = taskID + call.Id = taskID sessionID := taskID if p.sessions != nil { if sid, ok := p.sessions.TaskSession(taskID); ok { @@ -284,23 +368,21 @@ func (p *AgentPool) DispatchToolCall(agentID, taskID string, call aop.ToolCallDa if agentName == "" { agentName = a.id } - data, err := json.Marshal(call) - if err != nil { - return nil, fmt.Errorf("marshal tool.call: %w", err) - } - event := aop.Event{ - Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano), - SessionID: sessionID, TurnID: taskID, Agent: agentName, Data: data, + event := &aop.Event{ + Id: generateID(), EmittedAt: timestamppb.Now(), SessionId: sessionID, TurnId: taskID, Emitter: agentName, + Payload: &aop.Event_ToolCall{ToolCall: call}, } - payload, _ := json.Marshal(event) a.mu.Lock() if a.toolCalls == nil { a.toolCalls = map[string]struct{}{} } a.toolCalls[taskID] = struct{}{} a.mu.Unlock() - ch, err := p.dispatchMessage(agentID, taskID, webproto.Message{ - Type: webproto.TypeAOP, TaskID: taskID, TurnID: taskID, Payload: payload, + ch, err := p.dispatchFrame(agentID, taskID, &transport.ServerFrame{ + CorrelationId: taskID, + Payload: &transport.ServerFrame_ToolCall{ToolCall: &transport.ToolCallRequest{ + TaskId: taskID, SessionId: sessionID, TurnId: taskID, Call: call, + }}, }) if err != nil { a.mu.Lock() @@ -316,72 +398,112 @@ func (p *AgentPool) DispatchToolCall(agentID, taskID string, call aop.ToolCallDa // DispatchChat sends a natural-language prompt to an LLM-capable agent. func (p *AgentPool) DispatchChat(agentID, taskID, prompt string) (<-chan taskResult, error) { - return p.DispatchRun(agentID, taskID, webproto.RunPayload{Parts: []aop.MessagePart{{Type: aop.PartText, Text: prompt}}}) + return p.DispatchRun(agentID, &aop.RunTurnRequest{ + RequestId: taskID, TurnId: taskID, + Input: &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: prompt}}}}}, + }) +} + +func (p *AgentPool) DispatchOpenSession(agentID string, request *aop.OpenSessionRequest) (<-chan taskResult, error) { + if request == nil || strings.TrimSpace(request.RequestId) == "" || strings.TrimSpace(request.SessionId) == "" { + return nil, fmt.Errorf("open session request_id and session_id are required") + } + return p.dispatchFrame(agentID, request.RequestId, &transport.ServerFrame{ + CorrelationId: request.RequestId, + Payload: &transport.ServerFrame_OpenSession{OpenSession: request}, + }) +} + +func (p *AgentPool) SessionOpen(agentID, sessionID string) bool { + agent := p.get(agentID) + if agent == nil { + return false + } + agent.mu.Lock() + defer agent.mu.Unlock() + _, ok := agent.openSessions[sessionID] + return ok +} + +func (p *AgentPool) DispatchCloseSession(agentID string, request *aop.CloseSessionRequest) (<-chan taskResult, error) { + if request == nil || strings.TrimSpace(request.RequestId) == "" || strings.TrimSpace(request.SessionId) == "" { + return nil, fmt.Errorf("close session request_id and session_id are required") + } + return p.dispatchFrame(agentID, request.RequestId, &transport.ServerFrame{ + CorrelationId: request.RequestId, + Payload: &transport.ServerFrame_CloseSession{CloseSession: request}, + }) } -func (p *AgentPool) DispatchRun(agentID, turnID string, run webproto.RunPayload) (<-chan taskResult, error) { +func (p *AgentPool) DispatchRun(agentID string, request *aop.RunTurnRequest) (<-chan taskResult, error) { a := p.get(agentID) if a == nil { return nil, fmt.Errorf("agent %s not connected", agentID) } - if run.SessionID != "" { + if request == nil || request.Input == nil || request.TurnId == "" { + return nil, fmt.Errorf("run request with input and turn_id is required") + } + if request.SessionId != "" { a.mu.Lock() - _, opened := a.openSessions[run.SessionID] + _, opened := a.openSessions[request.SessionId] if !opened { - a.openSessions[run.SessionID] = struct{}{} + a.openSessions[request.SessionId] = struct{}{} } a.mu.Unlock() if !opened { - openPayload, _ := json.Marshal(webproto.SessionOpenPayload{SessionID: run.SessionID}) select { - case a.sendCh <- webproto.Message{Type: webproto.TypeSessionOpen, Payload: openPayload}: + case a.sendCh <- &transport.ServerFrame{CorrelationId: "open:" + request.SessionId, Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ + RequestId: "open:" + request.SessionId, SessionId: request.SessionId, Participant: agentID, + }}}: default: a.mu.Lock() - delete(a.openSessions, run.SessionID) + delete(a.openSessions, request.SessionId) a.mu.Unlock() return nil, fmt.Errorf("agent %s send channel full", agentID) } } } - payload, err := json.Marshal(run) - if err != nil { - return nil, fmt.Errorf("marshal run: %w", err) - } - return p.dispatchMessage(agentID, turnID, webproto.Message{Type: webproto.TypeRun, TurnID: turnID, Payload: payload}) + return p.dispatchFrame(agentID, request.TurnId, &transport.ServerFrame{ + CorrelationId: request.TurnId, Payload: &transport.ServerFrame_RunTurn{RunTurn: request}, + }) } -func (p *AgentPool) DispatchCommand(agentID, taskID string, command webproto.CommandPayload) (<-chan taskResult, error) { +func (p *AgentPool) DispatchCommand(agentID string, command *transport.CommandRequest) (<-chan taskResult, error) { + if command == nil || command.TaskId == "" { + return nil, fmt.Errorf("command task_id is required") + } + taskID := command.TaskId a := p.get(agentID) if a == nil { return nil, fmt.Errorf("agent %s not connected", agentID) } - if command.SessionID != "" { + if command.SessionId != "" { a.mu.Lock() - _, opened := a.openSessions[command.SessionID] + _, opened := a.openSessions[command.SessionId] if !opened { - a.openSessions[command.SessionID] = struct{}{} + a.openSessions[command.SessionId] = struct{}{} } a.mu.Unlock() if !opened { - openPayload, _ := json.Marshal(webproto.SessionOpenPayload{SessionID: command.SessionID}) select { - case a.sendCh <- webproto.Message{Type: webproto.TypeSessionOpen, Payload: openPayload}: + case a.sendCh <- &transport.ServerFrame{CorrelationId: "open:" + command.SessionId, Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ + RequestId: "open:" + command.SessionId, SessionId: command.SessionId, Participant: agentID, + }}}: default: a.mu.Lock() - delete(a.openSessions, command.SessionID) + delete(a.openSessions, command.SessionId) a.mu.Unlock() return nil, fmt.Errorf("agent %s send channel full", agentID) } } } - payload, err := json.Marshal(command) - if err != nil { - return nil, fmt.Errorf("marshal command: %w", err) - } - return p.dispatchMessage(agentID, taskID, webproto.Message{Type: webproto.TypeCommand, TaskID: taskID, Payload: payload}) + return p.dispatchFrame(agentID, taskID, &transport.ServerFrame{ + CorrelationId: taskID, + Payload: &transport.ServerFrame_Command{Command: protobuf.Clone(command).(*transport.CommandRequest)}, + }) } -func (p *AgentPool) dispatchMessage(agentID, taskID string, msg webproto.Message) (<-chan taskResult, error) { +func (p *AgentPool) dispatchFrame(agentID, taskID string, frame *transport.ServerFrame) (<-chan taskResult, error) { a := p.get(agentID) if a == nil { return nil, fmt.Errorf("agent %s not connected", agentID) @@ -393,7 +515,7 @@ func (p *AgentPool) dispatchMessage(agentID, taskID string, msg webproto.Message a.mu.Unlock() select { - case a.sendCh <- msg: + case a.sendCh <- frame: default: a.mu.Lock() delete(a.tasks, taskID) @@ -438,20 +560,20 @@ func (a *remoteAgent) queueConfigReload() bool { a.reloadPending = true a.mu.Unlock() - msg := webproto.Message{Type: "config"} + frame := &transport.ServerFrame{Payload: &transport.ServerFrame_ReloadConfig{ReloadConfig: &transport.ReloadConfig{}}} select { - case a.controlCh <- msg: + case a.controlCh <- frame: return true default: } go func() { if a.done == nil { - a.controlCh <- msg + a.controlCh <- frame return } select { - case a.controlCh <- msg: + case a.controlCh <- frame: case <-a.done: a.mu.Lock() a.reloadPending = false @@ -467,20 +589,20 @@ func (a *remoteAgent) finishConfigReload() { a.mu.Unlock() } -func (p *AgentPool) SendAgentMessage(agentID string, msg webproto.Message) error { +func (p *AgentPool) sendAgentFrame(agentID string, frame *transport.ServerFrame) error { a := p.get(agentID) if a == nil { return fmt.Errorf("agent %s not connected", agentID) } select { - case a.sendCh <- msg: + case a.sendCh <- frame: return nil default: return fmt.Errorf("agent %s send channel full", agentID) } } -func (p *AgentPool) CancelTask(agentID, taskID string) error { +func (p *AgentPool) CancelTask(agentID, taskID string, sessionID ...string) error { a := p.get(agentID) if a == nil { return nil @@ -498,36 +620,42 @@ func (p *AgentPool) CancelTask(agentID, taskID string) error { if !pending { return nil } - cancelMessage := webproto.Message{Type: webproto.TypeRunCancel, TurnID: taskID} + var chatSessionID string + if len(sessionID) > 0 { + chatSessionID = sessionID[0] + } + cancelFrame := &transport.ServerFrame{CorrelationId: taskID, Payload: &transport.ServerFrame_CancelTurn{CancelTurn: &aop.CancelTurnRequest{ + RequestId: taskID, SessionId: chatSessionID, TurnId: taskID, + }}} if isToolCall { - cancelMessage = webproto.Message{Type: "cancel", TaskID: taskID} + cancelFrame = &transport.ServerFrame{CorrelationId: taskID, Payload: &transport.ServerFrame_CancelOperation{CancelOperation: &transport.CancelOperation{TaskId: taskID}}} } if resultCh != nil { close(resultCh) } - a.enqueueControl(cancelMessage) + a.enqueueControl(cancelFrame) return nil } // enqueueControl never drops a control frame because task traffic temporarily // fills the channel. The pending send is bounded by the agent connection's // lifetime and the writer always drains controlCh before sendCh. -func (a *remoteAgent) enqueueControl(msg webproto.Message) { +func (a *remoteAgent) enqueueControl(frame *transport.ServerFrame) { if a == nil || a.controlCh == nil { return } select { - case a.controlCh <- msg: + case a.controlCh <- frame: return default: } go func() { if a.done == nil { - a.controlCh <- msg + a.controlCh <- frame return } select { - case a.controlCh <- msg: + case a.controlCh <- frame: case <-a.done: } }() @@ -555,7 +683,11 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h write := func(frame pty.Frame) error { writeMu.Lock() defer writeMu.Unlock() - return conn.WriteJSON(frame) + data, err := terminalcodec.Marshal(frame) + if err != nil { + return err + } + return conn.WriteMessage(websocket.TextMessage, data) } go func() { @@ -579,16 +711,21 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h } for { - var frame pty.Frame - if err := conn.ReadJSON(&frame); err != nil { + _, data, err := conn.ReadMessage() + if err != nil { return } + frame, err := terminalcodec.Unmarshal(data) + if err != nil { + _ = write(pty.Frame{Type: pty.FrameError, StreamID: terminalID, Error: "invalid terminal protobuf JSON: " + err.Error()}) + continue + } if frame.Type == "" { _ = write(pty.Frame{Type: pty.FrameError, StreamID: terminalID, Error: "PTY frame type is required"}) continue } frame.StreamID = terminalID - if err := p.SendAgentMessage(agentID, webproto.NewPTYMessage(frame)); err != nil { + if err := p.sendAgentFrame(agentID, &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}); err != nil { _ = write(pty.Frame{Type: pty.FrameError, StreamID: terminalID, Error: err.Error()}) continue } @@ -596,11 +733,11 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h } func (p *AgentPool) CancelPTY(agentID, terminalID string) { - _ = p.SendAgentMessage(agentID, webproto.NewPTYMessage(pty.Frame{Type: pty.FrameKill, StreamID: terminalID})) + _ = p.sendAgentFrame(agentID, &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(pty.Frame{Type: pty.FrameKill, StreamID: terminalID})}}) } func (p *AgentPool) CloseTerminal(agentID, terminalID string) { - _ = p.SendAgentMessage(agentID, webproto.NewPTYMessage(pty.Frame{Type: pty.FrameDetach, StreamID: terminalID})) + _ = p.sendAgentFrame(agentID, &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(pty.Frame{Type: pty.FrameDetach, StreamID: terminalID})}}) } func (p *AgentPool) subscribePTY(agentID, terminalID string) (<-chan pty.Frame, bool, func()) { @@ -661,43 +798,13 @@ func (p *AgentPool) rebindPTY(agent *remoteAgent) { terminalID := terminalID go func() { select { - case agent.sendCh <- webproto.NewPTYMessage(pty.Frame{Type: pty.FrameList, StreamID: terminalID}): + case agent.sendCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(pty.Frame{Type: pty.FrameList, StreamID: terminalID})}}: case <-agent.done: } }() } } -func (p *AgentPool) forwardPTYMessage(msg webproto.Message) bool { - if msg.Type != webproto.TypePTY { - return false - } - frame, err := webproto.DecodePTYMessage(msg) - if err != nil || frame.StreamID == "" { - return true - } - p.ptyMu.RLock() - ch := p.ptySubs[frame.StreamID] - if ch != nil { - select { - case ch <- frame: - default: - p.ptyDrops.Add(1) - select { - case <-ch: - default: - } - select { - case ch <- frame: - default: - p.ptyDrops.Add(1) - } - } - } - p.ptyMu.RUnlock() - return true -} - // --- WebSocket handler --- func buildUpgrader(origins []string) websocket.Upgrader { @@ -717,315 +824,6 @@ func buildUpgrader(origins []string) websocket.Upgrader { } } -// HandleWS upgrades to WebSocket and manages the agent lifecycle. -// This single endpoint replaces register + stream + output + complete. -func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { - conn, err := p.upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - - // First message must be register. - var reg webproto.Message - if err := conn.ReadJSON(®); err != nil || reg.Type != "register" { - conn.Close() - return - } - var info webproto.RegisterPayload - if reg.Payload != nil { - _ = json.Unmarshal(reg.Payload, &info) - } - // Resolve the stable pool key from the raw payload before the display-name - // default below, so an anonymous client still gets a unique per-connection id - // instead of every nameless agent colliding on the literal "agent". - id := agentKey(info) - if id == "" { - conn.Close() - return - } - if info.Name == "" { - info.Name = "agent" - } - - agent := &remoteAgent{ - id: id, - name: info.Name, - commands: info.Commands, - commandsMenu: info.CommandsMenu, - conn: conn, - sendCh: make(chan webproto.Message, 32), - controlCh: make(chan webproto.Message, 32), - connectAt: time.Now(), - node: info.Node, - runtime: info.Runtime, - status: info.Status, - stats: info.Stats, - tasks: make(map[string]chan taskResult), - turns: make(map[string]int), - openSessions: make(map[string]struct{}), - childSessions: make(map[string]map[string]struct{}), - done: make(chan struct{}), - } - p.register(agent) - defer func() { - p.unregister(agent) - conn.Close() - close(agent.done) - }() - - // Send connected ack. - ack, _ := json.Marshal(map[string]string{"agent_id": agent.id, "name": agent.name}) - if err := conn.WriteJSON(webproto.Message{Type: "connected", Payload: ack}); err != nil { - return - } - - // Write goroutine: sendCh → WebSocket. - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - closeBrokenConnection := func() { - // A failed writer must tear down the shared WebSocket so the read - // loop exits, unregisters this agent, and lets the client reconnect. - // Otherwise the pool keeps a zombie "online" agent whose sendCh has - // no consumer; PTY open/list requests then disappear indefinitely. - _ = conn.Close() - } - for { - // Give control frames priority over task/output traffic. - select { - case msg := <-agent.controlCh: - if msg.Type == "config" { - agent.finishConfigReload() - } - if err := conn.WriteJSON(msg); err != nil { - closeBrokenConnection() - return - } - continue - default: - } - select { - case msg := <-agent.controlCh: - if msg.Type == "config" { - agent.finishConfigReload() - } - if err := conn.WriteJSON(msg); err != nil { - closeBrokenConnection() - return - } - case msg, ok := <-agent.sendCh: - if !ok { - return - } - if err := conn.WriteJSON(msg); err != nil { - closeBrokenConnection() - return - } - case <-ticker.C: - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { - closeBrokenConnection() - return - } - case <-agent.done: - return - } - } - }() - - // Read loop: WebSocket → dispatch. - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - return - } - p.handleAgentMessage(agent, msg) - } -} - -func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) { - if p.forwardPTYMessage(msg) { - return - } - - switch msg.Type { - case "agent.stats": - var stats webproto.AgentStats - if len(msg.Payload) > 0 && json.Unmarshal(msg.Payload, &stats) == nil { - a.mu.Lock() - a.stats = stats - a.mu.Unlock() - } - - case "agent.status": - var status webproto.AgentStatus - if len(msg.Payload) > 0 && json.Unmarshal(msg.Payload, &status) == nil { - a.mu.Lock() - if status.Provider != "" { - a.status.Provider = status.Provider - } - if status.Model != "" { - a.status.Model = status.Model - } - a.status.Bound = status.Bound - a.status.ConfigError = status.ConfigError - if status.Space != "" { - a.status.Space = status.Space - } - a.mu.Unlock() - } - - case "tool.data": - // Progress lines stream live to the scan/console topics; structured - // scanner data persists through the libcstx-normalized tool.sco path. - if p.hub == nil || msg.TaskID == "" { - return - } - var event output.ToolDataEvent - if json.Unmarshal(msg.Payload, &event) != nil || event.Kind != output.ToolDataProgress { - return - } - line, ok := event.Data.(string) - if !ok { - return - } - data := output.StripANSI(line) - if data == "" { - return - } - p.hub.Broadcast(msg.TaskID, HubEvent{ - Type: "progress", - Data: mustJSON(map[string]string{"scan_id": msg.TaskID, "data": data}), - }) - p.forwardToSession(a, msg.TaskID, DomainEvent{ - Type: DomainEventScanProgress, - ScanID: msg.TaskID, - Data: data, - }) - - case "tool.sco": - if p.sco == nil || len(msg.Payload) == 0 { - return - } - var payload struct { - CallID string `json:"call_id"` - Nodes []json.RawMessage `json:"nodes"` - } - if json.Unmarshal(msg.Payload, &payload) != nil || len(payload.Nodes) == 0 { - return - } - scanID := payload.CallID - if scanID == "" { - scanID = msg.TaskID - } - if scanID == "" { - scanID = "standalone" - } - _ = p.sco.UpsertSCONodes(context.Background(), scanID, payload.Nodes) - - case "config.result": - var result webproto.ConfigReloadResult - if len(msg.Payload) > 0 && json.Unmarshal(msg.Payload, &result) == nil { - a.mu.Lock() - if result.OK { - a.status.Provider = agentprovider.NormalizeProvider(result.Provider) - a.status.Model = result.Model - a.status.ConfigError = "" - } else { - a.status.ConfigError = result.Error - } - a.mu.Unlock() - } - - case webproto.TypeSessionOpened: - var payload webproto.SessionLifecyclePayload - if json.Unmarshal(msg.Payload, &payload) == nil && payload.SessionID != "" { - a.mu.Lock() - a.openSessions[payload.SessionID] = struct{}{} - a.mu.Unlock() - } - - case webproto.TypeSessionClosed: - var payload webproto.SessionLifecyclePayload - if json.Unmarshal(msg.Payload, &payload) == nil && payload.SessionID != "" { - a.mu.Lock() - delete(a.openSessions, payload.SessionID) - a.mu.Unlock() - } - - case webproto.TypeCommandResult: - a.mu.Lock() - ch, ok := a.tasks[msg.TaskID] - _, isToolCall := a.toolCalls[msg.TaskID] - if ok { - delete(a.tasks, msg.TaskID) - delete(a.turns, msg.TaskID) - delete(a.toolCalls, msg.TaskID) - } - a.mu.Unlock() - if ok && ch != nil { - result := taskResult{Result: msg.Payload} - if isToolCall { - result = taskResult{Err: "direct tool task returned command.result; expected AOP tool.result"} - } - ch <- result - close(ch) - } - - // complete/error are the terminal envelopes of the file RPCs only; agent - // semantics (chat, tool calls) converge on AOP events. - case "complete": - a.mu.Lock() - ch, ok := a.tasks[msg.TaskID] - turn := a.turns[msg.TaskID] - if ok { - delete(a.tasks, msg.TaskID) - delete(a.turns, msg.TaskID) - delete(a.toolCalls, msg.TaskID) - delete(a.childSessions, msg.TaskID) - } - a.mu.Unlock() - if ok && ch != nil { - res := taskResult{Output: msg.Data, Result: msg.Payload, Turn: turn} - ch <- res - close(ch) - } - - case "error": - correlationID := msg.TurnID - if correlationID == "" { - correlationID = msg.TaskID - } - a.mu.Lock() - ch, ok := a.tasks[correlationID] - turn := a.turns[correlationID] - if ok { - delete(a.tasks, correlationID) - delete(a.turns, correlationID) - delete(a.toolCalls, correlationID) - delete(a.childSessions, correlationID) - } - a.mu.Unlock() - if ok && ch != nil { - var payload webproto.ErrorPayload - errText := msg.Data - if json.Unmarshal(msg.Payload, &payload) == nil && payload.Message != "" { - errText = payload.Message - } - ch <- taskResult{Err: errText, Turn: turn} - close(ch) - } - - case "aop": - // The transport identifies only the protocol. Event semantics live in - // the untouched AOP payload and are validated once at this ingress. - p.forwardAOPEvent(a, msg) - - default: - // Unknown control frames are intentionally not projected into another - // protocol. Producers must emit either a documented control frame or AOP. - } -} - func (p *AgentPool) recordScanResultStats(a *remoteAgent, payload json.RawMessage) { if a == nil || len(payload) == 0 { return @@ -1035,67 +833,23 @@ func (p *AgentPool) recordScanResultStats(a *remoteAgent, payload json.RawMessag return } a.mu.Lock() - a.stats.Assets += len(result.Assets) + if a.stats == nil { + a.stats = &transport.AgentStats{} + } + a.stats.Assets += uint64(len(result.Assets)) if result.Summary.Loots > 0 { - a.stats.Loots += result.Summary.Loots + a.stats.Loots += uint64(result.Summary.Loots) } else { - a.stats.Loots += len(result.Loots) + a.stats.Loots += uint64(len(result.Loots)) } a.mu.Unlock() } -func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event DomainEvent) { - if p.sessions == nil || taskID == "" { - return - } - sid, ok := p.sessions.TaskSession(taskID) - if !ok { - return - } - if event.AgentID == "" { - event.AgentID = a.id - } - if event.AgentName == "" { - event.AgentName = a.name - } - p.sessions.BroadcastDomainEvent(sid, event) -} - -func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) { - var aopEv aop.Event - if len(msg.Payload) > 0 { - _ = json.Unmarshal(msg.Payload, &aopEv) - } - if !aopEv.Valid() { - return - } - // Session-topic broadcast is optional (scans dispatched outside chat have - // no chat session); task convergence below is not. - if p.sessions != nil { - correlationID := msg.TurnID - if msg.TaskID != "" { - correlationID = msg.TaskID - } - if sid, ok := p.sessions.TaskSession(correlationID); ok { - p.sessions.BroadcastAOPEvent(sid, aopEv) - } - } - - switch aopEv.Type { - case aop.TypeTurnEnd: - p.convergeTaskOnTurnEnd(a, msg.TurnID, aopEv) - - case aop.TypeToolResult: - p.convergeTaskOnToolResult(a, msg.TaskID, aopEv) - } - -} - // convergeTaskOnToolResult closes a tool.call task on its terminal // tool.result: text content becomes the task output, structured Details the // scan result, and an is_error content the task error. tool.result events of // chat tasks (LLM tool use) are not terminals and pass through. -func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev aop.Event) { +func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev *aop.Event) { a.mu.Lock() if _, isToolCall := a.toolCalls[taskID]; !isToolCall { a.mu.Unlock() @@ -1113,20 +867,15 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a if !ok || ch == nil { return } - var d aop.ToolResultData - if err := json.Unmarshal(ev.Data, &d); err != nil { - ch <- taskResult{Err: "decode tool.result: " + err.Error(), Turn: turn} - close(ch) - return - } - res := taskResult{Output: aop.ToolResultText(d.Content), Turn: turn} + d := ev.GetToolResult() + res := taskResult{Output: aopToolResultText(d.Output), Turn: turn} if d.IsError { res.Err = res.Output res.Output = "" } var details json.RawMessage - if d.Details != nil { - details, _ = json.Marshal(d.Details) + if d.Detail != nil { + details = append(details, d.Detail.Data...) res.Result = details } ch <- res @@ -1139,7 +888,7 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a // ends: this terminal event drives task cleanup; child (derived sub-agent) // session ends and mid-run AOP error events are not terminal. Idempotent — // a file-RPC complete frame arriving after this close is a no-op. -func (p *AgentPool) convergeTaskOnTurnEnd(a *remoteAgent, taskID string, ev aop.Event) { +func (p *AgentPool) convergeTaskOnTurnEnd(a *remoteAgent, taskID string, ev *aop.Event) { if taskID == "" { return } @@ -1156,18 +905,29 @@ func (p *AgentPool) convergeTaskOnTurnEnd(a *remoteAgent, taskID string, ev aop. if !ok || ch == nil { return } - var d aop.TurnEndData - _ = json.Unmarshal(ev.Data, &d) + d := ev.GetTurnEnded() res := taskResult{Turn: turn} // A canceled run still carries the ctx error ("context canceled") — only // non-canceled stops surface it as a task error. - if d.Stop != "canceled" && d.Error != "" { - res.Err = d.Error + if d.StopReason != "canceled" && d.Error != nil { + res.Err = d.Error.Message } ch <- res close(ch) } +func aopToolResultText(content []*aop.Content) string { + var parts []string + for _, item := range content { + if text := item.GetText().GetText(); text != "" { + parts = append(parts, text) + } else if opaque := item.GetOpaque(); opaque != nil { + parts = append(parts, string(opaque.Value.GetData())) + } + } + return strings.Join(parts, "\n") +} + func (p *AgentPool) persistResultRecords(a *remoteAgent, taskID string, payload json.RawMessage) { if p.records == nil || len(payload) == 0 { return diff --git a/pkg/web/agents_session_end_test.go b/pkg/web/agents_session_end_test.go index e4ae3887..f3b1e31a 100644 --- a/pkg/web/agents_session_end_test.go +++ b/pkg/web/agents_session_end_test.go @@ -1,35 +1,24 @@ package web import ( - "encoding/json" + "context" "testing" "time" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" ) -func sessionEvent(t *testing.T, typ, sessionID string, data any) aop.Event { +func sessionEvent(t *testing.T, sessionID string, event *aop.Event) *aop.Event { t.Helper() - raw, err := json.Marshal(data) - if err != nil { - t.Fatal(err) - } - return aop.Event{ - Type: typ, - TS: time.Now().UTC().Format(time.RFC3339Nano), - SessionID: sessionID, - Agent: "test-agent", - Data: raw, - } + event.SessionId = sessionID + event.Emitter = "test-agent" + return event } -func forwardEvent(t *testing.T, pool *AgentPool, remote *remoteAgent, taskID string, ev aop.Event) { +func forwardEvent(t *testing.T, pool *AgentPool, remote *remoteAgent, taskID string, event *aop.Event) { t.Helper() - payload, err := json.Marshal(ev) - if err != nil { - t.Fatal(err) - } - pool.forwardAOPEvent(remote, WSMessage{Type: "aop", TurnID: taskID, Payload: payload}) + pool.forwardAOPFrame(remote, taskID, event) } func newChatTaskRemote() (*remoteAgent, chan taskResult) { @@ -79,8 +68,8 @@ func TestChatTaskConvergesOnTurnEnd(t *testing.T) { pool.SetSessionLookup(&evalSink{sid: "sess-1"}) remote, ch := newChatTaskRemote() - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeTurnStart, "agent-session", aop.TurnStartData{})) - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeTurnEnd, "agent-session", aop.TurnEndData{Stop: "completed"})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "agent-session", &aop.Event{TurnId: "task-1", Payload: &aop.Event_TurnStarted{TurnStarted: &aop.TurnStarted{}}})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "agent-session", &aop.Event{TurnId: "task-1", Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}})) res := readResult(t, ch) if res.Err != "" { @@ -98,10 +87,15 @@ func TestChatTaskTurnEndErrorPopulatesErr(t *testing.T) { // A mid-run AOP error is display-only; the terminal turn.end carries // the failure. - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeError, "agent-session", aop.ErrorData{Message: "boom"})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "agent-session", &aop.Event{TurnId: "task-1", Payload: &aop.Event_Error{Error: &aop.ProtocolError{Message: "boom"}}})) assertTaskOpen(t, remote, ch) - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeTurnEnd, "agent-session", aop.TurnEndData{Stop: "error", Error: "boom"})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "agent-session", &aop.Event{ + TurnId: "task-1", + Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{ + StopReason: "error", Error: &aop.ProtocolError{Message: "boom"}, + }}, + })) res := readResult(t, ch) if res.Err != "boom" { t.Fatalf("err = %q, want %q", res.Err, "boom") @@ -114,7 +108,12 @@ func TestChatTaskCanceledTurnEndHasNoErr(t *testing.T) { remote, ch := newChatTaskRemote() // The agent reports the ctx error on cancel; it must not surface as a task error. - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeTurnEnd, "agent-session", aop.TurnEndData{Stop: "canceled", Error: "context canceled"})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "agent-session", &aop.Event{ + TurnId: "task-1", + Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{ + StopReason: "canceled", Error: &aop.ProtocolError{Message: "context canceled"}, + }}, + })) res := readResult(t, ch) if res.Err != "" { t.Fatalf("err = %q, want empty for canceled run", res.Err) @@ -126,11 +125,11 @@ func TestChildSessionEndDoesNotConvergeTask(t *testing.T) { pool.SetSessionLookup(&evalSink{sid: "sess-1"}) remote, ch := newChatTaskRemote() - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeSessionStart, "child-1", aop.SessionStartData{ParentSessionID: "agent-session"})) - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeSessionEnd, "child-1", aop.SessionEndData{Reason: "completed"})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "child-1", &aop.Event{Payload: &aop.Event_SessionStarted{SessionStarted: &aop.SessionStarted{ParentSessionId: "agent-session"}}})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "child-1", &aop.Event{Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: "completed"}}})) assertTaskOpen(t, remote, ch) - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeTurnEnd, "agent-session", aop.TurnEndData{Stop: "completed"})) + forwardEvent(t, pool, remote, "task-1", sessionEvent(t, "agent-session", &aop.Event{TurnId: "task-1", Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}})) readResult(t, ch) } @@ -139,15 +138,60 @@ func TestTaskConvergesOnceWhenTurnEndAndCompleteArrive(t *testing.T) { pool.SetSessionLookup(&evalSink{sid: "sess-1"}) remote, ch := newChatTaskRemote() - forwardEvent(t, pool, remote, "task-1", sessionEvent(t, aop.TypeTurnEnd, "agent-session", aop.TurnEndData{Stop: "completed"})) + event := sessionEvent(t, "agent-session", &aop.Event{TurnId: "task-1", Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}}) + forwardEvent(t, pool, remote, "task-1", event) res := readResult(t, ch) if res.Err != "" { t.Fatalf("err = %q, want empty", res.Err) } - // A leftover complete frame (mixed-version agent) must be a no-op. - pool.handleAgentMessage(remote, WSMessage{Type: "complete", TaskID: "task-1"}) + // Duplicate terminal events must be idempotent. + forwardEvent(t, pool, remote, "task-1", event) if _, ok := <-ch; ok { t.Fatal("channel delivered a second result") } } + +func TestDisconnectedAcceptedTurnEmitsOneTerminalEvent(t *testing.T) { + store, err := NewSQLiteStore(t.TempDir() + "/chat.db") + if err != nil { + t.Fatal(err) + } + defer store.Close() + createStoredSession(t, store, "session-1") + service := NewService(ServiceConfig{Store: store}) + pool := NewAgentPool(service.Hub()) + service.SetAgentPool(pool) + remote := &remoteAgent{ + id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 2), controlCh: make(chan *transport.ServerFrame, 2), + tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: make(map[string]struct{}), + childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), + } + pool.agents[remote.id] = remote + if _, err := store.db.Exec(`UPDATE chat_sessions SET agent_id = ? WHERE id = ?`, remote.id, "session-1"); err != nil { + t.Fatal(err) + } + service.handleAgentRun("session-1", &aop.RunTurnRequest{ + RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", + Input: &aop.Message{Role: "user", Content: []*aop.Content{aop.Text("hello")}}, + }) + pool.unregister(remote) + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + events, _ := store.ListAOPEvents(context.Background(), "session-1", 10) + if len(events) == 1 { + ended := events[0].GetTurnEnded() + if events[0].TurnId != "turn-1" || events[0].Seq != 1 || ended == nil || ended.Error.GetCode() != "agent_disconnected" { + t.Fatalf("terminal event = %+v", events[0]) + } + service.BroadcastAOPEvent("session-1", &aop.Event{SessionId: "session-1", TurnId: "turn-1", Seq: 2, Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}}) + after, _ := store.ListAOPEvents(context.Background(), "session-1", 10) + if len(after) != 1 { + t.Fatalf("late duplicate terminal persisted: %+v", after) + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("disconnect terminal event was not persisted") +} diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index 5beb8aed..d6aad8b5 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -15,18 +15,43 @@ import ( webstatic "github.com/chainreactors/aiscan/web" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/webproto" + terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/gorilla/websocket" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" ) -// Keep wire fixtures concise without exposing a production alias. -type WSMessage = webproto.Message +func writeAgentFrame(t *testing.T, conn *websocket.Conn, frame *transport.AgentFrame) { + t.Helper() + raw, err := protojson.Marshal(frame) + if err != nil { + t.Fatal(err) + } + if err := conn.WriteMessage(websocket.TextMessage, raw); err != nil { + t.Fatal(err) + } +} + +func readServerFrame(t *testing.T, conn *websocket.Conn) *transport.ServerFrame { + t.Helper() + _, raw, err := conn.ReadMessage() + if err != nil { + t.Fatal(err) + } + frame := new(transport.ServerFrame) + if err := protojson.Unmarshal(raw, frame); err != nil { + t.Fatal(err) + } + return frame +} type recordingSCOStore struct { scanID string @@ -44,15 +69,9 @@ func TestAgentPoolPersistsToolSCO(t *testing.T) { pool := NewAgentPool(NewHub()) pool.SetSCOStore(store) node := json.RawMessage(`{"cstx_id":"ip:127.0.0.1","cstx_type":"ip","value":"127.0.0.1"}`) - payload, err := json.Marshal(map[string]any{ - "call_id": "call-gogo-1", - "nodes": []json.RawMessage{node}, - }) - if err != nil { - t.Fatal(err) - } - - pool.handleAgentMessage(&remoteAgent{}, WSMessage{Type: "tool.sco", Payload: payload}) + pool.handleAgentFrame(&remoteAgent{}, &transport.AgentFrame{Payload: &transport.AgentFrame_ScoNodes{ScoNodes: &transport.ScoNodes{ + CallId: "call-gogo-1", Nodes: [][]byte{node}, + }}}) if store.scanID != "call-gogo-1" { t.Fatalf("scan id = %q, want tool call id", store.scanID) @@ -63,26 +82,17 @@ func TestAgentPoolPersistsToolSCO(t *testing.T) { } func dialAgent(t *testing.T, srv *httptest.Server, name string, commands []string) *websocket.Conn { - return dialAgentWithIdentity(t, srv, name, commands, "node-"+name, webproto.AgentStatus{Space: "case-test"}) + return dialAgentWithIdentity(t, srv, name, commands, "node-"+name, transport.AgentStatus{Space: "case-test"}) } func writeAgentPTY(t *testing.T, conn *websocket.Conn, frame pty.Frame) { t.Helper() - if err := conn.WriteJSON(webproto.NewPTYMessage(frame)); err != nil { - t.Fatalf("agent write PTY %s: %v", frame.Type, err) - } + writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}) } func readAgentPTY(t *testing.T, conn *websocket.Conn, want pty.FrameType) pty.Frame { t.Helper() - var msg WSMessage - if err := conn.ReadJSON(&msg); err != nil { - t.Fatalf("agent read PTY %s: %v", want, err) - } - frame, err := webproto.DecodePTYMessage(msg) - if err != nil { - t.Fatalf("decode agent PTY %s: %v", want, err) - } + frame := terminalcodec.FromProto(readServerFrame(t, conn).GetTerminal()) if frame.Type != want { t.Fatalf("agent expected PTY %s, got %s", want, frame.Type) } @@ -91,24 +101,32 @@ func readAgentPTY(t *testing.T, conn *websocket.Conn, want pty.FrameType) pty.Fr func writeBrowserPTY(t *testing.T, conn *websocket.Conn, frame pty.Frame) { t.Helper() - if err := conn.WriteJSON(frame); err != nil { + raw, err := terminalcodec.Marshal(frame) + if err != nil { + t.Fatalf("marshal browser PTY %s: %v", frame.Type, err) + } + if err := conn.WriteMessage(websocket.TextMessage, raw); err != nil { t.Fatalf("browser write PTY %s: %v", frame.Type, err) } } func readBrowserPTY(t *testing.T, conn *websocket.Conn, want pty.FrameType) pty.Frame { t.Helper() - var frame pty.Frame - if err := conn.ReadJSON(&frame); err != nil { + _, raw, err := conn.ReadMessage() + if err != nil { t.Fatalf("browser read PTY %s: %v", want, err) } + frame, err := terminalcodec.Unmarshal(raw) + if err != nil { + t.Fatalf("decode browser PTY %s: %v", want, err) + } if frame.Type != want { t.Fatalf("browser expected PTY %s, got %s", want, frame.Type) } return frame } -func dialAgentWithIdentity(t *testing.T, srv *httptest.Server, name string, commands []string, nodeID string, status webproto.AgentStatus) *websocket.Conn { +func dialAgentWithIdentity(t *testing.T, srv *httptest.Server, name string, commands []string, nodeID string, status transport.AgentStatus) *websocket.Conn { t.Helper() wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agent/ws" conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) @@ -118,18 +136,14 @@ func dialAgentWithIdentity(t *testing.T, srv *httptest.Server, name string, comm if err != nil { t.Fatalf("dial: %v", err) } - reg, _ := json.Marshal(webproto.RegisterPayload{ - Name: name, - Commands: commands, - Node: protocols.NodeRef{ID: nodeID, Authority: srv.URL}, - Status: status, - Stats: webproto.AgentStats{TotalTokens: 42}, - }) - conn.WriteJSON(WSMessage{Type: "register", Payload: reg}) - var ack WSMessage - conn.ReadJSON(&ack) - if ack.Type != "connected" { - t.Fatalf("expected connected, got %s", ack.Type) + writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Hello{Hello: &transport.AgentHello{ + AgentId: nodeID, Name: name, Authority: srv.URL, Commands: commands, + Status: &transport.AgentStatus{Space: status.Space, Provider: status.Provider, Model: status.Model, Bound: status.Bound, ConfigError: status.ConfigError}, + Stats: &transport.AgentStats{TotalTokens: 42}, + }}}) + ack := readServerFrame(t, conn) + if ack.GetAccepted() == nil { + t.Fatalf("expected accepted, got %+v", ack) } return conn } @@ -221,58 +235,47 @@ func TestWSDispatchAndComplete(t *testing.T) { time.Sleep(50 * time.Millisecond) agentID := pool.List()[0].ID - progressCh, unsub := pool.hub.Subscribe("task-1") + progressCh, _, unsub := pool.hub.SubscribeScan("task-1") defer unsub() - resultCh, err := pool.DispatchToolCall(agentID, "task-1", aop.ToolCallData{ - ToolCallID: "task-1", - ToolName: "bash", - Args: map[string]any{"command": "scan -i 1.2.3.4"}, + arguments, _ := aop.JSONValue(map[string]any{"command": "scan -i 1.2.3.4"}) + resultCh, err := pool.DispatchToolCall(agentID, "task-1", &aop.ToolCall{ + Id: "task-1", Name: "bash", Arguments: arguments, }) if err != nil { t.Fatal(err) } - var cmd WSMessage - conn.ReadJSON(&cmd) - if cmd.Type != webproto.TypeAOP || cmd.TaskID != "task-1" { + cmd := readServerFrame(t, conn) + if cmd.GetToolCall().GetTaskId() != "task-1" { t.Fatalf("unexpected: %+v", cmd) } - var callEvent aop.Event - if err := json.Unmarshal(cmd.Payload, &callEvent); err != nil { - t.Fatal(err) - } - if callEvent.Type != aop.TypeToolCall || callEvent.TurnID != "task-1" { - t.Fatalf("unexpected tool.call event: %+v", callEvent) - } - call, err := aop.DecodeData[aop.ToolCallData](callEvent) - if err != nil { - t.Fatal(err) - } - args, _ := call.Args.(map[string]any) - if call.ToolName != "bash" || args["command"] != "scan -i 1.2.3.4" { + call := cmd.GetToolCall().GetCall() + args, _ := aop.DecodeJSON[map[string]any](call.Arguments) + if call.Name != "bash" || args["command"] != "scan -i 1.2.3.4" { t.Fatalf("unexpected tool.call data: %+v", call) } - progress, _ := json.Marshal(output.ToolDataEvent{Tool: "bash", Kind: output.ToolDataProgress, Data: "port 80 open", CallID: "task-1"}) - conn.WriteJSON(WSMessage{Type: "tool.data", TaskID: "task-1", Payload: progress}) + progress, _ := aop.JSONValue("port 80 open") + writeAgentFrame(t, conn, &transport.AgentFrame{CorrelationId: "task-1", Payload: &transport.AgentFrame_ToolTelemetry{ToolTelemetry: &transport.ToolTelemetry{ + Tool: "bash", Kind: output.ToolDataProgress, CallId: "task-1", Data: progress, + }}}) select { case evt := <-progressCh: - if !strings.Contains(string(evt.Data), "port 80 open") { - t.Fatalf("unexpected progress: %s", evt.Data) + if !strings.Contains(evt.GetProgress().GetData(), "port 80 open") { + t.Fatalf("unexpected progress: %v", evt) } case <-time.After(time.Second): t.Fatal("timeout") } - resultData, _ := json.Marshal(aop.ToolResultData{ - ToolCallID: "task-1", ToolName: "bash", Content: "done", Details: map[string]int{"ports": 3}, - }) - resultEvent := callEvent - resultEvent.Type = aop.TypeToolResult - resultEvent.TS = time.Now().UTC().Format(time.RFC3339Nano) - resultEvent.Data = resultData - conn.WriteJSON(WSMessage{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: webproto.MustJSON(resultEvent)}) + detail, _ := aop.JSONValue(map[string]int{"ports": 3}) + writeAgentFrame(t, conn, &transport.AgentFrame{CorrelationId: "task-1", Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + Id: "result-1", EmittedAt: timestamppb.Now(), SessionId: "task-1", TurnId: "task-1", Emitter: "worker", + Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{ + CallId: "task-1", Name: "bash", Output: []*aop.Content{aop.Text("done")}, Detail: detail, + }}, + }}}) select { case res := <-resultCh: if res.Err != "" || res.Output != "done" { @@ -286,10 +289,10 @@ func TestWSDispatchAndComplete(t *testing.T) { } } -func TestWSDispatchChatUsesChatMessage(t *testing.T) { +func TestWSDispatchChatUsesAOPMessage(t *testing.T) { srv, pool := setupTestServer(t) conn := dialAgentWithIdentity(t, srv, "chat-worker", []string{"scan"}, "node-chat-worker", - webproto.AgentStatus{Space: "case-test", Provider: "openai", Model: "test-model"}) + transport.AgentStatus{Space: "case-test", Provider: "openai", Model: "test-model"}) defer conn.Close() time.Sleep(50 * time.Millisecond) @@ -303,20 +306,16 @@ func TestWSDispatchChatUsesChatMessage(t *testing.T) { t.Fatal(err) } - var cmd WSMessage - conn.ReadJSON(&cmd) - if cmd.Type != webproto.TypeRun || cmd.TurnID != "task-chat" { + cmd := readServerFrame(t, conn) + if cmd.GetRunTurn().GetTurnId() != "task-chat" { t.Fatalf("unexpected: %+v", cmd) } - var run webproto.RunPayload - if err := json.Unmarshal(cmd.Payload, &run); err != nil { - t.Fatal(err) - } - if len(run.Parts) != 1 || run.Parts[0].Text != "hello" { + run := cmd.GetRunTurn() + if len(run.Input.Content) != 1 || run.Input.Content[0].GetText().GetText() != "hello" { t.Fatalf("unexpected run input: %+v", run) } - conn.WriteJSON(turnEndMessage("task-chat", "sess-chat", "completed")) + writeAgentFrame(t, conn, turnEndMessage("task-chat", "sess-chat", "completed")) select { case res := <-resultCh: if res.Err != "" { @@ -329,28 +328,22 @@ func TestWSDispatchChatUsesChatMessage(t *testing.T) { // turnEndMessage builds the agent→hub AOP turn.end frame that converges // a chat task. -func turnEndMessage(turnID, sessionID, stop string) WSMessage { - data, _ := json.Marshal(aop.TurnEndData{Stop: stop}) - payload, _ := json.Marshal(aop.Event{ - Type: aop.TypeTurnEnd, - TS: time.Now().UTC().Format(time.RFC3339Nano), - SessionID: sessionID, - TurnID: turnID, - Agent: "agent", - Data: data, - }) - return WSMessage{Type: "aop", TurnID: turnID, Payload: payload} +func turnEndMessage(turnID, sessionID, stop string) *transport.AgentFrame { + return &transport.AgentFrame{CorrelationId: turnID, Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + Id: "end-" + turnID, EmittedAt: timestamppb.Now(), SessionId: sessionID, TurnId: turnID, Emitter: "agent", + Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: stop}}, + }}} } // TestDispatchRunCarriesGoalOptions guards the Goal-mode wiring: the // eval criteria and round budget must survive into the AOP user message ext so // the agent can run the evaluator loop. This whole channel was silently dropped -// once (SendMessageRequest{Content} only), leaving the Goal panel a dead +// once (when an adapter forwarded only plain text), leaving the Goal panel a dead // control — this test fails loudly if that regresses. func TestDispatchRunCarriesGoalOptions(t *testing.T) { srv, pool := setupTestServer(t) conn := dialAgentWithIdentity(t, srv, "goal-worker", []string{"scan"}, "node-goal-worker", - webproto.AgentStatus{Provider: "openai", Model: "test-model"}) + transport.AgentStatus{Provider: "openai", Model: "test-model"}) defer conn.Close() time.Sleep(50 * time.Millisecond) @@ -359,39 +352,33 @@ func TestDispatchRunCarriesGoalOptions(t *testing.T) { t.Fatal("expected chat-capable agent") } - resultCh, err := pool.DispatchRun(agent.id, "task-goal", webproto.RunPayload{ - SessionID: "sess-1", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "audit target"}}, - NoEcho: true, EvalCriteria: "find at least one SQLi", EvalMaxRounds: 5, + options, _ := aop.ProtoJSONValue(&transport.RunOptions{EvalCriteria: "find at least one SQLi", EvalMaxRounds: 5}) + resultCh, err := pool.DispatchRun(agent.id, &aop.RunTurnRequest{ + RequestId: "task-goal", SessionId: "sess-1", TurnId: "task-goal", + Input: &aop.Message{Id: "input-task-goal", Role: "user", Content: []*aop.Content{aop.Text("audit target")}}, + Extensions: []*aop.Extension{{Namespace: "io.chainreactors.aiscan.run", Value: options}}, }) if err != nil { t.Fatal(err) } - var opened WSMessage - if err := conn.ReadJSON(&opened); err != nil { - t.Fatal(err) - } - if opened.Type != webproto.TypeSessionOpen { + opened := readServerFrame(t, conn) + if opened.GetOpenSession() == nil { t.Fatalf("first frame = %+v, want session.open", opened) } - var cmd WSMessage - if err := conn.ReadJSON(&cmd); err != nil { - t.Fatal(err) - } - var inbound webproto.RunPayload - if cmd.Type != webproto.TypeRun || json.Unmarshal(cmd.Payload, &inbound) != nil { + cmd := readServerFrame(t, conn) + inbound := cmd.GetRunTurn() + if inbound == nil { t.Fatalf("dispatch did not carry a Run: %+v", cmd) } - if inbound.SessionID != "sess-1" || len(inbound.Parts) != 1 || inbound.Parts[0].Text != "audit target" { + if inbound.SessionId != "sess-1" || len(inbound.Input.Content) != 1 || inbound.Input.Content[0].GetText().GetText() != "audit target" { t.Errorf("run = %+v", inbound) } - if inbound.EvalCriteria != "find at least one SQLi" || inbound.EvalMaxRounds != 5 { - t.Errorf("goal options = %+v", inbound) - } - if !inbound.NoEcho { - t.Error("hub-sent user message must set no_echo") + var gotOptions transport.RunOptions + if err := aop.DecodeProtoJSON(inbound.Extensions[0].Value, &gotOptions); err != nil || gotOptions.EvalCriteria != "find at least one SQLi" || gotOptions.EvalMaxRounds != 5 { + t.Errorf("goal options = %+v, err=%v", gotOptions, err) } - conn.WriteJSON(turnEndMessage("task-goal", "sess-1", "completed")) + writeAgentFrame(t, conn, turnEndMessage("task-goal", "sess-1", "completed")) select { case <-resultCh: case <-time.After(time.Second): @@ -414,7 +401,7 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { defer srv.Close() conn := dialAgentWithIdentity(t, srv, "upload-agent", []string{"scan"}, "node-upload-agent", - webproto.AgentStatus{Provider: "openai", Model: "test-model"}) + transport.AgentStatus{Provider: "openai", Model: "test-model"}) defer conn.Close() time.Sleep(50 * time.Millisecond) @@ -432,33 +419,15 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - var msg WSMessage - if err := conn.ReadJSON(&msg); err != nil { - t.Errorf("read upload message: %v", err) - return - } - if msg.Type != "upload" || msg.TaskID == "" || msg.DataB64 == "" { + msg := readServerFrame(t, conn) + upload := msg.GetFileUpload() + if upload == nil || upload.TaskId == "" || len(upload.Data) == 0 { t.Errorf("unexpected upload message: %+v", msg) return } - var payload webproto.FileUploadPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - t.Errorf("decode upload payload: %v", err) - return - } - result := webproto.FileUploadResult{ - Filename: payload.Filename, - Path: `C:\tmp\note.txt`, - Size: payload.FileSize, - } - if err := conn.WriteJSON(WSMessage{ - Type: "complete", - TaskID: msg.TaskID, - Data: result.Path, - Payload: mustJSON(result), - }); err != nil { - t.Errorf("write upload completion: %v", err) - } + writeAgentFrame(t, conn, &transport.AgentFrame{CorrelationId: msg.CorrelationId, Payload: &transport.AgentFrame_FileResult{FileResult: &transport.FileResult{ + TaskId: upload.TaskId, Filename: upload.Filename, Path: `C:\tmp\note.txt`, Size: int64(len(upload.Data)), + }}}) }() result, err := svc.HandleFileUpload(ctx, session.ID, "note.txt", []byte("hello")) @@ -475,15 +444,16 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { t.Fatal("timeout waiting for agent upload reply") } - msgs, err := store.ListMessages(ctx, session.ID, 10) + events, err := store.ListAOPEvents(ctx, session.ID, 10) if err != nil { t.Fatal(err) } - if len(msgs) != 1 { - t.Fatalf("expected 1 persisted message, got %d", len(msgs)) + if len(events) != 1 { + t.Fatalf("expected 1 persisted AOP event, got %d", len(events)) } - if msgs[0].Role != "system" || !strings.Contains(msgs[0].Content, "File uploaded: note.txt") || !strings.Contains(msgs[0].Content, result.Path) { - t.Fatalf("unexpected persisted upload message: %+v", msgs[0]) + message := events[0].GetMessage() + if message.GetRole() != "system" || !strings.Contains(message.GetContent()[0].GetText().GetText(), "File uploaded: note.txt") || !strings.Contains(message.GetContent()[0].GetText().GetText(), result.Path) { + t.Fatalf("unexpected persisted upload event: %+v", events[0]) } // The English Content is only a fallback; the localizable contract lives in // Metadata as {code, params} so the message stays translatable after reload. @@ -491,7 +461,11 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { Code string `json:"code"` Params map[string]string `json:"params"` } - if err := json.Unmarshal(msgs[0].Metadata, &meta); err != nil { + webExtension, ok, err := ext.GetWebMessage(events[0]) + if err != nil || !ok { + t.Fatalf("web extension = %+v, ok = %v, err = %v", webExtension, ok, err) + } + if err := json.Unmarshal(webExtension.Metadata, &meta); err != nil { t.Fatalf("decode system message metadata: %v", err) } if meta.Code != SysFileUploaded || meta.Params["filename"] != "note.txt" || meta.Params["path"] != result.Path { @@ -517,21 +491,23 @@ func TestWSPick(t *testing.T) { } } -func TestWSLegacyTelemetryIsNotProjected(t *testing.T) { +func TestWSUnrecognizedExtensionIsNotProjected(t *testing.T) { srv, pool := setupTestServer(t) conn := dialAgent(t, srv, "tele-agent", []string{"scan"}) defer conn.Close() time.Sleep(50 * time.Millisecond) - progressCh, unsub := pool.hub.Subscribe("task-2") + progressCh, _, unsub := pool.hub.SubscribeScan("task-2") defer unsub() - conn.WriteJSON(WSMessage{Type: "agent.turn_start", TaskID: "task-2", Data: "turn 1"}) + writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_OperationError{OperationError: &transport.OperationError{ + TaskId: "unknown-task", Code: "IGNORED", Message: "not progress telemetry", + }}}) select { case evt := <-progressCh: - t.Fatalf("legacy telemetry was projected into progress: %+v", evt) + t.Fatalf("non-telemetry frame was projected into progress: %+v", evt) case <-time.After(100 * time.Millisecond): } } @@ -843,7 +819,7 @@ func setupE2EServer(t *testing.T) (*httptest.Server, *AgentPool) { //nolint:unus type mockBrowserAgent struct { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag conn *websocket.Conn - messages chan WSMessage + messages chan *transport.ServerFrame errors chan error } @@ -857,24 +833,26 @@ func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *mockBrowser if err != nil { t.Fatalf("dial agent: %v", err) } - reg, _ := json.Marshal(webproto.RegisterPayload{ - Name: name, Commands: []string{"tmux"}, - Node: protocols.NodeRef{ID: "node-" + name, Authority: srv.URL}, - }) - conn.WriteJSON(WSMessage{Type: "register", Payload: reg}) - var ack WSMessage - conn.ReadJSON(&ack) - if ack.Type != "connected" { - t.Fatalf("expected connected, got %s", ack.Type) + writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Hello{Hello: &transport.AgentHello{ + AgentId: "node-" + name, Name: name, Authority: srv.URL, Commands: []string{"tmux"}, + }}}) + ack := readServerFrame(t, conn) + if ack.GetAccepted() == nil { + t.Fatalf("expected accepted, got %+v", ack) } agent := &mockBrowserAgent{ - conn: conn, messages: make(chan WSMessage, 64), errors: make(chan error, 1), + conn: conn, messages: make(chan *transport.ServerFrame, 64), errors: make(chan error, 1), } go func() { defer close(agent.messages) for { - var msg WSMessage - if err := conn.ReadJSON(&msg); err != nil { + _, raw, err := conn.ReadMessage() + if err != nil { + agent.errors <- err + return + } + msg := new(transport.ServerFrame) + if err := protojson.Unmarshal(raw, msg); err != nil { agent.errors <- err return } @@ -905,8 +883,8 @@ func launchBrowser(t *testing.T) *rod.Browser { //nolint:unused // referenced by return browser } -func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []WSMessage { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag - var msgs []WSMessage +func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []*transport.ServerFrame { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag + var msgs []*transport.ServerFrame timer := time.NewTimer(timeout) defer timer.Stop() for { @@ -932,8 +910,8 @@ func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) if !ok { t.Fatalf("agent connection closed while waiting for %s", want) } - frame, err := webproto.DecodePTYMessage(msg) - if err == nil && frame.Type == want { + frame := terminalcodec.FromProto(msg.GetTerminal()) + if frame.Type == want { return frame } case err := <-agent.errors: @@ -946,9 +924,7 @@ func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) func writeMockAgentPTY(t *testing.T, agent *mockBrowserAgent, frame pty.Frame) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() - if err := agent.conn.WriteJSON(webproto.NewPTYMessage(frame)); err != nil { - t.Fatalf("agent write PTY %s: %v", frame.Type, err) - } + writeAgentFrame(t, agent.conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}) } func openFirstAgentTerminal(t *testing.T, page *rod.Page) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag @@ -1012,8 +988,8 @@ func runE2ETerminalOpenAndType(t *testing.T) { //nolint:unused // referenced by inputs := drainAgentMessages(agentConn, time.Second) gotInput := false for _, m := range inputs { - frame, err := webproto.DecodePTYMessage(m) - if err == nil && frame.Type == pty.FrameInput && frame.StreamID == replStreamID { + frame := terminalcodec.FromProto(m.GetTerminal()) + if frame.Type == pty.FrameInput && frame.StreamID == replStreamID { gotInput = true break } @@ -1073,8 +1049,8 @@ func runE2ETerminalResize(t *testing.T) { //nolint:unused // referenced by agent msgs := drainAgentMessages(agentConn, time.Second) resizeReceived := false for _, m := range msgs { - frame, err := webproto.DecodePTYMessage(m) - if err == nil && frame.Type == pty.FrameResize { + frame := terminalcodec.FromProto(m.GetTerminal()) + if frame.Type == pty.FrameResize { resizeReceived = true t.Logf("resize received: %+v", frame) break @@ -1090,8 +1066,8 @@ func TestCancelTaskConvergesPendingTaskImmediately(t *testing.T) { resultCh := make(chan taskResult, 1) remote := &remoteAgent{ id: "agent-1", - sendCh: make(chan WSMessage, 1), - controlCh: make(chan WSMessage, 1), + sendCh: make(chan *transport.ServerFrame, 1), + controlCh: make(chan *transport.ServerFrame, 1), tasks: map[string]chan taskResult{"task-1": resultCh}, turns: map[string]int{"task-1": 1}, toolCalls: make(map[string]struct{}), @@ -1099,11 +1075,11 @@ func TestCancelTaskConvergesPendingTaskImmediately(t *testing.T) { } pool.agents[remote.id] = remote - pool.CancelTask(remote.id, "task-1") + pool.CancelTask(remote.id, "task-1", "session-1") select { case frame := <-remote.controlCh: - if frame.Type != webproto.TypeRunCancel || frame.TurnID != "task-1" { + if frame.GetCancelTurn().GetSessionId() != "session-1" || frame.GetCancelTurn().GetTurnId() != "task-1" { t.Fatalf("cancel frame = %+v", frame) } default: diff --git a/pkg/web/aop_grpc.go b/pkg/web/aop_grpc.go new file mode 100644 index 00000000..8cea8971 --- /dev/null +++ b/pkg/web/aop_grpc.go @@ -0,0 +1,503 @@ +package web + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + aop "github.com/chainreactors/aiscan/aop" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +type aopChatServer struct { + aop.UnimplementedChatServiceServer + service *Service + mu sync.Mutex +} + +const agentControlTimeout = 10 * time.Second + +func NewAOPChatServer(service *Service) aop.ChatServiceServer { + return &aopChatServer{service: service} +} + +func (s *aopChatServer) OpenSession(ctx context.Context, req *aop.OpenSessionRequest) (*aop.OpenSessionResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") + } + if req == nil || strings.TrimSpace(req.RequestId) == "" { + return rejectedOpen(req, codes.InvalidArgument, "request_id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.OpenSessionResponse) + hash, found, conflict, err := s.beginRequest(ctx, "OpenSession", req.RequestId, req, replayed) + if err != nil { + return nil, status.Errorf(codes.Internal, "load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedOpen(req, codes.AlreadyExists, "request_id conflicts with another request"), nil + } + finish := func(response *aop.OpenSessionResponse) (*aop.OpenSessionResponse, error) { + if err := s.finishRequest(ctx, "OpenSession", req.RequestId, hash, response); err != nil { + return nil, status.Errorf(codes.Internal, "save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.Participant) == "" { + return finish(rejectedOpen(req, codes.InvalidArgument, "participant is required")) + } + if s.service.agents == nil || s.service.agents.get(req.Participant) == nil { + return finish(rejectedOpen(req, codes.Unavailable, "participant is not connected")) + } + + id := strings.TrimSpace(req.SessionId) + if id == "" { + id = generateID() + } + createdNew := false + var created *ChatSession + if existing, err := s.service.store.GetSession(ctx, id); err == nil { + if existing.AgentID != req.Participant { + return finish(rejectedOpen(req, codes.AlreadyExists, "session is bound to another participant")) + } + created = existing + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, status.Errorf(codes.Internal, "get session: %v", err) + } else { + now := time.Now() + created = &ChatSession{ + ID: id, AgentID: req.Participant, Title: req.Title, Status: SessionActive, + CreatedAt: now, UpdatedAt: now, + } + if agent := s.service.agents.get(req.Participant); agent != nil { + created.AgentName = agent.name + } + if err := s.service.store.CreateSession(ctx, created); err != nil { + return nil, status.Errorf(codes.Internal, "create session: %v", err) + } + createdNew = true + } + if !s.service.agents.SessionOpen(req.Participant, id) { + forward := proto.Clone(req).(*aop.OpenSessionRequest) + forward.SessionId = id + resultCh, err := s.service.agents.DispatchOpenSession(req.Participant, forward) + if err != nil { + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return finish(rejectedOpen(req, codes.Unavailable, err.Error())) + } + timer := time.NewTimer(agentControlTimeout) + defer timer.Stop() + select { + case result, ok := <-resultCh: + if !ok || result.Err != "" { + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + message := result.Err + if message == "" { + message = "participant disconnected while opening session" + } + return finish(rejectedOpen(req, codes.FailedPrecondition, message)) + } + case <-ctx.Done(): + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return nil, status.FromContextError(ctx.Err()).Err() + case <-timer.C: + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return finish(rejectedOpen(req, codes.Unavailable, "participant timed out while opening session")) + } + } + return finish(&aop.OpenSessionResponse{RequestId: req.RequestId, Outcome: &aop.OpenSessionResponse_Accepted{Accepted: sessionToAOP(created)}}) +} + +func (s *aopChatServer) RunTurn(ctx context.Context, req *aop.RunTurnRequest) (*aop.RunTurnResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") + } + if req == nil || strings.TrimSpace(req.RequestId) == "" { + return rejectedRun(req, codes.InvalidArgument, "request_id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.RunTurnResponse) + hash, found, conflict, err := s.beginRequest(ctx, "RunTurn", req.RequestId, req, replayed) + if err != nil { + return nil, status.Errorf(codes.Internal, "load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedRun(req, codes.AlreadyExists, "request_id conflicts with another request"), nil + } + finish := func(response *aop.RunTurnResponse) (*aop.RunTurnResponse, error) { + if err := s.finishRequest(ctx, "RunTurn", req.RequestId, hash, response); err != nil { + return nil, status.Errorf(codes.Internal, "save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.SessionId) == "" || (!req.ContinueSession && (req.Input == nil || len(req.Input.Content) == 0)) { + return finish(rejectedRun(req, codes.InvalidArgument, "session_id and input.content are required unless continue_session is true")) + } + session, err := s.service.store.GetSession(ctx, req.SessionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedRun(req, codes.NotFound, "session not found")) + } + return nil, status.Errorf(codes.Internal, "get session: %v", err) + } + if s.service.sessionAgent(req.SessionId) == nil { + return finish(rejectedRun(req, codes.Unavailable, "participant is not connected")) + } + turnID := strings.TrimSpace(req.TurnId) + if turnID == "" { + turnID = generateID() + } + session.UpdatedAt = time.Now() + if session.Title == "" { + session.Title = contentText(req.Input.Content, 60) + } + _ = s.service.store.UpdateSession(ctx, session) + + forward := *req + if forward.Input == nil { + forward.Input = &aop.Message{Role: "user"} + } + forward.TurnId = turnID + response := &aop.RunTurnResponse{RequestId: req.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ + SessionId: req.SessionId, TurnId: turnID, State: "running", + }}} + if _, err := finish(response); err != nil { + return nil, err + } + s.service.handleAgentRun(req.SessionId, &forward) + return response, nil +} + +func (s *aopChatServer) CancelTurn(ctx context.Context, req *aop.CancelTurnRequest) (*aop.CancelTurnResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") + } + if req == nil || strings.TrimSpace(req.RequestId) == "" { + return rejectedCancel(req, codes.InvalidArgument, "request_id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.CancelTurnResponse) + hash, found, conflict, err := s.beginRequest(ctx, "CancelTurn", req.RequestId, req, replayed) + if err != nil { + return nil, status.Errorf(codes.Internal, "load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedCancel(req, codes.AlreadyExists, "request_id conflicts with another request"), nil + } + finish := func(response *aop.CancelTurnResponse) (*aop.CancelTurnResponse, error) { + if err := s.finishRequest(ctx, "CancelTurn", req.RequestId, hash, response); err != nil { + return nil, status.Errorf(codes.Internal, "save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.SessionId) == "" || strings.TrimSpace(req.TurnId) == "" { + return finish(rejectedCancel(req, codes.InvalidArgument, "session_id and turn_id are required")) + } + if err := s.service.CancelTurn(ctx, req.SessionId, req.TurnId); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedCancel(req, codes.NotFound, "session not found")) + } + if errors.Is(err, ErrTurnNotFound) { + return finish(rejectedCancel(req, codes.NotFound, "turn not found")) + } + return nil, status.Errorf(codes.Internal, "cancel turn: %v", err) + } + return finish(&aop.CancelTurnResponse{RequestId: req.RequestId, Outcome: &aop.CancelTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ + SessionId: req.SessionId, TurnId: req.TurnId, State: "canceled", + }}}) +} + +func (s *aopChatServer) CloseSession(ctx context.Context, req *aop.CloseSessionRequest) (*aop.CloseSessionResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") + } + if req == nil || strings.TrimSpace(req.RequestId) == "" { + return rejectedClose(req, codes.InvalidArgument, "request_id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.CloseSessionResponse) + hash, found, conflict, err := s.beginRequest(ctx, "CloseSession", req.RequestId, req, replayed) + if err != nil { + return nil, status.Errorf(codes.Internal, "load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedClose(req, codes.AlreadyExists, "request_id conflicts with another request"), nil + } + finish := func(response *aop.CloseSessionResponse) (*aop.CloseSessionResponse, error) { + if err := s.finishRequest(ctx, "CloseSession", req.RequestId, hash, response); err != nil { + return nil, status.Errorf(codes.Internal, "save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.SessionId) == "" { + return finish(rejectedClose(req, codes.InvalidArgument, "session_id is required")) + } + session, err := s.service.store.GetSession(ctx, req.SessionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedClose(req, codes.NotFound, "session not found")) + } + return nil, status.Errorf(codes.Internal, "get session: %v", err) + } + agentConnected := s.service.agents != nil && s.service.agents.get(session.AgentID) != nil + if agentConnected { + resultCh, dispatchErr := s.service.agents.DispatchCloseSession(session.AgentID, proto.Clone(req).(*aop.CloseSessionRequest)) + if dispatchErr != nil { + return finish(rejectedClose(req, codes.Unavailable, dispatchErr.Error())) + } + timer := time.NewTimer(agentControlTimeout) + defer timer.Stop() + select { + case result, ok := <-resultCh: + if !ok || result.Err != "" { + message := result.Err + if message == "" { + message = "participant disconnected while closing session" + } + return finish(rejectedClose(req, codes.FailedPrecondition, message)) + } + case <-ctx.Done(): + return nil, status.FromContextError(ctx.Err()).Err() + case <-timer.C: + return finish(rejectedClose(req, codes.Unavailable, "participant timed out while closing session")) + } + } + session.Status = SessionArchived + session.UpdatedAt = time.Now() + if err := s.service.store.UpdateSession(ctx, session); err != nil { + return nil, status.Errorf(codes.Internal, "close session: %v", err) + } + if !agentConnected { + s.service.BroadcastAOPEvent(req.SessionId, &aop.Event{ + SessionId: req.SessionId, Emitter: "aiscan.web", + Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: req.Reason}}, + }) + } + return finish(&aop.CloseSessionResponse{RequestId: req.RequestId, Outcome: &aop.CloseSessionResponse_Accepted{Accepted: sessionToAOP(session)}}) +} + +func (s *aopChatServer) ListEvents(ctx context.Context, req *aop.ListEventsRequest) (*aop.ListEventsResponse, error) { + if s.service == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { + return nil, status.Error(codes.InvalidArgument, "session_id is required") + } + after, err := parseAOPCursor(req.AfterCursor) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + stored, err := s.service.store.ListAOPEventsAfter(ctx, req.SessionId, after, int(req.Limit)) + if err != nil { + return nil, status.Errorf(codes.Internal, "list events: %v", err) + } + response := &aop.ListEventsResponse{Events: make([]*aop.EventDelivery, 0, len(stored))} + for _, item := range stored { + response.Events = append(response.Events, delivery(item.Cursor, item.Event)) + response.NextCursor = strconv.FormatInt(item.Cursor, 10) + } + return response, nil +} + +func (s *aopChatServer) WatchEvents(req *aop.WatchEventsRequest, stream aop.ChatService_WatchEventsServer) error { + return s.watchEvents(req, stream.Context(), func(response *aop.WatchEventsResponse) error { + return stream.Send(response) + }) +} + +func (s *aopChatServer) watchEvents(req *aop.WatchEventsRequest, ctx context.Context, send func(*aop.WatchEventsResponse) error) error { + if s.service == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { + return status.Error(codes.InvalidArgument, "session_id is required") + } + if send == nil { + return status.Error(codes.Internal, "event sender is unavailable") + } + after, err := parseAOPCursor(req.AfterCursor) + if err != nil { + return status.Error(codes.InvalidArgument, err.Error()) + } + live, unsubscribe := s.service.hub.SubscribeAOP(req.SessionId) + defer unsubscribe() + replayed, err := s.service.store.ListAOPEventsAfter(ctx, req.SessionId, after, 0) + if err != nil { + return status.Errorf(codes.Internal, "replay events: %v", err) + } + for _, item := range replayed { + if err := send(&aop.WatchEventsResponse{Delivery: delivery(item.Cursor, item.Event)}); err != nil { + return err + } + if item.Cursor > after { + after = item.Cursor + } + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case item, ok := <-live: + if !ok { + return nil + } + if item.Event == nil || (item.Cursor > 0 && item.Cursor <= after) { + continue + } + if err := send(&aop.WatchEventsResponse{Delivery: delivery(item.Cursor, item.Event)}); err != nil { + return err + } + if item.Cursor > after { + after = item.Cursor + } + } + } +} + +func (s *aopChatServer) beginRequest(ctx context.Context, method, requestID string, request, response proto.Message) (hash []byte, found, conflict bool, err error) { + raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) + if err != nil { + return nil, false, false, err + } + digest := sha256.Sum256(raw) + found, conflict, err = s.service.store.LoadAOPRequest(ctx, requestID, method, digest[:], response) + return digest[:], found, conflict, err +} + +func (s *aopChatServer) finishRequest(ctx context.Context, method, requestID string, hash []byte, response proto.Message) error { + return s.service.store.SaveAOPRequest(ctx, requestID, method, hash, response) +} + +func sessionToAOP(session *ChatSession) *aop.Session { + if session == nil { + return nil + } + state := "open" + if session.Status != SessionActive { + state = "closed" + } + return &aop.Session{Id: session.ID, State: state, Participant: session.AgentID, Title: session.Title} +} + +func delivery(cursor int64, event *aop.Event) *aop.EventDelivery { + value := "" + if cursor > 0 { + value = strconv.FormatInt(cursor, 10) + } + return &aop.EventDelivery{Cursor: value, Event: event} +} + +func parseAOPCursor(value string) (int64, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + cursor, err := strconv.ParseInt(value, 10, 64) + if err != nil || cursor < 0 { + return 0, fmt.Errorf("invalid cursor %q", value) + } + return cursor, nil +} + +func contentText(content []*aop.Content, limit int) string { + var text strings.Builder + for _, part := range content { + value := part.GetText().GetText() + if value == "" { + continue + } + if text.Len() > 0 { + text.WriteByte(' ') + } + text.WriteString(value) + } + value := strings.TrimSpace(text.String()) + if limit > 0 && len(value) > limit { + return value[:limit] + "..." + } + return value +} + +func rejection(code codes.Code, message string) *aop.Rejection { + return &aop.Rejection{Code: canonicalCode(code), Message: message} +} + +func canonicalCode(code codes.Code) string { + switch code { + case codes.InvalidArgument: + return "INVALID_ARGUMENT" + case codes.NotFound: + return "NOT_FOUND" + case codes.AlreadyExists: + return "ALREADY_EXISTS" + case codes.FailedPrecondition: + return "FAILED_PRECONDITION" + case codes.Unavailable: + return "UNAVAILABLE" + case codes.ResourceExhausted: + return "RESOURCE_EXHAUSTED" + case codes.Unauthenticated: + return "UNAUTHENTICATED" + case codes.Internal: + return "INTERNAL" + default: + return strings.ToUpper(strings.ReplaceAll(code.String(), " ", "_")) + } +} + +func rejectedOpen(req *aop.OpenSessionRequest, code codes.Code, message string) *aop.OpenSessionResponse { + response := &aop.OpenSessionResponse{Outcome: &aop.OpenSessionResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} + +func rejectedRun(req *aop.RunTurnRequest, code codes.Code, message string) *aop.RunTurnResponse { + response := &aop.RunTurnResponse{Outcome: &aop.RunTurnResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} + +func rejectedCancel(req *aop.CancelTurnRequest, code codes.Code, message string) *aop.CancelTurnResponse { + response := &aop.CancelTurnResponse{Outcome: &aop.CancelTurnResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} + +func rejectedClose(req *aop.CloseSessionRequest, code codes.Code, message string) *aop.CloseSessionResponse { + response := &aop.CloseSessionResponse{Outcome: &aop.CloseSessionResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} diff --git a/pkg/web/aop_transport_test.go b/pkg/web/aop_transport_test.go new file mode 100644 index 00000000..c326dbe9 --- /dev/null +++ b/pkg/web/aop_transport_test.go @@ -0,0 +1,305 @@ +package web + +import ( + "context" + "net" + "path/filepath" + "testing" + "time" + + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "google.golang.org/grpc" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestAgentFrameBinaryAndJSONAreEquivalent(t *testing.T) { + original := &transport.AgentFrame{ + FrameId: "frame-1", CorrelationId: "turn-1", + Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + Id: "event-1", SessionId: "session-1", TurnId: "turn-1", Emitter: "agent-1", Seq: 7, + Payload: &aop.Event_Message{Message: &aop.Message{Id: "message-1", Role: "assistant", Content: []*aop.Content{ + {Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}, + {Value: &aop.Content_Media{Media: &aop.MediaContent{Kind: "image", Resource: &aop.Resource{Source: &aop.Resource_Data{Data: []byte{0, 1, 2, 255}}, MediaType: "image/png"}}}}, + }}}, + }}, + } + binary, err := proto.Marshal(original) + if err != nil { + t.Fatal(err) + } + fromBinary := new(transport.AgentFrame) + if err := proto.Unmarshal(binary, fromBinary); err != nil { + t.Fatal(err) + } + jsonValue, err := protojson.Marshal(original) + if err != nil { + t.Fatal(err) + } + fromJSON := new(transport.AgentFrame) + if err := protojson.Unmarshal(jsonValue, fromJSON); err != nil { + t.Fatal(err) + } + if !proto.Equal(fromBinary, fromJSON) { + t.Fatalf("binary and JSON frames differ:\nbinary=%v\njson=%v", fromBinary, fromJSON) + } +} + +func TestAOPChatServiceGRPCPersistsAgentGeneratedEvents(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "chat.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(ServiceConfig{Store: store}) + defer service.Close() + pool := NewAgentPool(service.Hub()) + service.SetAgentPool(pool) + fake := &remoteAgent{ + id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 8), + controlCh: make(chan *transport.ServerFrame, 8), tasks: make(map[string]chan taskResult), + turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, + childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), + } + pool.agents[fake.id] = fake + + listener := bufconn.Listen(1 << 20) + server := NewGRPCServer("", service, pool) + go func() { _ = server.Serve(listener) }() + defer server.Stop() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), grpc.WithInsecure()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + client := aop.NewChatServiceClient(conn) + + opened, err := client.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1", Participant: "agent-1", Title: "integration"}) + if err != nil { + t.Fatal(err) + } + if opened.GetAccepted().GetId() != "session-1" { + t.Fatalf("unexpected open response: %v", opened) + } + + runInput := &aop.Message{Id: "message-1", Role: "user", Content: []*aop.Content{ + {Value: &aop.Content_Text{Text: &aop.TextContent{Text: "inspect this"}}}, + {Value: &aop.Content_Media{Media: &aop.MediaContent{Kind: "image", Resource: &aop.Resource{Source: &aop.Resource_Data{Data: []byte{1, 2, 3}}, MediaType: "image/png"}}}}, + }} + run, err := client.RunTurn(ctx, &aop.RunTurnRequest{ + RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", + Input: runInput, + }) + if err != nil { + t.Fatal(err) + } + if run.GetAccepted().GetTurnId() != "turn-1" { + t.Fatalf("unexpected run response: %v", run) + } + + before, err := client.ListEvents(ctx, &aop.ListEventsRequest{SessionId: "session-1", Limit: 100}) + if err != nil { + t.Fatal(err) + } + if len(before.Events) != 0 { + t.Fatalf("RunTurn synthesized AOP events before the agent emitted them: %v", before.Events) + } + + pool.handleAgentFrame(fake, &transport.AgentFrame{ + CorrelationId: "turn-1", + Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + Id: "event-1", EmittedAt: timestamppb.Now(), SessionId: "session-1", + TurnId: "turn-1", Emitter: "agent-1", Seq: 1, + Payload: &aop.Event_Message{Message: proto.Clone(runInput).(*aop.Message)}, + }}, + }) + + listed, err := client.ListEvents(ctx, &aop.ListEventsRequest{SessionId: "session-1", Limit: 100}) + if err != nil { + t.Fatal(err) + } + var found *aop.Event + for _, item := range listed.Events { + if item.Event.GetMessage().GetId() == "message-1" { + found = item.Event + break + } + } + if found == nil { + t.Fatalf("input message event was not persisted: %v", listed.Events) + } + if len(found.GetMessage().Content) != 2 || !proto.Equal(found.GetMessage(), &aop.Message{Id: "message-1", Role: "user", Content: []*aop.Content{ + {Value: &aop.Content_Text{Text: &aop.TextContent{Text: "inspect this"}}}, + {Value: &aop.Content_Media{Media: &aop.MediaContent{Kind: "image", Resource: &aop.Resource{Source: &aop.Resource_Data{Data: []byte{1, 2, 3}}, MediaType: "image/png"}}}}, + }}) { + t.Fatalf("stored message lost content: %v", found.GetMessage()) + } +} + +func TestAOPRequestIDReplayDoesNotDispatchTwice(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "chat.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(ServiceConfig{Store: store}) + pool := NewAgentPool(service.Hub()) + service.SetAgentPool(pool) + fake := &remoteAgent{ + id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 8), controlCh: make(chan *transport.ServerFrame, 8), + tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, + childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), + } + pool.agents[fake.id] = fake + server := NewAOPChatServer(service) + ctx := context.Background() + if opened, err := server.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1", Participant: "agent-1"}); err != nil || opened.GetAccepted() == nil { + t.Fatalf("open = %v, %v", opened, err) + } + request := &aop.RunTurnRequest{ + RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", + Input: &aop.Message{Id: "input-1", Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}}, + } + first, err := server.RunTurn(ctx, request) + if err != nil || first.GetAccepted() == nil { + t.Fatalf("first run = %v, %v", first, err) + } + second, err := server.RunTurn(ctx, proto.Clone(request).(*aop.RunTurnRequest)) + if err != nil || !proto.Equal(first, second) { + t.Fatalf("replay = %v, %v; want %v", second, err, first) + } + if got := len(fake.sendCh); got != 1 { + t.Fatalf("agent frames = %d, want one run", got) + } + conflicting := proto.Clone(request).(*aop.RunTurnRequest) + conflicting.Input.Content[0] = &aop.Content{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "different"}}} + response, err := server.RunTurn(ctx, conflicting) + if err != nil || response.GetRejected().GetCode() != "ALREADY_EXISTS" { + t.Fatalf("conflict = %v, %v", response, err) + } +} + +func TestCancelTurnTargetsOnlyRequestedTurn(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "chat.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(ServiceConfig{Store: store}) + defer service.Close() + pool := NewAgentPool(service.Hub()) + service.SetAgentPool(pool) + fake := &remoteAgent{ + id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 8), controlCh: make(chan *transport.ServerFrame, 8), + tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, + childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), + } + pool.agents[fake.id] = fake + server := NewAOPChatServer(service) + ctx := context.Background() + if opened, err := server.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1", Participant: fake.id}); err != nil || opened.GetAccepted() == nil { + t.Fatalf("open = %v, %v", opened, err) + } + for _, turnID := range []string{"turn-1", "turn-2"} { + response, err := server.RunTurn(ctx, &aop.RunTurnRequest{ + RequestId: "run-" + turnID, SessionId: "session-1", TurnId: turnID, + Input: &aop.Message{Id: "message-" + turnID, Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: turnID}}}}}, + }) + if err != nil || response.GetAccepted() == nil { + t.Fatalf("RunTurn(%s) = %v, %v", turnID, response, err) + } + } + + canceled, err := server.CancelTurn(ctx, &aop.CancelTurnRequest{RequestId: "cancel-1", SessionId: "session-1", TurnId: "turn-1"}) + if err != nil || canceled.GetAccepted().GetTurnId() != "turn-1" { + t.Fatalf("CancelTurn = %v, %v", canceled, err) + } + select { + case frame := <-fake.controlCh: + request := frame.GetCancelTurn() + if request.GetSessionId() != "session-1" || request.GetTurnId() != "turn-1" { + t.Fatalf("cancel frame = %v", request) + } + default: + t.Fatal("cancel frame was not sent") + } + fake.mu.Lock() + _, firstPending := fake.tasks["turn-1"] + _, secondPending := fake.tasks["turn-2"] + fake.mu.Unlock() + if firstPending || !secondPending { + t.Fatalf("pending turns after cancel: turn-1=%v turn-2=%v", firstPending, secondPending) + } + events, err := store.ListAOPEvents(ctx, "session-1", 100) + if err != nil { + t.Fatal(err) + } + terminalCount := 0 + for _, event := range events { + if event.GetTurnEnded() == nil { + continue + } + terminalCount++ + if event.TurnId != "turn-1" || event.GetTurnEnded().GetStopReason() != "canceled" { + t.Fatalf("unexpected terminal event after exact cancel: %v", event) + } + } + if terminalCount != 1 { + t.Fatalf("terminal events after exact cancel = %d, want 1", terminalCount) + } + if _, err := server.CancelTurn(ctx, &aop.CancelTurnRequest{RequestId: "cancel-2", SessionId: "session-1", TurnId: "turn-2"}); err != nil { + t.Fatal(err) + } +} + +func TestAOPRequestJournalSurvivesServerRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat.db") + store, err := NewSQLiteStore(path) + if err != nil { + t.Fatal(err) + } + service := NewService(ServiceConfig{Store: store}) + pool := NewAgentPool(service.Hub()) + service.SetAgentPool(pool) + pool.agents["agent-1"] = &remoteAgent{ + id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 1), controlCh: make(chan *transport.ServerFrame, 1), + tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, + childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), + } + request := &aop.OpenSessionRequest{RequestId: "open-durable", SessionId: "session-1", Participant: "agent-1", Title: "original"} + first, err := NewAOPChatServer(service).OpenSession(context.Background(), request) + if err != nil || first.GetAccepted() == nil { + t.Fatalf("first open = %v, %v", first, err) + } + service.Close() + if err := store.Close(); err != nil { + t.Fatal(err) + } + + store, err = NewSQLiteStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service = NewService(ServiceConfig{Store: store}) + defer service.Close() + server := NewAOPChatServer(service) + replayed, err := server.OpenSession(context.Background(), proto.Clone(request).(*aop.OpenSessionRequest)) + if err != nil || !proto.Equal(first, replayed) { + t.Fatalf("durable replay = %v, %v; want %v", replayed, err, first) + } + conflict := proto.Clone(request).(*aop.OpenSessionRequest) + conflict.Title = "different" + rejected, err := server.OpenSession(context.Background(), conflict) + if err != nil || rejected.GetRejected().GetCode() != "ALREADY_EXISTS" { + t.Fatalf("durable conflict = %v, %v", rejected, err) + } +} diff --git a/pkg/web/auth.go b/pkg/web/auth.go index e5c57b72..22fc87fc 100644 --- a/pkg/web/auth.go +++ b/pkg/web/auth.go @@ -1,30 +1,11 @@ package web import ( - "crypto/sha256" - "crypto/subtle" - "encoding/base64" "net/http" "strings" -) - -const authCookieName = "aiscan_session" -// authenticate resolves the request credential against the access key. -// Explicit Bearer credentials take precedence: an invalid supplied header -// cannot silently fall back to a browser cookie. An empty key disables auth. -func authenticate(r *http.Request, key string) bool { - if key == "" { - return true - } - if token, ok := bearerToken(r.Header.Get("Authorization")); ok { - return accessKeyMatches(key, token) - } - if cookie, err := r.Cookie(authCookieName); err == nil { - return sessionMatches(key, cookie.Value) - } - return false -} + "github.com/chainreactors/aiscan/pkg/web/auth" +) // AccessKeyAuth returns middleware that gates requests behind access-key credentials. // Browser logins exchange the access key for an HttpOnly session cookie so the @@ -47,7 +28,7 @@ func AccessKeyAuth(key string) func(http.Handler) http.Handler { return } - if !authenticate(r, key) { + if !auth.AuthenticateRequest(r, key) { writeError(w, http.StatusUnauthorized, "invalid or missing access key") return } @@ -59,7 +40,7 @@ func AccessKeyAuth(key string) func(http.Handler) http.Handler { func registerAuthRoutes(mux *http.ServeMux, key string) { mux.HandleFunc("GET /api/auth/session", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, map[string]bool{"authenticated": authenticate(r, key)}) + writeJSON(w, http.StatusOK, map[string]bool{"authenticated": auth.AuthenticateRequest(r, key)}) }) mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) { @@ -69,18 +50,18 @@ func registerAuthRoutes(mux *http.ServeMux, key string) { if !decodeBody(w, r, &req) { return } - if !accessKeyMatches(key, strings.TrimSpace(req.Token)) { + if !auth.AccessKeyMatches(key, strings.TrimSpace(req.Token)) { writeError(w, http.StatusUnauthorized, "invalid access token") return } //nolint:gosec // Local HTTP deployments cannot use Secure cookies. http.SetCookie(w, &http.Cookie{ - Name: authCookieName, - Value: sessionValue(key), + Name: auth.CookieName, + Value: auth.SessionValue(key), Path: "/", HttpOnly: true, - Secure: requestIsHTTPS(r), + Secure: auth.RequestIsHTTPS(r), SameSite: http.SameSiteStrictMode, }) w.Header().Set("Cache-Control", "no-store") @@ -90,11 +71,11 @@ func registerAuthRoutes(mux *http.ServeMux, key string) { mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) { //nolint:gosec // Match the transport attributes used by the login cookie. http.SetCookie(w, &http.Cookie{ - Name: authCookieName, + Name: auth.CookieName, Value: "", Path: "/", HttpOnly: true, - Secure: requestIsHTTPS(r), + Secure: auth.RequestIsHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: -1, }) @@ -102,33 +83,3 @@ func registerAuthRoutes(mux *http.ServeMux, key string) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) } - -func bearerToken(header string) (string, bool) { - parts := strings.Fields(header) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - return "", false - } - return parts[1], true -} - -func accessKeyMatches(key, candidate string) bool { - want := sha256.Sum256([]byte(key)) - got := sha256.Sum256([]byte(candidate)) - return subtle.ConstantTimeCompare(want[:], got[:]) == 1 -} - -func sessionValue(key string) string { - sum := sha256.Sum256([]byte("aiscan-web-session\x00" + key)) - return base64.RawURLEncoding.EncodeToString(sum[:]) -} - -func sessionMatches(key, candidate string) bool { - return subtle.ConstantTimeCompare([]byte(sessionValue(key)), []byte(candidate)) == 1 -} - -func requestIsHTTPS(r *http.Request) bool { - if r.TLS != nil { - return true - } - return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https") -} diff --git a/pkg/web/auth/auth.go b/pkg/web/auth/auth.go new file mode 100644 index 00000000..125ecba2 --- /dev/null +++ b/pkg/web/auth/auth.go @@ -0,0 +1,59 @@ +// Package auth contains access-key and session authentication helpers shared +// by the HTTP, ConnectRPC and gRPC surfaces. +package auth + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "net/http" + "strings" +) + +const CookieName = "aiscan_session" + +// AuthenticateRequest resolves the request credential against the access key. +// Explicit Bearer credentials take precedence: an invalid supplied header +// cannot silently fall back to a browser cookie. An empty key disables auth. +func AuthenticateRequest(r *http.Request, key string) bool { + if key == "" { + return true + } + if token, ok := BearerToken(r.Header.Get("Authorization")); ok { + return AccessKeyMatches(key, token) + } + if cookie, err := r.Cookie(CookieName); err == nil { + return SessionMatches(key, cookie.Value) + } + return false +} + +func BearerToken(header string) (string, bool) { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", false + } + return parts[1], true +} + +func AccessKeyMatches(key, candidate string) bool { + want := sha256.Sum256([]byte(key)) + got := sha256.Sum256([]byte(candidate)) + return subtle.ConstantTimeCompare(want[:], got[:]) == 1 +} + +func SessionValue(key string) string { + sum := sha256.Sum256([]byte("aiscan-web-session\x00" + key)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func SessionMatches(key, candidate string) bool { + return subtle.ConstantTimeCompare([]byte(SessionValue(key)), []byte(candidate)) == 1 +} + +func RequestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https") +} diff --git a/pkg/web/auth_test.go b/pkg/web/auth_test.go index 99e2d3bc..f11b4d81 100644 --- a/pkg/web/auth_test.go +++ b/pkg/web/auth_test.go @@ -6,6 +6,8 @@ import ( "net/http/cookiejar" "net/http/httptest" "testing" + + "github.com/chainreactors/aiscan/pkg/web/auth" ) func TestAccessKeyAuthBrowserSession(t *testing.T) { @@ -54,7 +56,7 @@ func TestAccessKeyAuthBearerStillSupported(t *testing.T) { invalid := httptest.NewRequest(http.MethodGet, "/api/protected", nil) invalid.Header.Set("Authorization", "Bearer wrong-token") - invalid.AddCookie(&http.Cookie{Name: authCookieName, Value: sessionValue("test-token")}) + invalid.AddCookie(&http.Cookie{Name: auth.CookieName, Value: auth.SessionValue("test-token")}) invalidRecorder := httptest.NewRecorder() handler.ServeHTTP(invalidRecorder, invalid) if invalidRecorder.Code != http.StatusUnauthorized { @@ -89,31 +91,31 @@ func TestLoginCookieSecurityAttributes(t *testing.T) { func TestAuthenticate(t *testing.T) { req := func() *http.Request { return httptest.NewRequest(http.MethodGet, "/api/x", nil) } - if !authenticate(req(), "") { + if !auth.AuthenticateRequest(req(), "") { t.Fatal("empty key must authenticate (dev mode)") } bearer := req() bearer.Header.Set("Authorization", "Bearer test-token") - if !authenticate(bearer, "test-token") { + if !auth.AuthenticateRequest(bearer, "test-token") { t.Fatal("valid bearer rejected") } // An invalid bearer must not fall back to a valid cookie. mixed := req() mixed.Header.Set("Authorization", "Bearer wrong-token") - mixed.AddCookie(&http.Cookie{Name: authCookieName, Value: sessionValue("test-token")}) - if authenticate(mixed, "test-token") { + mixed.AddCookie(&http.Cookie{Name: auth.CookieName, Value: auth.SessionValue("test-token")}) + if auth.AuthenticateRequest(mixed, "test-token") { t.Fatal("invalid bearer fell back to cookie") } cookie := req() - cookie.AddCookie(&http.Cookie{Name: authCookieName, Value: sessionValue("test-token")}) - if !authenticate(cookie, "test-token") { + cookie.AddCookie(&http.Cookie{Name: auth.CookieName, Value: auth.SessionValue("test-token")}) + if !auth.AuthenticateRequest(cookie, "test-token") { t.Fatal("valid session cookie rejected") } - if authenticate(req(), "test-token") { + if auth.AuthenticateRequest(req(), "test-token") { t.Fatal("credential-less request authenticated") } } diff --git a/pkg/web/broker.go b/pkg/web/broker.go new file mode 100644 index 00000000..a686a450 --- /dev/null +++ b/pkg/web/broker.go @@ -0,0 +1,128 @@ +package web + +import ( + "sync" + + aop "github.com/chainreactors/aiscan/aop" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + protobuf "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Hub is a typed in-process broker. Durable replay remains the responsibility +// of the event stores; live protobuf values never pass through JSON envelopes. +type Hub struct { + mu sync.Mutex + aopSubscribers map[string]map[chan AOPDelivery]struct{} + scanSubscribers map[string]map[chan *scanpb.ScanEvent]struct{} + scanSequence map[string]uint64 +} + +type AOPDelivery struct { + Cursor int64 + Event *aop.Event +} + +func NewHub() *Hub { + return &Hub{ + aopSubscribers: make(map[string]map[chan AOPDelivery]struct{}), + scanSubscribers: make(map[string]map[chan *scanpb.ScanEvent]struct{}), + scanSequence: make(map[string]uint64), + } +} + +func (h *Hub) SubscribeAOP(sessionID string) (<-chan AOPDelivery, func()) { + ch := make(chan AOPDelivery, 64) + h.mu.Lock() + if _, ok := h.aopSubscribers[sessionID]; !ok { + h.aopSubscribers[sessionID] = make(map[chan AOPDelivery]struct{}) + } + h.aopSubscribers[sessionID][ch] = struct{}{} + h.mu.Unlock() + return ch, func() { + h.mu.Lock() + if bucket, ok := h.aopSubscribers[sessionID]; ok { + delete(bucket, ch) + if len(bucket) == 0 { + delete(h.aopSubscribers, sessionID) + } + } + close(ch) + h.mu.Unlock() + } +} + +func (h *Hub) BroadcastAOP(sessionID string, delivery AOPDelivery, reliable bool) { + if delivery.Event == nil { + return + } + h.mu.Lock() + for ch := range h.aopSubscribers[sessionID] { + value := AOPDelivery{Cursor: delivery.Cursor, Event: protobuf.Clone(delivery.Event).(*aop.Event)} + broadcastBuffered(ch, value, reliable) + } + h.mu.Unlock() +} + +// SubscribeScan registers a live subscriber and returns the sequence that was +// current at the subscription boundary. A caller can stamp its initial +// snapshot with this value, then safely ignore queued events at or below it. +func (h *Hub) SubscribeScan(scanID string) (<-chan *scanpb.ScanEvent, uint64, func()) { + ch := make(chan *scanpb.ScanEvent, 64) + h.mu.Lock() + if _, ok := h.scanSubscribers[scanID]; !ok { + h.scanSubscribers[scanID] = make(map[chan *scanpb.ScanEvent]struct{}) + } + h.scanSubscribers[scanID][ch] = struct{}{} + sequence := h.scanSequence[scanID] + h.mu.Unlock() + return ch, sequence, func() { + h.mu.Lock() + if bucket, ok := h.scanSubscribers[scanID]; ok { + delete(bucket, ch) + if len(bucket) == 0 { + delete(h.scanSubscribers, scanID) + } + } + close(ch) + h.mu.Unlock() + } +} + +func (h *Hub) BroadcastScan(event *scanpb.ScanEvent, reliable bool) { + if event == nil || event.ScanId == "" { + return + } + h.mu.Lock() + if event.Sequence == 0 { + h.scanSequence[event.ScanId]++ + event.Sequence = h.scanSequence[event.ScanId] + } else if event.Sequence > h.scanSequence[event.ScanId] { + h.scanSequence[event.ScanId] = event.Sequence + } + if event.EmittedAt == nil { + event.EmittedAt = timestamppb.Now() + } + for ch := range h.scanSubscribers[event.ScanId] { + broadcastBuffered(ch, protobuf.Clone(event).(*scanpb.ScanEvent), reliable) + } + h.mu.Unlock() +} + +func broadcastBuffered[T any](ch chan T, value T, reliable bool) { + select { + case ch <- value: + default: + if !reliable { + return + } + select { + case <-ch: + default: + } + select { + case ch <- value: + default: + } + } +} diff --git a/pkg/web/broker_test.go b/pkg/web/broker_test.go new file mode 100644 index 00000000..16e52c10 --- /dev/null +++ b/pkg/web/broker_test.go @@ -0,0 +1,225 @@ +package web + +import ( + "context" + "path/filepath" + "testing" + "time" + + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestHubBroadcastAOPReliableSurvivesBackpressure(t *testing.T) { + hub := NewHub() + deliveries, unsubscribe := hub.SubscribeAOP("session-1") + defer unsubscribe() + + for i := int64(1); i <= 64; i++ { + hub.BroadcastAOP("session-1", AOPDelivery{Cursor: i, Event: &aop.Event{ + SessionId: "session-1", Payload: &aop.Event_MessageDelta{MessageDelta: &aop.MessageDelta{}}, + }}, false) + } + hub.BroadcastAOP("session-1", AOPDelivery{Cursor: 999, Event: &aop.Event{ + SessionId: "session-1", Payload: &aop.Event_MessageDelta{MessageDelta: &aop.MessageDelta{}}, + }}, false) + hub.BroadcastAOP("session-1", AOPDelivery{Cursor: 1000, Event: &aop.Event{ + SessionId: "session-1", Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}, + }}, true) + + var sawTerminal, sawOverflow bool + for len(deliveries) > 0 { + delivery := <-deliveries + sawTerminal = sawTerminal || delivery.Cursor == 1000 + sawOverflow = sawOverflow || delivery.Cursor == 999 + } + if !sawTerminal { + t.Fatal("reliable terminal AOP event was dropped") + } + if sawOverflow { + t.Fatal("droppable AOP event displaced buffered data") + } +} + +func TestHubBroadcastScanReliableSurvivesBackpressure(t *testing.T) { + hub := NewHub() + events, _, unsubscribe := hub.SubscribeScan("scan-1") + defer unsubscribe() + + for i := 0; i < 64; i++ { + hub.BroadcastScan(scanProgressEvent("scan-1", "progress"), false) + } + overflow := scanProgressEvent("scan-1", "overflow") + hub.BroadcastScan(overflow, false) + terminal := scanFailedEvent("scan-1", "failed", false) + hub.BroadcastScan(terminal, true) + + var sawTerminal, sawOverflow bool + for len(events) > 0 { + event := <-events + sawTerminal = sawTerminal || event.GetFailed() != nil + sawOverflow = sawOverflow || event.GetProgress().GetData() == "overflow" + } + if !sawTerminal { + t.Fatal("reliable terminal scan event was dropped") + } + if sawOverflow { + t.Fatal("droppable scan event displaced buffered data") + } +} + +func TestScanSubscriptionReturnsSnapshotSequenceBoundary(t *testing.T) { + hub := NewHub() + hub.BroadcastScan(scanProgressEvent("scan-1", "before-subscribe"), false) + events, sequence, unsubscribe := hub.SubscribeScan("scan-1") + defer unsubscribe() + if sequence != 1 { + t.Fatalf("subscription sequence = %d, want 1", sequence) + } + snapshot := scanSnapshot(&ScanJob{ID: "scan-1"}, sequence) + if snapshot.Sequence != sequence { + t.Fatalf("snapshot sequence = %d, want %d", snapshot.Sequence, sequence) + } + hub.BroadcastScan(scanProgressEvent("scan-1", "after-subscribe"), false) + select { + case event := <-events: + if event.Sequence <= snapshot.Sequence { + t.Fatalf("live sequence = %d, snapshot = %d", event.Sequence, snapshot.Sequence) + } + case <-time.After(time.Second): + t.Fatal("missing live scan event") + } +} + +func TestBroadcastAOPEventPersistsCanonicalProtoJSON(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(ServiceConfig{Store: store}) + createStoredSession(t, store, "session-aop") + event := &aop.Event{ + Id: "event-1", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)), + SessionId: "session-aop", Emitter: "aiscan", Seq: 7, + Payload: &aop.Event_Message{Message: &aop.Message{Id: "message-1", Role: "assistant", Content: []*aop.Content{aop.Text("hello")}}}, + } + service.BroadcastAOPEvent("session-aop", event) + + events, err := store.ListAOPEvents(context.Background(), "session-aop", 100) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || !proto.Equal(events[0], event) { + t.Fatalf("persisted events = %+v, want %+v", events, event) + } +} + +func TestEvalMetadataPersistsOnlyInAOP(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(ServiceConfig{Store: store}) + createStoredSession(t, store, "session-eval") + event := &aop.Event{ + Id: "event-1", EmittedAt: timestamppb.Now(), SessionId: "session-eval", TurnId: "turn-1", Emitter: "aiscan", + Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}, + } + _ = ext.SetEvalDetail(event, ext.EvalDetail{Round: 2, Reason: "needs verification"}) + service.BroadcastAOPEvent("session-eval", event) + events, err := store.ListAOPEvents(context.Background(), "session-eval", 100) + if err != nil || len(events) != 1 { + t.Fatalf("events = %+v, err = %v", events, err) + } + detail, ok, err := ext.GetEvalDetail(events[0]) + if err != nil || !ok || detail.Round != 2 || detail.Reason != "needs verification" { + t.Fatalf("eval detail = %+v, ok = %v, err = %v", detail, ok, err) + } +} + +func TestServerGeneratedAOPEventContinuesStoredSessionSequence(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + createStoredSession(t, store, "session-seq") + if err := store.AddAOPEvent(context.Background(), "session-seq", &aop.Event{ + Id: "agent-7", EmittedAt: timestamppb.Now(), SessionId: "session-seq", Emitter: "agent", Seq: 7, + Payload: &aop.Event_Status{Status: &aop.Status{State: "running"}}, + }); err != nil { + t.Fatal(err) + } + service := NewService(ServiceConfig{Store: store}) + service.broadcastHubError("session-seq", "failed", "failed", nil) + events, err := store.ListAOPEvents(context.Background(), "session-seq", 10) + if err != nil || len(events) != 2 || events[1].Seq != 8 || events[1].GetError() == nil { + t.Fatalf("events = %+v, err = %v", events, err) + } +} + +func TestScanCompletePersistsTypedAOPExtension(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + createStoredSession(t, store, "session-scan") + service := NewService(ServiceConfig{Store: store}) + service.registerSessionTask("scan-123", "session-scan", "") + service.broadcastScanComplete("scan-123") + + events, err := store.ListAOPEvents(context.Background(), "session-scan", 10) + if err != nil || len(events) != 1 { + t.Fatalf("events = %+v, err = %v", events, err) + } + extension := events[0].GetExtension() + if extension == nil || extension.Type != "io.chainreactors.aiscan.scan" { + t.Fatalf("extension = %+v", extension) + } + value := new(scanpb.SessionScanEvent) + if err := aop.DecodeProtoJSON(extension.Value, value); err != nil { + t.Fatal(err) + } + if value.ScanId != "scan-123" || value.Status != scanpb.ScanStatus_SCAN_STATUS_COMPLETED { + t.Fatalf("scan extension = %+v", value) + } + ids, err := store.SessionScanIDs(context.Background(), "session-scan") + if err != nil || len(ids) != 1 || ids[0] != "scan-123" { + t.Fatalf("session scan ids = %v, err = %v", ids, err) + } +} + +func TestWatchScanEventsImmediatelyReturnsTerminalSnapshot(t *testing.T) { + for _, status := range []ScanStatus{StatusCompleted, StatusFailed, StatusCanceled} { + t.Run(string(status), func(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Now() + job := &ScanJob{ID: "terminal-scan", Target: "127.0.0.1", Mode: "quick", Status: status, CreatedAt: now, UpdatedAt: now} + if err := store.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + service := NewService(ServiceConfig{Store: store}) + var responses []*scanpb.WatchScanEventsResponse + err = newScanServiceCore(service).WatchScanEvents( + &scanpb.WatchScanEventsRequest{ScanId: job.ID}, context.Background(), + func(response *scanpb.WatchScanEventsResponse) error { + responses = append(responses, response) + return nil + }, + ) + if err != nil || len(responses) != 1 || responses[0].GetEvent().GetSnapshot().GetId() != job.ID { + t.Fatalf("responses = %+v, err = %v", responses, err) + } + }) + } +} diff --git a/pkg/web/command_test.go b/pkg/web/command_test.go index 3b2468ef..a5082c96 100644 --- a/pkg/web/command_test.go +++ b/pkg/web/command_test.go @@ -2,14 +2,13 @@ package web import ( "context" - "encoding/json" - "net/http" "net/http/httptest" "path/filepath" "testing" - "time" - "github.com/chainreactors/aiscan/pkg/webproto" + "connectrpc.com/connect" + chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" + "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" ) func TestParseCommand(t *testing.T) { @@ -61,77 +60,40 @@ func TestSessionMenuMergeAndFallback(t *testing.T) { for _, s := range svc.SessionMenu("no-such-session") { names[s.Name] = true } - for _, want := range []string{"/scan", "/agents", "/help", "/status", "/provider", "/model"} { + for _, want := range []string{"/agents", "/help", "/status", "/provider", "/model"} { if !names[want] { t.Errorf("SessionMenu missing %q", want) } } - for _, absent := range []string{"/stop", "/continue", "/eval", "/followup", "/loop"} { + for _, absent := range []string{"/scan", "/stop", "/continue", "/eval", "/followup", "/loop"} { if names[absent] { t.Errorf("SessionMenu leaked run-control command %q", absent) } } } -// TestClearCommandWipesTranscript verifies web /clear is a true "clear -// conversation": the session's persisted messages are deleted (so a reload stays -// empty), not merely the agent's model context. No agent is bound here, so the -// path is store-wipe + UI signal only. -func TestClearCommandWipesTranscript(t *testing.T) { - svc := newMenuTestService(t) - ctx := context.Background() - sid := "sess-clear" - createStoredSession(t, svc.store, sid) - for _, role := range []string{"user", "assistant", "user"} { - err := svc.store.AddMessage(ctx, &ChatMessage{ - ID: generateID(), SessionID: sid, Role: role, Content: "x", CreatedAt: time.Now(), - }) - if err != nil { - t.Fatalf("AddMessage: %v", err) - } - } - if msgs, _ := svc.GetMessages(ctx, sid); len(msgs) != 3 { - t.Fatalf("setup: got %d messages, want 3", len(msgs)) - } - - svc.handleClearCommand(sid, webproto.GoalExt{}) - - msgs, err := svc.GetMessages(ctx, sid) - if err != nil { - t.Fatalf("GetMessages: %v", err) - } - if len(msgs) != 0 { - t.Errorf("after /clear: got %d messages, want 0", len(msgs)) - } -} - -// TestSessionCommandsRoute drives the real HTTP endpoint the frontend "/" menu -// fetches, proving the route is wired and returns a JSON slash-command catalog. -func TestSessionCommandsRoute(t *testing.T) { +// TestSessionCommandsConnectRPC drives the generated Connect endpoint the +// frontend "/" menu uses and proves it returns the protobuf command catalog. +func TestSessionCommandsConnectRPC(t *testing.T) { svc := newMenuTestService(t) srv := httptest.NewServer(NewHandler(svc, nil, nil, nil, nil, "")) defer srv.Close() - resp, err := http.Get(srv.URL + "/api/chat/sessions/anything/commands") + client := chatconnect.NewSessionServiceClient(srv.Client(), srv.URL, connect.WithProtoJSON()) + resp, err := client.ListCommands(context.Background(), connect.NewRequest(&chatpb.ListCommandsRequest{SessionId: "anything"})) if err != nil { - t.Fatalf("GET /commands: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) - } - - var specs []webproto.CommandSpec - if err := json.NewDecoder(resp.Body).Decode(&specs); err != nil { - t.Fatalf("decode: %v", err) + t.Fatalf("ListCommands: %v", err) } names := map[string]bool{} - for _, s := range specs { + for _, s := range resp.Msg.Commands { names[s.Name] = true } - for _, want := range []string{"/scan", "/help", "/status", "/model"} { + for _, want := range []string{"/help", "/status", "/model"} { if !names[want] { - t.Errorf("/commands response missing %q (got %d specs)", want, len(specs)) + t.Errorf("ListCommands response missing %q (got %d specs)", want, len(resp.Msg.Commands)) } } + if names["/scan"] { + t.Error("ListCommands leaked deferred scan command") + } } diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go index 1faa0c64..6a730763 100644 --- a/pkg/web/config_profiles_test.go +++ b/pkg/web/config_profiles_test.go @@ -4,13 +4,13 @@ import ( "context" "testing" - "github.com/chainreactors/aiscan/pkg/webproto" + proto "github.com/chainreactors/aiscan/core/config" ) func TestActivateLLMProfileSelectsByID(t *testing.T) { store := &fakeConfigStore{} store.cfg.LLM.ActiveProfile = "primary" - store.cfg.LLM.Providers = []webproto.LLMProviderConfig{ + store.cfg.LLM.Providers = []proto.LLMProviderConfig{ {ID: "primary", Name: "Primary", Provider: "openai", Model: "gpt-primary", APIKey: "key-1"}, {ID: "fast", Name: "Fast", Provider: "openai", Model: "deepseek-fast", APIKey: "key-2"}, } @@ -34,9 +34,9 @@ func TestActivateLLMProfileSelectsByID(t *testing.T) { } func TestConfigStatusIncludesModelLimits(t *testing.T) { - var cfg webproto.DistributeConfig + var cfg proto.DistributeConfig cfg.LLM.ActiveProfile = "large" - cfg.LLM.Providers = []webproto.LLMProviderConfig{{ + cfg.LLM.Providers = []proto.LLMProviderConfig{{ ID: "large", Provider: "anthropic", Model: "glm-5.2[1m]", MaxTokens: 32768, ContextWindow: 1000000, }} @@ -52,14 +52,14 @@ func TestConfigStatusIncludesModelLimits(t *testing.T) { func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) { store := &fakeConfigStore{} service := NewService(ServiceConfig{ConfigStore: store}) - for _, mutate := range []func(*webproto.LLMProviderConfig){ - func(p *webproto.LLMProviderConfig) { p.MaxTokens = -1 }, - func(p *webproto.LLMProviderConfig) { p.ContextWindow = -1 }, + for _, mutate := range []func(*proto.LLMProviderConfig){ + func(p *proto.LLMProviderConfig) { p.MaxTokens = -1 }, + func(p *proto.LLMProviderConfig) { p.ContextWindow = -1 }, } { - var cfg webproto.DistributeConfig - profile := webproto.LLMProviderConfig{ID: "bad", Model: "test-model"} + var cfg proto.DistributeConfig + profile := proto.LLMProviderConfig{ID: "bad", Model: "test-model"} mutate(&profile) - cfg.LLM.Providers = []webproto.LLMProviderConfig{profile} + cfg.LLM.Providers = []proto.LLMProviderConfig{profile} if _, err := service.SaveConfig(context.Background(), cfg); err == nil { t.Fatal("SaveConfig() accepted a negative model limit") } @@ -72,8 +72,8 @@ func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) { func TestSaveConfigRejectsEmptyProfileModel(t *testing.T) { store := &fakeConfigStore{} service := NewService(ServiceConfig{ConfigStore: store}) - var cfg webproto.DistributeConfig - cfg.LLM.Providers = []webproto.LLMProviderConfig{{ID: "empty", Name: "Empty", Model: " "}} + var cfg proto.DistributeConfig + cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "empty", Name: "Empty", Model: " "}} if _, err := service.SaveConfig(context.Background(), cfg); err == nil { t.Fatal("SaveConfig() accepted an empty profile model") @@ -86,7 +86,7 @@ func TestSaveConfigRejectsEmptyProfileModel(t *testing.T) { func TestActivateLLMProfileRejectsEmptyModel(t *testing.T) { store := &fakeConfigStore{} store.cfg.LLM.ActiveProfile = "primary" - store.cfg.LLM.Providers = []webproto.LLMProviderConfig{ + store.cfg.LLM.Providers = []proto.LLMProviderConfig{ {ID: "primary", Model: "gpt-primary"}, {ID: "empty", Model: ""}, } diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go index fe100730..fb1a1360 100644 --- a/pkg/web/config_reload_test.go +++ b/pkg/web/config_reload_test.go @@ -1,19 +1,19 @@ package web import ( - "encoding/json" "testing" "time" - "github.com/chainreactors/aiscan/pkg/webproto" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" ) func newFakeAgent(id string, buf int) *remoteAgent { return &remoteAgent{ id: id, name: id, - sendCh: make(chan WSMessage, buf), - controlCh: make(chan WSMessage, 1), + sendCh: make(chan *transport.ServerFrame, buf), + controlCh: make(chan *transport.ServerFrame, 1), tasks: make(map[string]chan taskResult), turns: make(map[string]int), done: make(chan struct{}), @@ -26,7 +26,7 @@ func TestBroadcastConfigReload(t *testing.T) { pool := NewAgentPool(nil) open := newFakeAgent("open", 1) full := newFakeAgent("full", 1) - full.sendCh <- WSMessage{Type: "exec"} // saturate the buffer + full.sendCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_Exec{Exec: &transport.ExecRequest{TaskId: "busy"}}} // saturate the buffer pool.register(open) pool.register(full) @@ -35,16 +35,16 @@ func TestBroadcastConfigReload(t *testing.T) { } select { case msg := <-open.controlCh: - if msg.Type != "config" { - t.Fatalf("open agent got %q, want config", msg.Type) + if msg.GetReloadConfig() == nil { + t.Fatalf("open agent got %+v, want reload_config", msg) } default: t.Fatal("open agent got no config message") } select { case msg := <-full.controlCh: - if msg.Type != "config" { - t.Fatalf("full agent got %q, want config", msg.Type) + if msg.GetReloadConfig() == nil { + t.Fatalf("full agent got %+v, want reload_config", msg) } default: t.Fatal("full agent got no config control message") @@ -54,19 +54,19 @@ func TestBroadcastConfigReload(t *testing.T) { func TestBroadcastConfigReloadWaitsBehindCancellationFrames(t *testing.T) { pool := NewAgentPool(nil) agent := newFakeAgent("busy-control", 1) - agent.controlCh <- WSMessage{Type: webproto.TypeRunCancel, TurnID: "task-1"} + agent.controlCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_CancelTurn{CancelTurn: &aop.CancelTurnRequest{TurnId: "task-1"}}} pool.register(agent) if n := pool.BroadcastConfigReload(); n != 1 { t.Fatalf("notified = %d, want 1", n) } - if msg := <-agent.controlCh; msg.Type != webproto.TypeRunCancel { - t.Fatalf("first control frame = %q, want %q", msg.Type, webproto.TypeRunCancel) + if msg := <-agent.controlCh; msg.GetCancelTurn().GetTurnId() != "task-1" { + t.Fatalf("first control frame = %+v, want cancel turn", msg) } select { case msg := <-agent.controlCh: - if msg.Type != "config" { - t.Fatalf("queued control frame = %q, want config", msg.Type) + if msg.GetReloadConfig() == nil { + t.Fatalf("queued control frame = %+v, want reload config", msg) } case <-time.After(time.Second): t.Fatal("config reload was dropped behind a full cancellation queue") @@ -76,12 +76,13 @@ func TestBroadcastConfigReloadWaitsBehindCancellationFrames(t *testing.T) { func TestHandleAgentStatusUpdate(t *testing.T) { pool := NewAgentPool(nil) a := newFakeAgent("n1", 1) - a.runtime = webproto.AgentRuntime{PID: 4242, Hostname: "local-1"} - a.status = webproto.AgentStatus{Provider: "anthropic", Model: "old-model"} + a.runtime = &transport.AgentRuntimeInfo{Pid: 4242, Hostname: "local-1"} + a.status = &transport.AgentStatus{Provider: "anthropic", Model: "old-model"} pool.register(a) - payload, _ := json.Marshal(webproto.AgentStatus{Provider: "anthropic", Model: "glm-5.2", Bound: true}) - pool.handleAgentMessage(a, WSMessage{Type: "agent.status", Payload: payload}) + pool.handleAgentFrame(a, &transport.AgentFrame{Payload: &transport.AgentFrame_Status{Status: &transport.AgentStatus{ + Provider: "anthropic", Model: "glm-5.2", Bound: true, + }}}) got := a.info().Status if got.Model != "glm-5.2" { @@ -98,20 +99,20 @@ func TestHandleAgentStatusUpdate(t *testing.T) { func TestHandleConfigReloadResultUpdatesAgentStatus(t *testing.T) { pool := NewAgentPool(nil) a := newFakeAgent("n1", 1) - a.status = webproto.AgentStatus{Provider: "openai", Model: "old-model"} + a.status = &transport.AgentStatus{Provider: "openai", Model: "old-model"} pool.register(a) - payload, _ := json.Marshal(webproto.ConfigReloadResult{ - OK: true, Provider: "openai", Model: "deepseek-v4-pro", - }) - pool.handleAgentMessage(a, WSMessage{Type: "config.result", Payload: payload}) + pool.handleAgentFrame(a, &transport.AgentFrame{Payload: &transport.AgentFrame_ConfigReload{ConfigReload: &transport.ConfigReloadResult{ + Ok: true, Provider: "openai", Model: "deepseek-v4-pro", + }}}) got := a.info().Status if got.Provider != "openai" || got.Model != "deepseek-v4-pro" || got.ConfigError != "" { t.Fatalf("unexpected config result status: %+v", got) } - payload, _ = json.Marshal(webproto.ConfigReloadResult{OK: false, Error: "invalid API key"}) - pool.handleAgentMessage(a, WSMessage{Type: "config.result", Payload: payload}) + pool.handleAgentFrame(a, &transport.AgentFrame{Payload: &transport.AgentFrame_ConfigReload{ConfigReload: &transport.ConfigReloadResult{ + Ok: false, Error: "invalid API key", + }}}) if got := a.info().Status; got.ConfigError != "invalid API key" { t.Fatalf("config error = %q", got.ConfigError) } diff --git a/pkg/web/config_transaction_test.go b/pkg/web/config_transaction_test.go index c2f3a9c4..f6d9c74e 100644 --- a/pkg/web/config_transaction_test.go +++ b/pkg/web/config_transaction_test.go @@ -7,25 +7,25 @@ import ( "testing" "time" + proto "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/runner" - "github.com/chainreactors/aiscan/pkg/webproto" ) type transactionalConfigStore struct { mu sync.Mutex - cfg webproto.DistributeConfig + cfg proto.DistributeConfig commitErr error discarded int prepareLog []string } -func (s *transactionalConfigStore) GetDistributeConfig(context.Context) (string, bool, webproto.DistributeConfig, error) { +func (s *transactionalConfigStore) GetDistributeConfig(context.Context) (string, bool, proto.DistributeConfig, error) { s.mu.Lock() defer s.mu.Unlock() return "config.yaml", true, s.cfg, nil } -func (s *transactionalConfigStore) PrepareDistributeConfig(_ context.Context, cfg webproto.DistributeConfig) (*PreparedConfig, error) { +func (s *transactionalConfigStore) PrepareDistributeConfig(_ context.Context, cfg proto.DistributeConfig) (*PreparedConfig, error) { s.mu.Lock() s.prepareLog = append(s.prepareLog, cfg.LLM.Active().Model) s.mu.Unlock() @@ -62,10 +62,10 @@ func (c *recordingCloser) Close() { c.once.Do(func() { close(c.done) }) } -func configForModel(model string) webproto.DistributeConfig { - var cfg webproto.DistributeConfig +func configForModel(model string) proto.DistributeConfig { + var cfg proto.DistributeConfig cfg.LLM.ActiveProfile = "primary" - cfg.LLM.Providers = []webproto.LLMProviderConfig{{ID: "primary", Provider: "openai", Model: model}} + cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "primary", Provider: "openai", Model: model}} return cfg } diff --git a/pkg/web/conn_probe_test.go b/pkg/web/conn_probe_test.go index 7b956aa0..59e8ca16 100644 --- a/pkg/web/conn_probe_test.go +++ b/pkg/web/conn_probe_test.go @@ -8,11 +8,11 @@ import ( "strings" "testing" + proto "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/probe" - "github.com/chainreactors/aiscan/pkg/webproto" ) -type cfgT = webproto.DistributeConfig +type cfgT = proto.DistributeConfig // configWith builds a DistributeConfig, letting each test set only the fields // it cares about. Pass nil for an empty config. diff --git a/pkg/web/connect.go b/pkg/web/connect.go new file mode 100644 index 00000000..4abf35e6 --- /dev/null +++ b/pkg/web/connect.go @@ -0,0 +1,139 @@ +package web + +import ( + "context" + "errors" + "net/http" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" + "github.com/chainreactors/aiscan/aop/aiscan/scan/scanconnect" + "github.com/chainreactors/aiscan/aop/aopconnect" + "github.com/chainreactors/aiscan/pkg/web/auth" + "google.golang.org/grpc/status" +) + +// Protobuf JSON base64-encodes bytes, so a 50 MiB UploadSessionFile payload can +// occupy roughly 67 MiB on the wire. Leave enough envelope headroom while the +// business method continues to enforce the exact 50 MiB file limit. +const connectMaxMessageBytes = 72 << 20 + +// NewConnectHandler exposes the public AOP service and AIScan's product-specific +// chat service from the same protobuf schemas. Generated Connect handlers also +// accept native gRPC and gRPC-Web requests on the canonical procedure paths. +func NewConnectHandler(accessKey string, service *Service) http.Handler { + interceptor := connectAuthInterceptor{accessKey: accessKey} + opts := []connect.HandlerOption{ + connect.WithInterceptors(interceptor), + connect.WithReadMaxBytes(connectMaxMessageBytes), + connect.WithSendMaxBytes(connectMaxMessageBytes), + } + mux := http.NewServeMux() + chatCore := NewAOPChatServer(service).(*aopChatServer) + chatPath, chatHandler := aopconnect.NewChatServiceHandler(&connectChatServer{core: chatCore}, opts...) + sessionPath, sessionHandler := chatconnect.NewSessionServiceHandler(newConnectSessionServer(service, chatCore), opts...) + scanPath, scanHandler := scanconnect.NewScanServiceHandler(newConnectScanServer(service), opts...) + mux.Handle(chatPath, chatHandler) + mux.Handle(sessionPath, sessionHandler) + mux.Handle(scanPath, scanHandler) + return mux +} + +type connectChatServer struct { + aopconnect.UnimplementedChatServiceHandler + core *aopChatServer +} + +func (s *connectChatServer) OpenSession(ctx context.Context, req *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { + response, err := s.core.OpenSession(ctx, req.Msg) + return connectResponse(response, err) +} + +func (s *connectChatServer) RunTurn(ctx context.Context, req *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { + response, err := s.core.RunTurn(ctx, req.Msg) + return connectResponse(response, err) +} + +func (s *connectChatServer) CancelTurn(ctx context.Context, req *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) { + response, err := s.core.CancelTurn(ctx, req.Msg) + return connectResponse(response, err) +} + +func (s *connectChatServer) CloseSession(ctx context.Context, req *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) { + response, err := s.core.CloseSession(ctx, req.Msg) + return connectResponse(response, err) +} + +func (s *connectChatServer) ListEvents(ctx context.Context, req *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { + response, err := s.core.ListEvents(ctx, req.Msg) + return connectResponse(response, err) +} + +func (s *connectChatServer) WatchEvents(ctx context.Context, req *connect.Request[aop.WatchEventsRequest], stream *connect.ServerStream[aop.WatchEventsResponse]) error { + return asConnectError(s.core.watchEvents(req.Msg, ctx, stream.Send)) +} + +func connectResponse[T any](response *T, err error) (*connect.Response[T], error) { + if err != nil { + return nil, asConnectError(err) + } + return connect.NewResponse(response), nil +} + +func asConnectError(err error) error { + if err == nil { + return nil + } + var connectErr *connect.Error + if errors.As(err, &connectErr) { + return connectErr + } + if grpcStatus, ok := status.FromError(err); ok { + return connect.NewError(connect.Code(grpcStatus.Code()), errors.New(grpcStatus.Message())) + } + return connect.NewError(connect.CodeInternal, err) +} + +type connectAuthInterceptor struct { + accessKey string +} + +func (i connectAuthInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if !connectAuthenticated(req.Header(), i.accessKey) { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid or missing access key")) + } + return next(ctx, req) + } +} + +func (i connectAuthInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (i connectAuthInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + if !connectAuthenticated(conn.RequestHeader(), i.accessKey) { + return connect.NewError(connect.CodeUnauthenticated, errors.New("invalid or missing access key")) + } + return next(ctx, conn) + } +} + +func connectAuthenticated(header http.Header, accessKey string) bool { + if accessKey == "" { + return true + } + if token, ok := auth.BearerToken(header.Get("Authorization")); ok { + return auth.AccessKeyMatches(accessKey, token) + } + request := &http.Request{Header: header} + if cookie, err := request.Cookie(auth.CookieName); err == nil { + return auth.SessionMatches(accessKey, cookie.Value) + } + return false +} + +var _ aopconnect.ChatServiceHandler = (*connectChatServer)(nil) +var _ chatconnect.SessionServiceHandler = (*connectSessionServer)(nil) diff --git a/pkg/web/connect_test.go b/pkg/web/connect_test.go new file mode 100644 index 00000000..11677807 --- /dev/null +++ b/pkg/web/connect_test.go @@ -0,0 +1,268 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" + "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/aop/aopconnect" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/protobuf/proto" +) + +func TestConnectJSONAndGRPCShareChatContract(t *testing.T) { + service, pool, stop := newConnectTestService(t) + defer stop() + handler := NewHandler(service, pool, nil, nil, nil, "") + server := httptest.NewUnstartedServer(handler) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + connectClient := aopconnect.NewChatServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) + opened, err := connectClient.OpenSession(ctx, connect.NewRequest(&aop.OpenSessionRequest{ + RequestId: "connect-open", SessionId: "connect-session", Participant: "agent-1", Title: "connect", + })) + if err != nil || opened.Msg.GetAccepted().GetState() != "open" { + t.Fatalf("Connect OpenSession = %v, %v", opened, err) + } + watch, err := connectClient.WatchEvents(ctx, connect.NewRequest(&aop.WatchEventsRequest{SessionId: "connect-session"})) + if err != nil { + t.Fatal(err) + } + input := &aop.Message{Id: "message-client", Role: "user", Name: "operator", Content: []*aop.Content{{ + Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello over Connect"}}, + }}} + run, err := connectClient.RunTurn(ctx, connect.NewRequest(&aop.RunTurnRequest{ + RequestId: "connect-run", SessionId: "connect-session", TurnId: "connect-turn", Input: input, + })) + if err != nil || run.Msg.GetAccepted().GetState() != "running" { + t.Fatalf("Connect RunTurn = %v, %v", run, err) + } + var sawInput, sawEnd bool + for watch.Receive() { + event := watch.Msg().GetDelivery().GetEvent() + if message := event.GetMessage(); message != nil && message.Id == input.Id { + sawInput = proto.Equal(message, input) + } + if event.GetTurnEnded() != nil && event.TurnId == "connect-turn" { + sawEnd = true + break + } + } + if err := watch.Err(); err != nil && !sawEnd { + t.Fatal(err) + } + if !sawInput || !sawEnd { + t.Fatalf("Connect stream sawInput=%v sawEnd=%v", sawInput, sawEnd) + } + + sessionClient := chatconnect.NewSessionServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) + resetRequest := &chatpb.ResetSessionRequest{ + RequestId: "reset-1", SessionId: "connect-session", NewSessionId: "connect-session-reset", + } + reset, err := sessionClient.ResetSession(ctx, connect.NewRequest(resetRequest)) + if err != nil || reset.Msg.GetAccepted().GetCurrent().GetSession().GetId() != "connect-session-reset" { + t.Fatalf("ResetSession = %v, %v", reset, err) + } + assertResetHistory := func() { + t.Helper() + oldEvents, err := connectClient.ListEvents(ctx, connect.NewRequest(&aop.ListEventsRequest{SessionId: "connect-session", Limit: 100})) + if err != nil { + t.Fatal(err) + } + oldMessage, oldEnded := 0, 0 + for _, delivery := range oldEvents.Msg.Events { + event := delivery.Event + if event.GetMessage().GetId() == input.Id { + oldMessage++ + } + if event.GetSessionEnded().GetReason() == "reset" { + oldEnded++ + } + } + if oldMessage != 1 || oldEnded != 1 { + t.Fatalf("old session history message=%d reset_end=%d events=%v", oldMessage, oldEnded, oldEvents.Msg.Events) + } + newEvents, err := connectClient.ListEvents(ctx, connect.NewRequest(&aop.ListEventsRequest{SessionId: "connect-session-reset", Limit: 100})) + if err != nil { + t.Fatal(err) + } + started := 0 + for _, delivery := range newEvents.Msg.Events { + event := delivery.Event + if event.GetSessionStarted() != nil { + started++ + } + if event.GetMessage() != nil || event.GetTurnStarted() != nil || event.GetTurnEnded() != nil { + t.Fatalf("reset session inherited chat history: %v", event) + } + } + if started != 1 { + t.Fatalf("new session_started count = %d, events=%v", started, newEvents.Msg.Events) + } + } + assertResetHistory() + replayedReset, err := sessionClient.ResetSession(ctx, connect.NewRequest(proto.Clone(resetRequest).(*chatpb.ResetSessionRequest))) + if err != nil || !proto.Equal(reset.Msg, replayedReset.Msg) { + t.Fatalf("ResetSession replay = %v, %v; want %v", replayedReset, err, reset) + } + assertResetHistory() + + tlsConfig := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone() + tlsConfig.InsecureSkipVerify = true //nolint:gosec // httptest certificate + grpcConn, err := grpc.NewClient(strings.TrimPrefix(server.URL, "https://"), grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + if err != nil { + t.Fatal(err) + } + defer grpcConn.Close() + grpcClient := aop.NewChatServiceClient(grpcConn) + grpcOpened, err := grpcClient.OpenSession(ctx, &aop.OpenSessionRequest{ + RequestId: "grpc-open", SessionId: "grpc-session", Participant: "agent-1", Title: "grpc", + }) + if err != nil || grpcOpened.GetAccepted().GetState() != "open" { + t.Fatalf("gRPC OpenSession through Connect handler = %v, %v", grpcOpened, err) + } +} + +func TestConnectBearerAuthentication(t *testing.T) { + store, err := NewSQLiteStore(t.TempDir() + "/auth.db") + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(ServiceConfig{Store: store}) + defer service.Close() + server := httptest.NewServer(NewHandler(service, nil, nil, nil, nil, "secret")) + defer server.Close() + client := chatconnect.NewSessionServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) + + if _, err := client.ListSessions(context.Background(), connect.NewRequest(&chatpb.ListSessionsRequest{})); connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Fatalf("unauthenticated ListSessions error = %v", err) + } + request := connect.NewRequest(&chatpb.ListSessionsRequest{}) + request.Header().Set("Authorization", "Bearer secret") + if _, err := client.ListSessions(context.Background(), request); err != nil { + t.Fatalf("authenticated ListSessions: %v", err) + } +} + +func TestExternalGoModuleConnectClientEndToEnd(t *testing.T) { + service, pool, stop := newConnectTestService(t) + defer stop() + server := httptest.NewServer(NewHandler(service, pool, nil, nil, nil, "external-secret")) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + clientDir, err := filepath.Abs(filepath.Join("..", "..", "examples", "external-go-client")) + if err != nil { + t.Fatal(err) + } + command := exec.CommandContext(ctx, "go", "run", ".", + "-url", server.URL, + "-token", "external-secret", + "-agent", "agent-1", + "-prompt", "hello from an independent module", + "-timeout", "30s", + ) + command.Dir = clientDir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("external client failed: %v\n%s", err, output) + } + text := string(output) + if !strings.Contains(text, "done") || !strings.Contains(text, "stop=completed") { + t.Fatalf("external client output = %q", text) + } +} + +func newConnectTestService(t *testing.T) (*Service, *AgentPool, func()) { + t.Helper() + store, err := NewSQLiteStore(t.TempDir() + "/connect.db") + if err != nil { + t.Fatal(err) + } + service := NewService(ServiceConfig{Store: store}) + pool := NewAgentPool(service.Hub()) + service.SetAgentPool(pool) + fake := &remoteAgent{ + id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 32), controlCh: make(chan *transport.ServerFrame, 32), + tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: make(map[string]struct{}), + childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), + } + pool.agents[fake.id] = fake + stop := make(chan struct{}) + go func() { + for { + select { + case frame := <-fake.sendCh: + respondToConnectTestFrame(pool, fake, frame) + case frame := <-fake.controlCh: + respondToConnectTestFrame(pool, fake, frame) + case <-stop: + return + } + } + }() + return service, pool, func() { + close(stop) + service.Close() + _ = store.Close() + } +} + +func respondToConnectTestFrame(pool *AgentPool, fake *remoteAgent, frame *transport.ServerFrame) { + if frame == nil { + return + } + switch payload := frame.Payload.(type) { + case *transport.ServerFrame_OpenSession: + request := payload.OpenSession + pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_OpenSession{OpenSession: &aop.OpenSessionResponse{ + RequestId: request.RequestId, Outcome: &aop.OpenSessionResponse_Accepted{Accepted: &aop.Session{Id: request.SessionId, State: "open", Participant: request.Participant, Title: request.Title}}, + }}}) + pool.handleAgentFrame(fake, &transport.AgentFrame{Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + SessionId: request.SessionId, Emitter: fake.name, Payload: &aop.Event_SessionStarted{SessionStarted: &aop.SessionStarted{}}, + }}}) + case *transport.ServerFrame_RunTurn: + request := payload.RunTurn + pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_RunTurn{RunTurn: &aop.RunTurnResponse{ + RequestId: request.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: request.SessionId, TurnId: request.TurnId, State: "running"}}, + }}}) + for _, event := range []*aop.Event{ + {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_TurnStarted{TurnStarted: &aop.TurnStarted{}}}, + {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_Message{Message: proto.Clone(request.Input).(*aop.Message)}}, + {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_Message{Message: &aop.Message{Id: "assistant-1", Role: "assistant", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "done"}}}}}}}, + {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}}, + } { + pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: request.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) + } + case *transport.ServerFrame_CloseSession: + request := payload.CloseSession + pool.handleAgentFrame(fake, &transport.AgentFrame{Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + SessionId: request.SessionId, Emitter: fake.name, Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: request.Reason}}, + }}}) + pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CloseSession{CloseSession: &aop.CloseSessionResponse{ + RequestId: request.RequestId, Outcome: &aop.CloseSessionResponse_Accepted{Accepted: &aop.Session{Id: request.SessionId, State: "closed"}}, + }}}) + case *transport.ServerFrame_CancelTurn: + request := payload.CancelTurn + pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CancelTurn{CancelTurn: &aop.CancelTurnResponse{ + RequestId: request.RequestId, Outcome: &aop.CancelTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: request.SessionId, TurnId: request.TurnId, State: "canceled"}}, + }}}) + } +} diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go index 033cbdb5..543e1835 100644 --- a/pkg/web/eval_forward_test.go +++ b/pkg/web/eval_forward_test.go @@ -1,27 +1,20 @@ package web import ( - "encoding/json" "testing" - "github.com/chainreactors/aiscan/core/aop" - xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" - xeval "github.com/chainreactors/aiscan/core/aop/x/eval" - "github.com/chainreactors/aiscan/pkg/webproto" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" ) type evalSink struct { - sid string - found bool - chatEvents []DomainEvent - aopEvents []aop.Event + sid string + found bool + aopEvents []*aop.Event } func (s *evalSink) TaskSession(string) (string, bool) { return s.sid, s.found } -func (s *evalSink) BroadcastDomainEvent(_ string, event DomainEvent) { - s.chatEvents = append(s.chatEvents, event) -} -func (s *evalSink) BroadcastAOPEvent(_ string, event aop.Event) { +func (s *evalSink) BroadcastAOPEvent(_ string, event *aop.Event) { s.aopEvents = append(s.aopEvents, event) } @@ -31,32 +24,25 @@ func TestForwardAgentEventKeepsEvalOnlyInAOP(t *testing.T) { pool.SetSessionLookup(sink) remote := &remoteAgent{id: "agent-1", name: "worker", tasks: map[string]chan taskResult{}, turns: map[string]int{}} - event := aop.Event{ - Type: aop.TypeStatus, - TS: "2026-07-19T00:00:00Z", - SessionID: "agent-session", - Agent: "test-agent", - Data: mustJSON(aop.StatusData{State: xeval.StateEnd}), + event := &aop.Event{ + SessionId: "agent-session", TurnId: "turn-1", Emitter: "test-agent", + Payload: &aop.Event_Status{Status: &aop.Status{State: ext.EvalStateEnd}}, } - _ = xeval.SetDetail(&event, xeval.Detail{Round: 1, Pass: true, Reason: "found SQLi"}) - _ = xcompact.SetDetail(&event, xcompact.Detail{TokensBefore: 1000, TokensAfter: 400, KeptMessages: 8}) - payload, _ := json.Marshal(event) - pool.forwardAOPEvent(remote, WSMessage{Type: "aop", TurnID: "turn-1", Payload: payload}) + _ = ext.SetEvalDetail(event, ext.EvalDetail{Round: 1, Pass: true, Reason: "found SQLi"}) + _ = ext.SetCompactDetail(event, ext.CompactDetail{TokensBefore: 1000, TokensAfter: 400, KeptMessages: 8}) + pool.forwardAOPFrame(remote, "turn-1", event) - if len(sink.chatEvents) != 0 { - t.Fatalf("AOP metadata was duplicated as chat events: %#v", sink.chatEvents) - } if len(sink.aopEvents) == 0 { t.Fatal("AOP event was not forwarded") } - evalDetail, ok, err := xeval.GetDetail(sink.aopEvents[0]) + evalDetail, ok, err := ext.GetEvalDetail(sink.aopEvents[0]) if err != nil || !ok { - t.Fatalf("eval extension = %#v, %v, %v", sink.aopEvents[0].Ext, ok, err) + t.Fatalf("eval extension = %#v, %v, %v", sink.aopEvents[0].Extensions, ok, err) } if evalDetail.Round != 1 || !evalDetail.Pass || evalDetail.Reason != "found SQLi" { t.Fatalf("eval detail = %#v", evalDetail) } - compactDetail, ok, err := xcompact.GetDetail(sink.aopEvents[0]) + compactDetail, ok, err := ext.GetCompactDetail(sink.aopEvents[0]) if err != nil || !ok || compactDetail.TokensBefore != 1000 || compactDetail.KeptMessages != 8 { t.Fatalf("compact detail = %#v, %v, %v", compactDetail, ok, err) } @@ -66,12 +52,8 @@ func TestForwardStandaloneScanAOPDoesNotCreateChatHistory(t *testing.T) { sink := &evalSink{} pool := NewAgentPool(NewHub()) pool.SetSessionLookup(sink) - event := aop.Event{ - Type: aop.TypeStatus, TS: "2026-07-19T00:00:00Z", - SessionID: "scan-not-chat", Agent: "worker", Data: mustJSON(aop.StatusData{State: "running"}), - } - payload, _ := json.Marshal(event) - pool.forwardAOPEvent(&remoteAgent{}, WSMessage{Type: webproto.TypeAOP, TaskID: "scan-not-chat", Payload: payload}) + event := &aop.Event{SessionId: "scan-not-chat", Emitter: "worker", Payload: &aop.Event_Status{Status: &aop.Status{State: "running"}}} + pool.forwardAOPFrame(&remoteAgent{}, "scan-not-chat", event) if len(sink.aopEvents) != 0 { t.Fatalf("standalone scan AOP was forwarded to chat history: %+v", sink.aopEvents) } diff --git a/pkg/web/grpc.go b/pkg/web/grpc.go new file mode 100644 index 00000000..d4f10b41 --- /dev/null +++ b/pkg/web/grpc.go @@ -0,0 +1,61 @@ +package web + +import ( + "context" + "strings" + + aop "github.com/chainreactors/aiscan/aop" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/aiscan/pkg/web/auth" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func NewGRPCServer(accessKey string, service *Service, pool *AgentPool) *grpc.Server { + server := grpc.NewServer( + grpc.ChainUnaryInterceptor(grpcUnaryAuth(accessKey)), + grpc.ChainStreamInterceptor(grpcStreamAuth(accessKey)), + ) + aop.RegisterChatServiceServer(server, NewAOPChatServer(service)) + scanpb.RegisterScanServiceServer(server, newGRPCScanServer(service)) + transport.RegisterAgentTransportServiceServer(server, NewAgentTransportServer(pool)) + return server +} + +func grpcUnaryAuth(accessKey string) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if !grpcAuthenticated(ctx, accessKey) { + return nil, status.Error(codes.Unauthenticated, "invalid or missing access key") + } + return handler(ctx, req) + } +} + +func grpcStreamAuth(accessKey string) grpc.StreamServerInterceptor { + return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if !grpcAuthenticated(stream.Context(), accessKey) { + return status.Error(codes.Unauthenticated, "invalid or missing access key") + } + return handler(srv, stream) + } +} + +func grpcAuthenticated(ctx context.Context, accessKey string) bool { + if accessKey == "" { + return true + } + values, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + for _, value := range values.Get("authorization") { + parts := strings.Fields(value) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") && auth.AccessKeyMatches(accessKey, parts[1]) { + return true + } + } + return false +} diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 602342e3..52ad3d9e 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -2,15 +2,12 @@ package web import ( "encoding/json" - "errors" - "fmt" "io" "net/http" "strconv" - "strings" "github.com/chainreactors/aiscan/agent/probe" - "github.com/chainreactors/aiscan/pkg/webproto" + config "github.com/chainreactors/aiscan/core/config" ) type Handler struct { @@ -26,13 +23,18 @@ func NewHandler(service *Service, agents *AgentPool, local *LocalAgents, ioaHand } h := &handlerImpl{service: service, agents: agents, ioa: console, accessKey: accessKey} registerAuthRoutes(mux, accessKey) + connectHandler := NewConnectHandler(accessKey, service) + mux.Handle("/aop.ChatService/", connectHandler) + mux.Handle("/aiscan.chat.SessionService/", connectHandler) + mux.Handle("/aiscan.scan.ScanService/", connectHandler) + // Retired REST/SSE protocol roots must not fall through to the SPA and + // masquerade as successful HTML responses. + legacyNotFound := func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } + mux.HandleFunc("/api/chat", legacyNotFound) + mux.HandleFunc("/api/chat/", legacyNotFound) + mux.HandleFunc("/api/scans", legacyNotFound) + mux.HandleFunc("/api/scans/", legacyNotFound) - mux.HandleFunc("POST /api/scans", h.createScan) - mux.HandleFunc("GET /api/scans", h.listScans) - mux.HandleFunc("GET /api/scans/{id}", h.getScan) - mux.HandleFunc("DELETE /api/scans/{id}", h.cancelScan) - mux.HandleFunc("GET /api/scans/{id}/events", h.scanEvents) - mux.HandleFunc("GET /api/scans/{id}/report", h.scanReport) mux.HandleFunc("GET /api/status", h.serviceStatus) mux.HandleFunc("GET /api/config", h.getConfig) mux.HandleFunc("PUT /api/config", h.saveConfig) @@ -53,18 +55,6 @@ func NewHandler(service *Service, agents *AgentPool, local *LocalAgents, ioaHand mux.HandleFunc("POST /api/sco/import", h.importSCONodes) mux.HandleFunc("GET /api/sco/artifacts", h.listSupportedArtifacts) - // Chat session routes - mux.HandleFunc("POST /api/chat/sessions", h.createSession) - mux.HandleFunc("GET /api/chat/sessions", h.listSessions) - mux.HandleFunc("GET /api/chat/sessions/{id}", h.getSession) - mux.HandleFunc("DELETE /api/chat/sessions/{id}", h.deleteSession) - mux.HandleFunc("POST /api/chat/sessions/{id}/messages", h.sendMessage) - mux.HandleFunc("POST /api/chat/sessions/{id}/cancel", h.cancelSession) - mux.HandleFunc("POST /api/chat/sessions/{id}/upload", h.uploadFile) - mux.HandleFunc("GET /api/chat/sessions/{id}/messages", h.listMessages) - mux.HandleFunc("GET /api/chat/sessions/{id}/commands", h.sessionCommands) - mux.HandleFunc("GET /api/chat/sessions/{id}/events", h.sessionEvents) - if agents != nil { mux.HandleFunc("/api/agents/{id}/terminal/ws", func(w http.ResponseWriter, r *http.Request) { agents.HandleTerminalWS(r.PathValue("id"), w, r) @@ -92,7 +82,8 @@ func NewHandler(service *Service, agents *AgentPool, local *LocalAgents, ioaHand func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Connect-Protocol-Version, Connect-Timeout-Ms, Connect-Content-Encoding, Connect-Accept-Encoding, Grpc-Timeout, Grpc-Encoding, Grpc-Accept-Encoding, X-Grpc-Web, X-User-Agent") + w.Header().Set("Access-Control-Expose-Headers", "Connect-Content-Encoding, Grpc-Status, Grpc-Message, Grpc-Status-Details-Bin") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return @@ -144,7 +135,7 @@ func (h *handlerImpl) getConfig(w http.ResponseWriter, r *http.Request) { } func (h *handlerImpl) saveConfig(w http.ResponseWriter, r *http.Request) { - var req webproto.DistributeConfig + var req config.DistributeConfig if err := decodeJSON(r.Body, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -209,7 +200,7 @@ func (h *handlerImpl) listLLMModels(w http.ResponseWriter, r *http.Request) { } func (h *handlerImpl) testConn(w http.ResponseWriter, r *http.Request) { - var cfg webproto.DistributeConfig + var cfg config.DistributeConfig if !decodeOptionalBody(w, r, &cfg) { return } @@ -221,326 +212,6 @@ func (h *handlerImpl) testConn(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } -func (h *handlerImpl) createScan(w http.ResponseWriter, r *http.Request) { - var req ScanRequest - if err := decodeJSON(r.Body, &req); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - job, err := h.service.SubmitScan(r.Context(), req.Target, req.Mode, req.Verify, req.Sniper, req.Deep) - if err != nil { - writeError(w, http.StatusUnprocessableEntity, err.Error()) - return - } - writeJSON(w, http.StatusCreated, job) -} - -func (h *handlerImpl) listScans(w http.ResponseWriter, r *http.Request) { - jobs, err := h.service.ListScans(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if jobs == nil { - jobs = []*ScanJob{} - } - writeJSON(w, http.StatusOK, jobs) -} - -func (h *handlerImpl) getScan(w http.ResponseWriter, r *http.Request) { - job, err := h.service.GetScan(r.Context(), r.PathValue("id")) - if err != nil { - writeError(w, http.StatusNotFound, "scan not found") - return - } - writeJSON(w, http.StatusOK, job) -} - -func (h *handlerImpl) cancelScan(w http.ResponseWriter, r *http.Request) { - if err := h.service.CancelScan(r.PathValue("id")); err != nil { - switch { - case errors.Is(err, ErrScanNotFound): - writeError(w, http.StatusNotFound, ErrScanNotFound.Error()) - case errors.Is(err, ErrScanNotCancelable): - writeError(w, http.StatusConflict, err.Error()) - default: - writeError(w, http.StatusInternalServerError, err.Error()) - } - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "canceled"}) -} - -func (h *handlerImpl) scanEvents(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - err := ServeSSEWithSnapshot(w, r, h.service.Hub(), id, func() ([]HubEvent, error) { - job, err := h.service.GetScan(r.Context(), id) - if err != nil { - return nil, err - } - return []HubEvent{scanSnapshotEvent(job)}, nil - }, "complete", "error") - if err != nil { - writeError(w, http.StatusNotFound, "scan not found") - } -} - -func scanSnapshotEvent(job *ScanJob) HubEvent { - switch job.Status { - case StatusCompleted: - return HubEvent{ - Type: "complete", - Data: mustJSON(map[string]any{"scan_id": job.ID, "status": string(job.Status), "result": job.Result}), - } - case StatusFailed, StatusCanceled: - errMsg := job.Error - if errMsg == "" && job.Status == StatusCanceled { - errMsg = "scan canceled" - } - return HubEvent{ - Type: "error", - Data: mustJSON(map[string]string{"scan_id": job.ID, "status": string(job.Status), "error": errMsg}), - } - default: - return HubEvent{ - Type: "status", - Data: mustJSON(map[string]string{"scan_id": job.ID, "status": string(job.Status), "progress": job.Progress}), - } - } -} - -func (h *handlerImpl) scanReport(w http.ResponseWriter, r *http.Request) { - report, err := h.service.GetReport(r.Context(), r.PathValue("id"), r.URL.Query().Get("lang")) - if err != nil { - writeError(w, http.StatusNotFound, "scan not found") - return - } - if report == "" { - writeError(w, http.StatusNotFound, "report not ready") - return - } - w.Header().Set("Content-Type", "text/markdown; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, report) //nolint:gosec // Content-Type is text/markdown, not HTML -} - -// --- Chat session handlers --- - -func (h *handlerImpl) createSession(w http.ResponseWriter, r *http.Request) { - var req CreateSessionRequest - if r.ContentLength > 0 { - _ = decodeJSON(r.Body, &req) - } - if req.AgentID == "" { - writeError(w, http.StatusBadRequest, "agent_id is required") - return - } - session, err := h.service.CreateSession(r.Context(), req.AgentID, req.Title) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusCreated, session) -} - -func (h *handlerImpl) listSessions(w http.ResponseWriter, r *http.Request) { - sessions, err := h.service.ListSessions(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if sessions == nil { - sessions = []*ChatSession{} - } - writeJSON(w, http.StatusOK, sessions) -} - -func (h *handlerImpl) getSession(w http.ResponseWriter, r *http.Request) { - session, err := h.service.GetSession(r.Context(), r.PathValue("id")) - if err != nil { - writeError(w, http.StatusNotFound, "session not found") - return - } - writeJSON(w, http.StatusOK, session) -} - -func (h *handlerImpl) deleteSession(w http.ResponseWriter, r *http.Request) { - if err := h.service.DeleteSession(r.Context(), r.PathValue("id")); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) -} - -func (h *handlerImpl) sendMessage(w http.ResponseWriter, r *http.Request) { - var req SendMessageRequest - if err := decodeJSON(r.Body, &req); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - if strings.TrimSpace(req.Content) == "" { - writeError(w, http.StatusBadRequest, "content is required") - return - } - opts := webproto.GoalExt{ - EvalCriteria: strings.TrimSpace(req.EvalCriteria), - EvalMaxRounds: req.EvalMaxRounds, - PersistMaxTurns: req.PersistMaxTurns, - } - msg, err := h.service.HandleUserMessage(r.Context(), r.PathValue("id"), req.Content, opts) - if err != nil { - if errors.Is(err, ErrSessionNotFound) { - writeError(w, http.StatusNotFound, ErrSessionNotFound.Error()) - return - } - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusCreated, msg) -} - -// sessionCommands returns the web "/" command menu for a session: hub-scope -// commands merged with the bound agent's reported agent-scope commands (skills -// included). The frontend renders its slash-command popup from this, so the menu -// always reflects what actually works instead of a hand-maintained list. -func (h *handlerImpl) sessionCommands(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, h.service.SessionMenu(r.PathValue("id"))) -} - -func (h *handlerImpl) cancelSession(w http.ResponseWriter, r *http.Request) { - if err := h.service.CancelSession(r.Context(), r.PathValue("id")); err != nil { - writeError(w, http.StatusNotFound, "session not found") - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "paused"}) -} - -const maxUploadSize = 50 << 20 // 50 MB - -var ErrUploadTooLarge = errors.New("uploaded file exceeds the size limit") - -func readMultipartUpload(w http.ResponseWriter, r *http.Request, maxSize int64) (string, []byte, error) { - if maxSize <= 0 { - return "", nil, fmt.Errorf("upload size limit must be positive") - } - r.Body = http.MaxBytesReader(w, r.Body, maxSize+(1<<20)) - if err := r.ParseMultipartForm(maxSize); err != nil { //nolint:gosec // G120: body is bounded above - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { - return "", nil, ErrUploadTooLarge - } - return "", nil, fmt.Errorf("parse multipart form: %w", err) - } - if r.MultipartForm != nil { - defer func() { _ = r.MultipartForm.RemoveAll() }() - } - - file, header, err := r.FormFile("file") - if err != nil { - return "", nil, fmt.Errorf("missing file field: %w", err) - } - defer file.Close() - if header.Size > maxSize { - return "", nil, ErrUploadTooLarge - } - - data, err := io.ReadAll(io.LimitReader(file, maxSize+1)) - if err != nil { - return "", nil, fmt.Errorf("read uploaded file: %w", err) - } - if int64(len(data)) > maxSize { - return "", nil, ErrUploadTooLarge - } - return header.Filename, data, nil -} - -func (h *handlerImpl) uploadFile(w http.ResponseWriter, r *http.Request) { - filename, data, err := readMultipartUpload(w, r, maxUploadSize) - if err != nil { - if errors.Is(err, ErrUploadTooLarge) { - writeError(w, http.StatusRequestEntityTooLarge, ErrUploadTooLarge.Error()) - return - } - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - result, err := h.service.HandleFileUpload(r.Context(), r.PathValue("id"), filename, data) - if err != nil { - if errors.Is(err, ErrSessionNotFound) { - writeError(w, http.StatusNotFound, ErrSessionNotFound.Error()) - return - } - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, result) -} - -func (h *handlerImpl) listMessages(w http.ResponseWriter, r *http.Request) { - before, err := parsePositiveInt64(r.URL.Query().Get("before")) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid before cursor") - return - } - limit := 500 - if value := r.URL.Query().Get("limit"); value != "" { - parsed, parseErr := strconv.Atoi(value) - if parseErr != nil || parsed < 1 || parsed > 500 { - writeError(w, http.StatusBadRequest, "limit must be between 1 and 500") - return - } - limit = parsed - } - page, err := h.service.GetMessagePage(r.Context(), r.PathValue("id"), before, limit) - if err != nil { - if errors.Is(err, ErrSessionNotFound) { - writeError(w, http.StatusNotFound, ErrSessionNotFound.Error()) - return - } - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if page.Items == nil { - page.Items = []*ChatMessage{} - } - writeJSON(w, http.StatusOK, page) -} - -func (h *handlerImpl) sessionEvents(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if _, err := h.service.GetSession(r.Context(), id); err != nil { - writeError(w, http.StatusNotFound, "session not found") - return - } - after, _ := parsePositiveInt64(r.Header.Get("Last-Event-ID")) - err := ServeSSEWithSnapshot(w, r, h.service.Hub(), sessionTopic(id), func() ([]HubEvent, error) { - events, err := h.service.GetAOPEventsAfter(r.Context(), id, after) - if err != nil { - return nil, err - } - initial := make([]HubEvent, 0, len(events)) - for _, event := range events { - initial = append(initial, HubEvent{ID: event.Cursor, Type: "aop", Data: mustJSON(event.Event)}) - } - return initial, nil - }, "_never") - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - } -} - -func parsePositiveInt64(value string) (int64, error) { - if strings.TrimSpace(value) == "" { - return 0, nil - } - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil || parsed < 0 { - return 0, fmt.Errorf("invalid positive integer %q", value) - } - return parsed, nil -} - // ── SCO Nodes ── func (h *handlerImpl) listSCONodes(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go index ead02193..9065d1b8 100644 --- a/pkg/web/llm_probe_test.go +++ b/pkg/web/llm_probe_test.go @@ -9,19 +9,19 @@ import ( "testing" "github.com/chainreactors/aiscan/agent/probe" - "github.com/chainreactors/aiscan/pkg/webproto" + proto "github.com/chainreactors/aiscan/core/config" ) // fakeConfigStore is a minimal in-memory ConfigStore for probe tests. type fakeConfigStore struct { - cfg webproto.DistributeConfig + cfg proto.DistributeConfig } -func (f *fakeConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, webproto.DistributeConfig, error) { +func (f *fakeConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, proto.DistributeConfig, error) { return "config.yaml", true, f.cfg, nil } -func (f *fakeConfigStore) PrepareDistributeConfig(_ context.Context, cfg webproto.DistributeConfig) (*PreparedConfig, error) { +func (f *fakeConfigStore) PrepareDistributeConfig(_ context.Context, cfg proto.DistributeConfig) (*PreparedConfig, error) { return &PreparedConfig{Config: cfg, TargetPath: "config.yaml"}, nil } @@ -97,7 +97,7 @@ func TestTestLLMFallsBackToStoredKey(t *testing.T) { defer srv.Close() store := &fakeConfigStore{} - store.cfg.LLM.Providers = []webproto.LLMProviderConfig{{ID: "default", Provider: "openai", APIKey: "sk-stored"}} + store.cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "default", Provider: "openai", APIKey: "sk-stored"}} svc := NewService(ServiceConfig{ConfigStore: store}) // APIKey left blank: the stored secret must be used. @@ -187,7 +187,7 @@ func TestListLLMModelsFallsBackToStoredKey(t *testing.T) { defer srv.Close() store := &fakeConfigStore{} - store.cfg.LLM.Providers = []webproto.LLMProviderConfig{{ID: "default", Provider: "openai", APIKey: "sk-stored"}} + store.cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "default", Provider: "openai", APIKey: "sk-stored"}} svc := NewService(ServiceConfig{ConfigStore: store}) // APIKey left blank: the stored secret must be used. @@ -213,7 +213,7 @@ func TestListLLMModelsUsesSelectedProfileStoredKey(t *testing.T) { store := &fakeConfigStore{} store.cfg.LLM.ActiveProfile = "primary" - store.cfg.LLM.Providers = []webproto.LLMProviderConfig{ + store.cfg.LLM.Providers = []proto.LLMProviderConfig{ {ID: "primary", Provider: "openai", APIKey: "sk-primary"}, {ID: "secondary", Provider: "openai", APIKey: "sk-secondary"}, } diff --git a/pkg/web/probe.go b/pkg/web/probe.go index d125c7b2..67725b26 100644 --- a/pkg/web/probe.go +++ b/pkg/web/probe.go @@ -5,19 +5,19 @@ import ( "strings" agentprobe "github.com/chainreactors/aiscan/agent/probe" + config "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/probe" - "github.com/chainreactors/aiscan/pkg/webproto" ) // TestConn probes one settings section's external dependencies, resolving blank // secrets against the stored config, then delegates to pkg/probe. Probe failures // live inside the response; a returned error only signals an untestable section. -func (s *Service) TestConn(ctx context.Context, section string, in webproto.DistributeConfig) ([]probe.ConnCheck, error) { +func (s *Service) TestConn(ctx context.Context, section string, in config.DistributeConfig) ([]probe.ConnCheck, error) { stored, _ := s.storedConfig(ctx) return probe.TestConn(ctx, section, toProbeConfig(in), toProbeConfig(stored)) } -func toProbeConfig(dc webproto.DistributeConfig) probe.ProbeConfig { +func toProbeConfig(dc config.DistributeConfig) probe.ProbeConfig { return probe.ProbeConfig{ Cyberhub: probe.CyberhubProbe{URL: dc.Cyberhub.URL, Key: dc.Cyberhub.Key}, Recon: probe.ReconProbe{ @@ -65,13 +65,13 @@ func (s *Service) storedLLMAPIKey(ctx context.Context, profileID string) string // storedConfig returns the config persisted on the server, or ok=false when no // config store is wired or it cannot be read. -func (s *Service) storedConfig(ctx context.Context) (webproto.DistributeConfig, bool) { +func (s *Service) storedConfig(ctx context.Context) (config.DistributeConfig, bool) { if s.config == nil { - return webproto.DistributeConfig{}, false + return config.DistributeConfig{}, false } dc, err := s.GetDistributeConfig(ctx) if err != nil { - return webproto.DistributeConfig{}, false + return config.DistributeConfig{}, false } return dc, true } diff --git a/pkg/web/replay_test.go b/pkg/web/replay_test.go index cbed9506..b84c3f43 100644 --- a/pkg/web/replay_test.go +++ b/pkg/web/replay_test.go @@ -2,16 +2,18 @@ package web import ( "context" - "encoding/json" + "errors" "net/http" "net/http/httptest" "path/filepath" - "strings" "sync" "testing" "time" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) type lockedResponseRecorder struct { @@ -51,9 +53,9 @@ func (r *lockedResponseRecorder) BodyString() string { return r.Body.String() } -// Replay (SQLite → SSE) must be a pure read: no frames to agents, no task -// lifecycle changes, no new events persisted. -func TestSessionEventsReplayHasNoSideEffects(t *testing.T) { +// ListEvents replay is a pure read: it must not dispatch frames, converge an +// in-flight task, or append another copy of a terminal event. +func TestListEventsReplayHasNoSideEffects(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "replay.db")) if err != nil { t.Fatal(err) @@ -62,15 +64,9 @@ func TestSessionEventsReplayHasNoSideEffects(t *testing.T) { pool := NewAgentPool(NewHub()) svc := NewService(ServiceConfig{Store: store, AgentPool: pool}) - h := &handlerImpl{service: svc, agents: pool} - - // A connected agent with an in-flight chat task. remote := &remoteAgent{ - id: "agent-1", - name: "worker", - sendCh: make(chan WSMessage, 8), - tasks: map[string]chan taskResult{}, - turns: map[string]int{}, + id: "agent-1", name: "worker", sendCh: make(chan *transport.ServerFrame, 8), + tasks: map[string]chan taskResult{}, turns: map[string]int{}, } taskCh := make(chan taskResult, 1) remote.tasks["task-1"] = taskCh @@ -81,70 +77,38 @@ func TestSessionEventsReplayHasNoSideEffects(t *testing.T) { if err != nil { t.Fatal(err) } - stored := []aop.Event{ - { - Type: aop.TypeMessage, TS: "2026-07-19T00:00:01Z", SessionID: session.ID, Agent: "aiscan", - Data: mustJSON(aop.MessageData{MessageID: "m-1", Role: "user", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "hi"}}}), - }, - { - Type: aop.TypeToolCall, TS: "2026-07-19T00:00:02Z", SessionID: session.ID, Agent: "aiscan", - Data: mustJSON(aop.ToolCallData{ToolCallID: "tc-1", ToolName: "bash", Args: map[string]string{"command": "ls"}}), - }, - { - Type: aop.TypeTurnEnd, TS: "2026-07-19T00:00:03Z", SessionID: session.ID, TurnID: "turn-1", Agent: "aiscan", - Data: mustJSON(aop.TurnEndData{Stop: "completed"}), - }, - } - for _, ev := range stored { - if err := store.AddAOPEvent(ctx, session.ID, ev); err != nil { + arguments, _ := aop.JSONValue(map[string]string{"command": "ls"}) + stored := []*aop.Event{ + {Id: "e-1", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 1, 0, time.UTC)), SessionId: session.ID, Emitter: "aiscan", + Payload: &aop.Event_Message{Message: &aop.Message{Id: "m-1", Role: "user", Content: []*aop.Content{aop.Text("hi")}}}}, + {Id: "e-2", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 2, 0, time.UTC)), SessionId: session.ID, Emitter: "aiscan", + Payload: &aop.Event_ToolCall{ToolCall: &aop.ToolCall{Id: "tc-1", Name: "bash", Arguments: arguments}}}, + {Id: "e-3", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 3, 0, time.UTC)), SessionId: session.ID, TurnId: "turn-1", Emitter: "aiscan", + Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}}, + } + for _, event := range stored { + if err := store.AddAOPEvent(ctx, session.ID, event); err != nil { t.Fatal(err) } } - before, err := store.ListAOPEvents(ctx, session.ID, 100) + + response, err := NewAOPChatServer(svc).ListEvents(ctx, &aop.ListEventsRequest{SessionId: session.ID, Limit: 100}) if err != nil { t.Fatal(err) } - - reqCtx, cancel := context.WithCancel(context.Background()) - req := httptest.NewRequest("GET", "/api/chat/sessions/"+session.ID+"/events", nil).WithContext(reqCtx) - req.SetPathValue("id", session.ID) - rec := newLockedResponseRecorder() - - done := make(chan struct{}) - go func() { - h.sessionEvents(rec, req) - close(done) - }() - - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if strings.Contains(rec.BodyString(), "turn.end") { - break - } - time.Sleep(10 * time.Millisecond) - } - cancel() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("sessionEvents did not return after request cancel") + if len(response.Events) != len(stored) { + t.Fatalf("replayed events = %d, want %d", len(response.Events), len(stored)) } - - body := rec.BodyString() - for _, ev := range stored { - raw, _ := json.Marshal(ev.Data) - if !strings.Contains(body, string(raw)) { - t.Fatalf("replayed stream is missing %s event data %s", ev.Type, raw) + for index, delivery := range response.Events { + if !proto.Equal(delivery.Event, stored[index]) { + t.Fatalf("delivery %d = %v, want %v", index, delivery.Event, stored[index]) } } - - // No agent frame was produced by the replay. select { - case msg := <-remote.sendCh: - t.Fatalf("replay dispatched a frame to the agent: %+v", msg) + case frame := <-remote.sendCh: + t.Fatalf("replay dispatched a frame: %v", frame) default: } - // The in-flight task was not converged by replayed terminal events. remote.mu.Lock() _, stillRegistered := remote.tasks["task-1"] remote.mu.Unlock() @@ -152,21 +116,17 @@ func TestSessionEventsReplayHasNoSideEffects(t *testing.T) { t.Fatal("replay converged the in-flight task") } select { - case res, ok := <-taskCh: - t.Fatalf("replay wrote to the task channel: res=%+v ok=%v", res, ok) + case result, ok := <-taskCh: + t.Fatalf("replay wrote to task channel: result=%+v ok=%v", result, ok) default: } - // Replay is read-only on the store. after, err := store.ListAOPEvents(ctx, session.ID, 100) - if err != nil { - t.Fatal(err) - } - if len(after) != len(before) { - t.Fatalf("event count changed by replay: before=%d after=%d", len(before), len(after)) + if err != nil || len(after) != len(stored) { + t.Fatalf("stored events after replay = %d, %v", len(after), err) } } -func TestSessionEventsResumesAfterLastEventID(t *testing.T) { +func TestWatchEventsResumesAfterCursor(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "resume.db")) if err != nil { t.Fatal(err) @@ -178,36 +138,28 @@ func TestSessionEventsResumesAfterLastEventID(t *testing.T) { t.Fatal(err) } for seq := 1; seq <= 3; seq++ { - if err := store.AddAOPEvent(context.Background(), session.ID, aop.Event{ - Type: aop.TypeStatus, TS: time.Now().UTC().Format(time.RFC3339Nano), SessionID: session.ID, Agent: "aiscan", - Data: mustJSON(map[string]int{"seq": seq}), + detail, _ := aop.JSONValue(map[string]int{"seq": seq}) + if err := store.AddAOPEvent(context.Background(), session.ID, &aop.Event{ + Id: string(rune('0' + seq)), EmittedAt: timestamppb.Now(), SessionId: session.ID, Emitter: "aiscan", + Payload: &aop.Event_Status{Status: &aop.Status{State: "running", Detail: detail}}, }); err != nil { t.Fatal(err) } } - reqCtx, cancel := context.WithCancel(context.Background()) - req := httptest.NewRequest("GET", "/api/chat/sessions/"+session.ID+"/events", nil).WithContext(reqCtx) - req.Header.Set("Last-Event-ID", "2") - req.SetPathValue("id", session.ID) - recorder := newLockedResponseRecorder() - done := make(chan struct{}) - go func() { - (&handlerImpl{service: svc}).sessionEvents(recorder, req) - close(done) - }() - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) && !strings.Contains(recorder.BodyString(), "id: 3\n") { - time.Sleep(10 * time.Millisecond) - } - cancel() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("sessionEvents did not return after request cancel") - } - body := recorder.BodyString() - if strings.Contains(body, "id: 1\n") || strings.Contains(body, "id: 2\n") || !strings.Contains(body, "id: 3\n") { - t.Fatalf("resume body = %q, want only events after cursor 2", body) + ctx, cancel := context.WithCancel(context.Background()) + var deliveries []*aop.EventDelivery + err = NewAOPChatServer(svc).(*aopChatServer).watchEvents(&aop.WatchEventsRequest{ + SessionId: session.ID, AfterCursor: "2", + }, ctx, func(response *aop.WatchEventsResponse) error { + deliveries = append(deliveries, response.Delivery) + cancel() + return nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("WatchEvents error = %v, want context canceled", err) + } + if len(deliveries) != 1 || deliveries[0].Cursor != "3" || deliveries[0].Event.Id != "3" { + t.Fatalf("resumed deliveries = %v, want only cursor 3", deliveries) } } diff --git a/pkg/web/scan_connect.go b/pkg/web/scan_connect.go new file mode 100644 index 00000000..6d2bd0ac --- /dev/null +++ b/pkg/web/scan_connect.go @@ -0,0 +1,64 @@ +package web + +import ( + "context" + + "connectrpc.com/connect" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + "github.com/chainreactors/aiscan/aop/aiscan/scan/scanconnect" +) + +type connectScanServer struct { + scanconnect.UnimplementedScanServiceHandler + core *scanServiceCore +} + +func newConnectScanServer(service *Service) scanconnect.ScanServiceHandler { + return &connectScanServer{core: newScanServiceCore(service)} +} + +func (s *connectScanServer) SubmitScan(ctx context.Context, req *connect.Request[scanpb.SubmitScanRequest]) (*connect.Response[scanpb.SubmitScanResponse], error) { + response, err := s.core.SubmitScan(ctx, req.Msg) + if err != nil { + return nil, asConnectScanError(err) + } + return connect.NewResponse(response), nil +} + +func (s *connectScanServer) GetScan(ctx context.Context, req *connect.Request[scanpb.GetScanRequest]) (*connect.Response[scanpb.GetScanResponse], error) { + response, err := s.core.GetScan(ctx, req.Msg) + if err != nil { + return nil, asConnectScanError(err) + } + return connect.NewResponse(response), nil +} + +func (s *connectScanServer) ListScans(ctx context.Context, req *connect.Request[scanpb.ListScansRequest]) (*connect.Response[scanpb.ListScansResponse], error) { + response, err := s.core.ListScans(ctx, req.Msg) + if err != nil { + return nil, asConnectScanError(err) + } + return connect.NewResponse(response), nil +} + +func (s *connectScanServer) CancelScan(ctx context.Context, req *connect.Request[scanpb.CancelScanRequest]) (*connect.Response[scanpb.CancelScanResponse], error) { + response, err := s.core.CancelScan(ctx, req.Msg) + if err != nil { + return nil, asConnectScanError(err) + } + return connect.NewResponse(response), nil +} + +func (s *connectScanServer) WatchScanEvents(ctx context.Context, req *connect.Request[scanpb.WatchScanEventsRequest], stream *connect.ServerStream[scanpb.WatchScanEventsResponse]) error { + return asConnectScanError(s.core.WatchScanEvents(req.Msg, ctx, stream.Send)) +} + +func (s *connectScanServer) GetScanReport(ctx context.Context, req *connect.Request[scanpb.GetScanReportRequest]) (*connect.Response[scanpb.GetScanReportResponse], error) { + response, err := s.core.GetScanReport(ctx, req.Msg) + if err != nil { + return nil, asConnectScanError(err) + } + return connect.NewResponse(response), nil +} + +var _ scanconnect.ScanServiceHandler = (*connectScanServer)(nil) diff --git a/pkg/web/scan_grpc.go b/pkg/web/scan_grpc.go new file mode 100644 index 00000000..8821427b --- /dev/null +++ b/pkg/web/scan_grpc.go @@ -0,0 +1,44 @@ +package web + +import ( + "context" + + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" +) + +type grpcScanServer struct { + scanpb.UnimplementedScanServiceServer + core *scanServiceCore +} + +func newGRPCScanServer(service *Service) scanpb.ScanServiceServer { + return &grpcScanServer{core: newScanServiceCore(service)} +} + +func (s *grpcScanServer) SubmitScan(ctx context.Context, req *scanpb.SubmitScanRequest) (*scanpb.SubmitScanResponse, error) { + return s.core.SubmitScan(ctx, req) +} + +func (s *grpcScanServer) GetScan(ctx context.Context, req *scanpb.GetScanRequest) (*scanpb.GetScanResponse, error) { + return s.core.GetScan(ctx, req) +} + +func (s *grpcScanServer) ListScans(ctx context.Context, req *scanpb.ListScansRequest) (*scanpb.ListScansResponse, error) { + return s.core.ListScans(ctx, req) +} + +func (s *grpcScanServer) CancelScan(ctx context.Context, req *scanpb.CancelScanRequest) (*scanpb.CancelScanResponse, error) { + return s.core.CancelScan(ctx, req) +} + +func (s *grpcScanServer) WatchScanEvents(req *scanpb.WatchScanEventsRequest, stream scanpb.ScanService_WatchScanEventsServer) error { + return s.core.WatchScanEvents(req, stream.Context(), func(response *scanpb.WatchScanEventsResponse) error { + return stream.Send(response) + }) +} + +func (s *grpcScanServer) GetScanReport(ctx context.Context, req *scanpb.GetScanReportRequest) (*scanpb.GetScanReportResponse, error) { + return s.core.GetScanReport(ctx, req) +} + +var _ scanpb.ScanServiceServer = (*grpcScanServer)(nil) diff --git a/pkg/web/scan_lifecycle_test.go b/pkg/web/scan_lifecycle_test.go index 4c00ca3c..20de65eb 100644 --- a/pkg/web/scan_lifecycle_test.go +++ b/pkg/web/scan_lifecycle_test.go @@ -10,8 +10,10 @@ import ( "testing" "time" + aop "github.com/chainreactors/aiscan/aop" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/webproto" ) func waitScanStatus(t *testing.T, store *SQLiteStore, id string, want ScanStatus) *ScanJob { @@ -50,11 +52,8 @@ func TestCancelRemoteScanStopsAgentAndPreservesCanceledStatus(t *testing.T) { t.Fatal(err) } - var call webproto.Message - if err := conn.ReadJSON(&call); err != nil { - t.Fatal(err) - } - if call.Type != webproto.TypeAOP || call.TaskID != job.ID { + call := readServerFrame(t, conn) + if call.GetToolCall().GetTaskId() != job.ID { t.Fatalf("scan dispatch = %+v", call) } waitScanStatus(t, store, job.ID, StatusRunning) @@ -63,11 +62,8 @@ func TestCancelRemoteScanStopsAgentAndPreservesCanceledStatus(t *testing.T) { t.Fatal(err) } _ = conn.SetReadDeadline(time.Now().Add(time.Second)) - var cancel webproto.Message - if err := conn.ReadJSON(&cancel); err != nil { - t.Fatalf("agent did not receive scan cancellation: %v", err) - } - if cancel.Type != "cancel" || cancel.TaskID != job.ID { + cancel := readServerFrame(t, conn) + if cancel.GetCancelOperation().GetTaskId() != job.ID { t.Fatalf("cancel frame = %+v", cancel) } @@ -81,10 +77,10 @@ func TestCancelRemoteScanStopsAgentAndPreservesCanceledStatus(t *testing.T) { } // A result that races with cancellation must not resurrect the scan. - resultJSON, _ := json.Marshal(&output.Result{}) - pool.handleAgentMessage(pool.Pick(), webproto.Message{ - Type: "complete", TaskID: job.ID, Payload: resultJSON, - }) + resultJSON, _ := aop.JSONValue(&output.Result{}) + pool.handleAgentFrame(pool.Pick(), &transport.AgentFrame{CorrelationId: job.ID, Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + SessionId: job.ID, TurnId: job.ID, Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{CallId: job.ID, Detail: resultJSON}}, + }}}) time.Sleep(20 * time.Millisecond) if got, err := store.Get(context.Background(), job.ID); err != nil || got.Status != StatusCanceled { t.Fatalf("late result changed canceled scan: job=%+v err=%v", got, err) @@ -110,10 +106,7 @@ func TestCancelQueuedScanDoesNotWaitForConcurrencySlot(t *testing.T) { if err != nil { t.Fatal(err) } - var call webproto.Message - if err := conn.ReadJSON(&call); err != nil { - t.Fatal(err) - } + _ = readServerFrame(t, conn) waitScanStatus(t, store, running.ID, StatusRunning) queued, err := svc.SubmitScan(context.Background(), "127.0.0.2", "quick", false, false, false) @@ -130,10 +123,7 @@ func TestCancelQueuedScanDoesNotWaitForConcurrencySlot(t *testing.T) { t.Fatal(err) } _ = conn.SetReadDeadline(time.Now().Add(time.Second)) - var cancel webproto.Message - if err := conn.ReadJSON(&cancel); err != nil { - t.Fatal(err) - } + _ = readServerFrame(t, conn) waitScanStatus(t, store, running.ID, StatusCanceled) } @@ -189,24 +179,24 @@ func TestRemoteScanTimeoutCancelsAgentAndFailsScan(t *testing.T) { close(done) }() - var call webproto.Message + var call *transport.ServerFrame select { case call = <-agent.sendCh: case <-time.After(time.Second): t.Fatal("agent did not receive scan dispatch") } - if call.Type != webproto.TypeAOP || call.TaskID != jobID { + if call.GetToolCall().GetTaskId() != jobID { t.Fatalf("scan dispatch = %+v", call) } ctx.expire() - var cancel webproto.Message + var cancel *transport.ServerFrame select { case cancel = <-agent.controlCh: case <-time.After(time.Second): t.Fatal("agent did not receive timeout cancellation") } - if cancel.Type != "cancel" || cancel.TaskID != jobID { + if cancel.GetCancelOperation().GetTaskId() != jobID { t.Fatalf("timeout cancel frame = %+v", cancel) } select { @@ -270,7 +260,7 @@ func TestCancelTaskUsesControlChannelWhenTaskQueueIsFull(t *testing.T) { remote := newFakeAgent("agent-1", 1) remote.toolCalls = map[string]struct{}{"scan-1": {}} remote.tasks["scan-1"] = make(chan taskResult, 1) - remote.sendCh <- webproto.Message{Type: "busy"} + remote.sendCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_Exec{Exec: &transport.ExecRequest{TaskId: "busy"}}} pool.agents[remote.id] = remote if err := pool.CancelTask(remote.id, "scan-1"); err != nil { @@ -278,7 +268,7 @@ func TestCancelTaskUsesControlChannelWhenTaskQueueIsFull(t *testing.T) { } select { case msg := <-remote.controlCh: - if msg.Type != "cancel" || msg.TaskID != "scan-1" { + if msg.GetCancelOperation().GetTaskId() != "scan-1" { t.Fatalf("control cancellation = %+v", msg) } default: @@ -292,7 +282,7 @@ func TestCancelTaskWaitsForSaturatedControlChannel(t *testing.T) { remote.toolCalls = map[string]struct{}{"scan-1": {}} resultCh := make(chan taskResult, 1) remote.tasks["scan-1"] = resultCh - remote.controlCh <- webproto.Message{Type: "config"} + remote.controlCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_ReloadConfig{ReloadConfig: &transport.ReloadConfig{}}} pool.agents[remote.id] = remote if err := pool.CancelTask(remote.id, "scan-1"); err != nil { @@ -310,7 +300,7 @@ func TestCancelTaskWaitsForSaturatedControlChannel(t *testing.T) { <-remote.controlCh select { case msg := <-remote.controlCh: - if msg.Type != "cancel" || msg.TaskID != "scan-1" { + if msg.GetCancelOperation().GetTaskId() != "scan-1" { t.Fatalf("queued cancellation = %+v", msg) } case <-time.After(time.Second): @@ -389,13 +379,14 @@ func TestCancelCompletedScanReturnsConflictAndPreservesStatus(t *testing.T) { t.Fatal(err) } - handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodDelete, "/api/scans/"+job.ID, nil) - handler.ServeHTTP(recorder, request) - - if recorder.Code != http.StatusConflict { - t.Fatalf("DELETE completed scan status = %d, body = %s; want %d", recorder.Code, recorder.Body.String(), http.StatusConflict) + response, err := newScanServiceCore(NewService(ServiceConfig{Store: store})).CancelScan(context.Background(), &scanpb.CancelScanRequest{ + RequestId: "cancel-completed", ScanId: job.ID, + }) + if err != nil { + t.Fatal(err) + } + if response.GetRejected().GetCode() != "FAILED_PRECONDITION" { + t.Fatalf("CancelScan rejection = %+v; want FAILED_PRECONDITION", response.GetRejected()) } stored, err := store.Get(context.Background(), job.ID) if err != nil { @@ -413,12 +404,13 @@ func TestCancelMissingScanReturnsNotFound(t *testing.T) { } t.Cleanup(func() { _ = store.Close() }) - handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodDelete, "/api/scans/missing", nil) - handler.ServeHTTP(recorder, request) - - if recorder.Code != http.StatusNotFound { - t.Fatalf("DELETE missing scan status = %d, body = %s; want %d", recorder.Code, recorder.Body.String(), http.StatusNotFound) + response, err := newScanServiceCore(NewService(ServiceConfig{Store: store})).CancelScan(context.Background(), &scanpb.CancelScanRequest{ + RequestId: "cancel-missing", ScanId: "missing", + }) + if err != nil { + t.Fatal(err) + } + if response.GetRejected().GetCode() != "NOT_FOUND" { + t.Fatalf("CancelScan rejection = %+v; want NOT_FOUND", response.GetRejected()) } } diff --git a/pkg/web/scan_rpc.go b/pkg/web/scan_rpc.go new file mode 100644 index 00000000..eb80a53c --- /dev/null +++ b/pkg/web/scan_rpc.go @@ -0,0 +1,231 @@ +package web + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type scanServiceCore struct { + service *Service +} + +func newScanServiceCore(service *Service) *scanServiceCore { + return &scanServiceCore{service: service} +} + +func (s *scanServiceCore) SubmitScan(ctx context.Context, request *scanpb.SubmitScanRequest) (*scanpb.SubmitScanResponse, error) { + if s.service == nil || request == nil || strings.TrimSpace(request.RequestId) == "" { + return rejectedSubmitScan(request, codes.InvalidArgument, "request_id is required"), nil + } + options := request.GetOptions() + job, err := s.service.SubmitScan(ctx, request.Target, request.Mode, options.GetVerify(), options.GetSniper(), options.GetDeep()) + if err != nil { + return rejectedSubmitScan(request, codes.InvalidArgument, err.Error()), nil + } + return &scanpb.SubmitScanResponse{RequestId: request.RequestId, Outcome: &scanpb.SubmitScanResponse_Accepted{Accepted: scanToProto(job)}}, nil +} + +func (s *scanServiceCore) GetScan(ctx context.Context, request *scanpb.GetScanRequest) (*scanpb.GetScanResponse, error) { + if s.service == nil || request == nil || strings.TrimSpace(request.ScanId) == "" { + return nil, status.Error(codes.InvalidArgument, "scan_id is required") + } + job, err := s.service.GetScan(ctx, request.ScanId) + if err != nil { + return nil, scanRPCError(err) + } + return &scanpb.GetScanResponse{Scan: scanToProto(job)}, nil +} + +func (s *scanServiceCore) ListScans(ctx context.Context, _ *scanpb.ListScansRequest) (*scanpb.ListScansResponse, error) { + if s.service == nil { + return nil, status.Error(codes.Unavailable, "scan service is unavailable") + } + jobs, err := s.service.ListScans(ctx) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + response := &scanpb.ListScansResponse{Scans: make([]*scanpb.Scan, 0, len(jobs))} + for _, job := range jobs { + response.Scans = append(response.Scans, scanToProto(job)) + } + return response, nil +} + +func (s *scanServiceCore) CancelScan(ctx context.Context, request *scanpb.CancelScanRequest) (*scanpb.CancelScanResponse, error) { + if s.service == nil || request == nil || strings.TrimSpace(request.RequestId) == "" || strings.TrimSpace(request.ScanId) == "" { + return rejectedCancelScan(request, codes.InvalidArgument, "request_id and scan_id are required"), nil + } + if err := s.service.CancelScan(request.ScanId); err != nil { + code := codes.FailedPrecondition + if errors.Is(err, ErrScanNotFound) { + code = codes.NotFound + } + return rejectedCancelScan(request, code, err.Error()), nil + } + job, err := s.service.GetScan(ctx, request.ScanId) + if err != nil { + return nil, scanRPCError(err) + } + return &scanpb.CancelScanResponse{RequestId: request.RequestId, Outcome: &scanpb.CancelScanResponse_Accepted{Accepted: scanToProto(job)}}, nil +} + +func (s *scanServiceCore) WatchScanEvents(request *scanpb.WatchScanEventsRequest, ctx context.Context, send func(*scanpb.WatchScanEventsResponse) error) error { + if s.service == nil || request == nil || strings.TrimSpace(request.ScanId) == "" { + return status.Error(codes.InvalidArgument, "scan_id is required") + } + if send == nil { + return status.Error(codes.Internal, "scan event sender is unavailable") + } + live, snapshotSequence, unsubscribe := s.service.hub.SubscribeScan(request.ScanId) + defer unsubscribe() + job, err := s.service.GetScan(ctx, request.ScanId) + if err != nil { + return scanRPCError(err) + } + snapshot := scanSnapshot(job, snapshotSequence) + if err := send(&scanpb.WatchScanEventsResponse{Event: snapshot}); err != nil { + return err + } + if scanTerminal(job.Status) { + return nil + } + last := snapshot.Sequence + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event, ok := <-live: + if !ok { + return nil + } + if event == nil || event.Sequence <= last { + continue + } + if err := send(&scanpb.WatchScanEventsResponse{Event: event}); err != nil { + return err + } + last = event.Sequence + if event.GetCompleted() != nil || event.GetFailed() != nil { + return nil + } + } + } +} + +func (s *scanServiceCore) GetScanReport(ctx context.Context, request *scanpb.GetScanReportRequest) (*scanpb.GetScanReportResponse, error) { + if s.service == nil || request == nil || strings.TrimSpace(request.ScanId) == "" { + return nil, status.Error(codes.InvalidArgument, "scan_id is required") + } + markdown, err := s.service.GetReport(ctx, request.ScanId, request.Language) + if err != nil { + return nil, scanRPCError(err) + } + if markdown == "" { + return nil, status.Error(codes.FailedPrecondition, "scan report is not ready") + } + return &scanpb.GetScanReportResponse{Markdown: markdown, MediaType: "text/markdown; charset=utf-8"}, nil +} + +func scanToProto(job *ScanJob) *scanpb.Scan { + if job == nil { + return nil + } + var result *aop.EncodedValue + if job.Result != nil { + result, _ = aop.JSONValue(job.Result) + } + return &scanpb.Scan{ + Id: job.ID, Target: job.Target, Mode: job.Mode, + Options: &scanpb.ScanOptions{Verify: job.Verify, Sniper: job.Sniper, Deep: job.Deep}, + Status: scanStatusToProto(job.Status), Progress: job.Progress, Report: job.Report, + Result: result, Error: job.Error, + CreatedAt: timestamppb.New(job.CreatedAt), UpdatedAt: timestamppb.New(job.UpdatedAt), + } +} + +func scanStatusToProto(value ScanStatus) scanpb.ScanStatus { + switch value { + case StatusQueued: + return scanpb.ScanStatus_SCAN_STATUS_QUEUED + case StatusRunning: + return scanpb.ScanStatus_SCAN_STATUS_RUNNING + case StatusCompleted: + return scanpb.ScanStatus_SCAN_STATUS_COMPLETED + case StatusFailed: + return scanpb.ScanStatus_SCAN_STATUS_FAILED + case StatusCanceled: + return scanpb.ScanStatus_SCAN_STATUS_CANCELED + default: + return scanpb.ScanStatus_SCAN_STATUS_UNSPECIFIED + } +} + +func scanSnapshot(job *ScanJob, sequence uint64) *scanpb.ScanEvent { + return &scanpb.ScanEvent{ScanId: job.ID, Sequence: sequence, EmittedAt: timestamppb.Now(), Payload: &scanpb.ScanEvent_Snapshot{Snapshot: scanToProto(job)}} +} + +func scanStatusEvent(scanID string, value ScanStatus) *scanpb.ScanEvent { + return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Status{Status: scanStatusToProto(value)}} +} + +func scanProgressEvent(scanID, data string) *scanpb.ScanEvent { + return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Progress{Progress: &scanpb.ScanProgress{Data: data}}} +} + +func scanCompletedEvent(scanID string, result any) *scanpb.ScanEvent { + encoded, _ := aop.JSONValue(result) + return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Completed{Completed: &scanpb.ScanCompleted{Result: encoded}}} +} + +func scanFailedEvent(scanID, message string, canceled bool) *scanpb.ScanEvent { + return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Failed{Failed: &scanpb.ScanFailed{Message: message, Canceled: canceled}}} +} + +func scanTerminal(value ScanStatus) bool { + return value == StatusCompleted || value == StatusFailed || value == StatusCanceled +} + +func rejectedSubmitScan(request *scanpb.SubmitScanRequest, code codes.Code, message string) *scanpb.SubmitScanResponse { + response := &scanpb.SubmitScanResponse{Outcome: &scanpb.SubmitScanResponse_Rejected{Rejected: rejection(code, message)}} + if request != nil { + response.RequestId = request.RequestId + } + return response +} + +func rejectedCancelScan(request *scanpb.CancelScanRequest, code codes.Code, message string) *scanpb.CancelScanResponse { + response := &scanpb.CancelScanResponse{Outcome: &scanpb.CancelScanResponse_Rejected{Rejected: rejection(code, message)}} + if request != nil { + response.RequestId = request.RequestId + } + return response +} + +func scanRPCError(err error) error { + switch { + case errors.Is(err, ErrScanNotFound), errors.Is(err, sql.ErrNoRows): + return status.Error(codes.NotFound, ErrScanNotFound.Error()) + default: + return status.Error(codes.Internal, fmt.Sprint(err)) + } +} + +func asConnectScanError(err error) error { + if err == nil { + return nil + } + if grpcStatus, ok := status.FromError(err); ok { + return connect.NewError(connect.Code(grpcStatus.Code()), errors.New(grpcStatus.Message())) + } + return connect.NewError(connect.CodeInternal, err) +} diff --git a/pkg/web/service.go b/pkg/web/service.go index aee07d76..bc4e53fc 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -5,7 +5,6 @@ import ( "context" "crypto/rand" "database/sql" - "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -17,30 +16,29 @@ import ( "sync" "time" - "github.com/chainreactors/aiscan/core/aop" - xcompact "github.com/chainreactors/aiscan/core/aop/x/compact" - xeval "github.com/chainreactors/aiscan/core/aop/x/eval" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/tui" - "github.com/chainreactors/aiscan/pkg/webproto" scantool "github.com/chainreactors/aiscan/tools/scan" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) -// hubCommands are the 3 commands that run on the web hub, not the agent. -var hubCommands = map[string]bool{"scan": true, "agents": true, "help": true} - type ConfigStore interface { - GetDistributeConfig(ctx context.Context) (path string, loaded bool, cfg webproto.DistributeConfig, err error) - PrepareDistributeConfig(ctx context.Context, cfg webproto.DistributeConfig) (*PreparedConfig, error) + GetDistributeConfig(ctx context.Context) (path string, loaded bool, cfg config.DistributeConfig, err error) + PrepareDistributeConfig(ctx context.Context, cfg config.DistributeConfig) (*PreparedConfig, error) CommitDistributeConfig(ctx context.Context, prepared *PreparedConfig) error DiscardDistributeConfig(prepared *PreparedConfig) } type PreparedConfig struct { - Config webproto.DistributeConfig + Config config.DistributeConfig RuntimePath string TargetPath string } @@ -73,6 +71,10 @@ type Service struct { taskSessions map[string]string // taskID → sessionID taskAgents map[string]string // taskID → agentID taskCanceled map[string]bool + + eventMu sync.Mutex + sessionSeq map[string]uint64 + endedTurns map[string]bool } type managedApp struct { @@ -105,6 +107,8 @@ func NewService(cfg ServiceConfig) *Service { taskSessions: make(map[string]string), taskAgents: make(map[string]string), taskCanceled: make(map[string]bool), + sessionSeq: make(map[string]uint64), + endedTurns: make(map[string]bool), } if cfg.AgentPool != nil { cfg.AgentPool.SetSessionLookup(svc) @@ -182,7 +186,7 @@ func (s *Service) GetConfigStatus(ctx context.Context) (ConfigStatus, error) { return ConfigStatusFromDistribute(&dc, path, loaded), nil } -func (s *Service) SaveConfig(ctx context.Context, cfg webproto.DistributeConfig) (ConfigStatus, error) { +func (s *Service) SaveConfig(ctx context.Context, cfg config.DistributeConfig) (ConfigStatus, error) { s.saveMu.Lock() defer s.saveMu.Unlock() if s.config == nil { @@ -262,9 +266,9 @@ func (s *Service) ActivateLLMProfile(ctx context.Context, id string) (ConfigStat return s.SaveConfig(ctx, cfg) } -func (s *Service) GetDistributeConfig(ctx context.Context) (webproto.DistributeConfig, error) { +func (s *Service) GetDistributeConfig(ctx context.Context) (config.DistributeConfig, error) { if s.config == nil { - return webproto.DistributeConfig{}, fmt.Errorf("config store is not configured") + return config.DistributeConfig{}, fmt.Errorf("config store is not configured") } _, _, dc, err := s.config.GetDistributeConfig(ctx) return dc, err @@ -380,11 +384,7 @@ func (s *Service) CancelScan(id string) error { if cancel != nil { cancel() } - s.hub.Broadcast(id, HubEvent{ - Type: "error", - Data: mustJSON(map[string]string{"scan_id": id, "status": string(StatusCanceled), "error": "scan canceled"}), - Reliable: true, - }) + s.hub.BroadcastScan(scanFailedEvent(id, "scan canceled", true), true) if agentID != "" && s.agents != nil { _ = s.agents.CancelTask(agentID, id) } @@ -442,10 +442,7 @@ func (s *Service) runScan(runCtx context.Context, jobID string) { return } - s.hub.Broadcast(jobID, HubEvent{ - Type: "status", - Data: mustJSON(map[string]string{"scan_id": jobID, "status": string(StatusRunning)}), - }) + s.hub.BroadcastScan(scanStatusEvent(jobID, StatusRunning), false) // Try agent dispatch first, fall back to local execution. if s.agents != nil && s.agents.Count() > 0 { @@ -470,10 +467,9 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { } cmd := "scan " + strings.Join(scanArgsForJob(job), " ") - resultCh, err := s.agents.DispatchToolCall(agent.id, job.ID, aop.ToolCallData{ - ToolCallID: job.ID, - ToolName: "bash", - Args: map[string]any{"command": cmd}, + args, _ := aop.JSONValue(map[string]any{"command": cmd}) + resultCh, err := s.agents.DispatchToolCall(agent.id, job.ID, &aop.ToolCall{ + Id: job.ID, Name: "bash", Kind: "function", Arguments: args, }) if err != nil { _, _ = s.failJob(job, err.Error()) @@ -519,7 +515,7 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { } func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) { - streamWriter := &sseStreamWriter{ + streamWriter := &scanStreamWriter{ hub: s.hub, scanID: job.ID, store: s.store, @@ -601,12 +597,8 @@ func (s *Service) completeJob(ctx context.Context, job *ScanJob, agentID string, if len(result.Nodes) > 0 { _ = s.store.UpsertSCONodes(ctx, job.ID, result.Nodes) } - s.hub.Broadcast(job.ID, HubEvent{ - Type: "complete", - Data: mustJSON(map[string]any{"scan_id": job.ID, "status": "completed", "result": result}), - Reliable: true, - }) - s.broadcastScanComplete(job.ID, result) + s.hub.BroadcastScan(scanCompletedEvent(job.ID, result), true) + s.broadcastScanComplete(job.ID) return true, nil } @@ -620,11 +612,7 @@ func (s *Service) failJob(job *ScanJob, errMsg string) (bool, error) { return changed, err } *job = next - s.hub.Broadcast(job.ID, HubEvent{ - Type: "error", - Data: mustJSON(map[string]string{"scan_id": job.ID, "error": errMsg}), - Reliable: true, - }) + s.hub.BroadcastScan(scanFailedEvent(job.ID, errMsg, false), true) return true, nil } @@ -750,7 +738,7 @@ func (s *Service) executeScan(ctx context.Context, args []string, stream io.Writ return text.String(), result, nil } -type sseStreamWriter struct { +type scanStreamWriter struct { hub *Hub scanID string store *SQLiteStore @@ -759,7 +747,7 @@ type sseStreamWriter struct { buf []byte } -func (w *sseStreamWriter) Write(p []byte) (int, error) { +func (w *scanStreamWriter) Write(p []byte) (int, error) { if w.ctx != nil { select { case <-w.ctx.Done(): @@ -801,10 +789,7 @@ func (w *sseStreamWriter) Write(p []byte) (int, error) { } w.job = current - w.hub.Broadcast(w.scanID, HubEvent{ - Type: "progress", - Data: mustJSON(map[string]string{"scan_id": w.scanID, "data": line}), - }) + w.hub.BroadcastScan(scanProgressEvent(w.scanID, line), false) } return len(p), nil } @@ -828,10 +813,6 @@ func lastOutputLine(s string) string { // --- Chat session service methods --- -func sessionTopic(id string) string { - return "session:" + id -} - func (s *Service) TaskSession(taskID string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -886,7 +867,7 @@ func (s *Service) CancelSession(ctx context.Context, sessionID string) error { if s.agents != nil { for _, task := range tasks { if task.agentID != "" { - _ = s.agents.CancelTask(task.agentID, task.taskID) + _ = s.agents.CancelTask(task.agentID, task.taskID, sessionID) } } } @@ -894,7 +875,39 @@ func (s *Service) CancelSession(ctx context.Context, sessionID string) error { return nil } -func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename string, data []byte) (*webproto.FileUploadResult, error) { +func (s *Service) CancelTurn(ctx context.Context, sessionID, turnID string) error { + if _, err := s.store.GetSession(ctx, sessionID); err != nil { + return err + } + turnID = strings.TrimSpace(turnID) + if turnID == "" { + return ErrTurnNotFound + } + s.mu.Lock() + sid, pending := s.taskSessions[turnID] + agentID := s.taskAgents[turnID] + if pending && sid == sessionID { + s.taskCanceled[turnID] = true + } else { + pending = false + } + s.mu.Unlock() + if !pending { + return ErrTurnNotFound + } + if s.agents != nil && agentID != "" { + if err := s.agents.CancelTask(agentID, turnID, sessionID); err != nil { + return err + } + } + s.BroadcastAOPEvent(sessionID, &aop.Event{ + SessionId: sessionID, TurnId: turnID, Emitter: "aiscan.web", + Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "canceled"}}, + }) + return nil +} + +func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename string, data []byte) (*transport.FileResult, error) { session, err := s.store.GetSession(ctx, sessionID) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -910,23 +923,14 @@ func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename stri return nil, fmt.Errorf("session has no assigned agent") } - payload := webproto.FileUploadPayload{ - Filename: filename, - FileSize: int64(len(data)), - MimeType: http.DetectContentType(data), - SessionID: sessionID, - } - payloadJSON, _ := json.Marshal(payload) - taskID := generateID() - msg := webproto.Message{ - Type: "upload", - TaskID: taskID, - DataB64: base64.StdEncoding.EncodeToString(data), - Payload: payloadJSON, - } - - resultCh, err := s.agents.dispatchMessage(agentID, taskID, msg) + resultCh, err := s.agents.dispatchFrame(agentID, taskID, &transport.ServerFrame{ + CorrelationId: taskID, + Payload: &transport.ServerFrame_FileUpload{FileUpload: &transport.FileUploadRequest{ + TaskId: taskID, SessionId: sessionID, Filename: filename, + MediaType: http.DetectContentType(data), Data: data, + }}, + }) if err != nil { return nil, fmt.Errorf("agent dispatch failed: %w", err) } @@ -936,17 +940,14 @@ func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename stri if !ok { return nil, fmt.Errorf("agent disconnected during upload") } - var result webproto.FileUploadResult - if len(res.Result) == 0 || json.Unmarshal(res.Result, &result) != nil { + result := res.File + if result == nil { return nil, fmt.Errorf("agent upload returned no result envelope") } - if result.Error != "" { - return nil, fmt.Errorf("agent upload error: %s", result.Error) - } s.broadcastSystemMessage(sessionID, SysFileUploaded, fmt.Sprintf("File uploaded: %s → %s", filename, result.Path), map[string]any{"filename": filename, "path": result.Path}) - return &result, nil + return result, nil case <-ctx.Done(): _ = s.agents.CancelTask(agentID, taskID) return nil, ctx.Err() @@ -989,42 +990,11 @@ func (s *Service) DeleteSession(ctx context.Context, id string) error { return s.store.DeleteSession(ctx, id) } -func (s *Service) GetMessages(ctx context.Context, sessionID string) ([]*ChatMessage, error) { - return s.store.ListMessages(ctx, sessionID, 500) -} - -func (s *Service) GetMessagePage(ctx context.Context, sessionID string, before int64, limit int) (ChatMessagePage, error) { - if _, err := s.store.GetSession(ctx, sessionID); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ChatMessagePage{}, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) - } - return ChatMessagePage{}, err - } - return s.store.ListMessagePage(ctx, sessionID, before, limit) -} - -func (s *Service) GetAOPEvents(ctx context.Context, sessionID string) ([]aop.Event, error) { - return s.store.ListAOPEvents(ctx, sessionID, 10000) -} - -func (s *Service) GetAOPEventsAfter(ctx context.Context, sessionID string, after int64) ([]persistedAOPEvent, error) { - return s.store.ListAOPEventsAfter(ctx, sessionID, after, 0) -} - -func (s *Service) BroadcastDomainEvent(sessionID string, event DomainEvent) { - event.SessionID = sessionID - if !event.Transient { - s.persistRuntimeDomainEvent(sessionID, event) +func (s *Service) BroadcastAOPEvent(sessionID string, event *aop.Event) { + if s == nil || s.hub == nil || sessionID == "" || event == nil || event.Payload == nil { + return } - s.hub.Broadcast(sessionTopic(sessionID), HubEvent{ - Type: event.Type, - Data: mustJSON(event), - Reliable: isTerminalDomainEvent(event.Type), - }) -} - -func (s *Service) BroadcastAOPEvent(sessionID string, event aop.Event) { - if s == nil || s.hub == nil || sessionID == "" || !event.Valid() { + if !s.prepareAOPEvent(sessionID, event) { return } var cursor int64 @@ -1038,235 +1008,107 @@ func (s *Service) BroadcastAOPEvent(sessionID string, event aop.Event) { s.broadcastAOPEvent(sessionID, event, cursor) } -func (s *Service) broadcastAOPEvent(sessionID string, event aop.Event, cursor int64) { - s.hub.Broadcast(sessionTopic(sessionID), HubEvent{ - ID: cursor, - Type: "aop", - Data: mustJSON(event), - Reliable: isReliableAOPEvent(event), - }) -} - -// broadcastHubError emits a hub-originated failure as an AOP error event: the -// code names a translatable template (mirrored under `sys.*` in the frontend -// locales), message is the English fallback, and params feed i18n -// interpolation via the aiscan.web extension. -func (s *Service) broadcastHubError(sessionID, code, message string, params map[string]any) { - data, _ := json.Marshal(aop.ErrorData{Code: code, Message: message}) - event := aop.Event{ - Type: aop.TypeError, - TS: time.Now().UTC().Format(time.RFC3339Nano), - SessionID: sessionID, - Agent: "aiscan.web", - Data: data, - } - if len(params) > 0 { - _ = webproto.SetWebExt(&event, webproto.WebMessageExt{Params: params}) +func (s *Service) prepareAOPEvent(sessionID string, event *aop.Event) bool { + if event.SessionId == "" { + event.SessionId = sessionID + } + if event.Id == "" { + event.Id = generateID() + } + if event.EmittedAt == nil { + event.EmittedAt = timestamppb.Now() + } + sequenceKey := event.SessionId + if event.Seq == 0 && s.store != nil { + s.eventMu.Lock() + _, initialized := s.sessionSeq[sequenceKey] + s.eventMu.Unlock() + if !initialized { + if maximum, err := s.store.MaxAOPEventSeq(context.Background(), sequenceKey); err == nil { + s.eventMu.Lock() + if _, exists := s.sessionSeq[sequenceKey]; !exists { + s.sessionSeq[sequenceKey] = maximum + } + s.eventMu.Unlock() + } + } } - s.BroadcastAOPEvent(sessionID, event) -} - -func isReliableAOPEvent(event aop.Event) bool { - switch event.Type { - case aop.TypeSessionEnd, aop.TypeError, aop.TypeToolResult, aop.TypeTurnEnd, aop.TypeMessage: - return true - case aop.TypeStatus: - // Status entries that drive durable UI state (eval/compact banners, - // budget warnings) must survive reconnect; the rest are evictable. - data, err := aop.DecodeData[aop.StatusData](event) - if err != nil { + s.eventMu.Lock() + defer s.eventMu.Unlock() + if event.GetTurnEnded() != nil && event.TurnId != "" { + terminalKey := sequenceKey + "\x00" + event.TurnId + if s.endedTurns[terminalKey] { return false } - switch data.State { - case xeval.StateEnd, xcompact.StateEnd, aop.StatusTokenBudgetWarning: - return true - } - } - return false -} - -// isTerminalDomainEvent classifies terminal platform events. Agent run lifecycle -// (including hub-originated failures) is carried exclusively by AOP. -func isTerminalDomainEvent(t string) bool { - return t == DomainEventScanComplete -} - -func (s *Service) persistRuntimeDomainEvent(sessionID string, event DomainEvent) { - if s == nil || s.store == nil || sessionID == "" { - return - } - - now := time.Now() - msg := &ChatMessage{ - ID: generateID(), - SessionID: sessionID, - AgentID: event.AgentID, - AgentName: event.AgentName, - CreatedAt: now, + s.endedTurns[terminalKey] = true } - metadata := map[string]any{ - "event_type": event.Type, + if event.Seq == 0 { + s.sessionSeq[sequenceKey]++ + event.Seq = s.sessionSeq[sequenceKey] + } else if event.Seq > s.sessionSeq[sequenceKey] { + s.sessionSeq[sequenceKey] = event.Seq } + return true +} - switch event.Type { - case DomainEventScanComplete: - // Persist a lightweight marker so the inline scan card survives a reload / - // session switch. The heavy Result payload is NOT stored here — it stays - // reloadable via the session_scans link (getScan), and the client fills the - // card from its scanResults map keyed by this scan_id. Without this marker - // the scan is invisible to any timeline rebuilt from messages (a page - // reload, an SSE reconnect, or a session switch that revalidates against - // the store), even though the result itself is still fetchable. - if event.ScanID == "" { - return - } - msg.Role = "system" - msg.Content = "scan complete" - metadata["scan_id"] = event.ScanID - - default: +func (s *Service) resetTurnTerminal(sessionID, turnID string) { + if sessionID == "" || turnID == "" { return } + s.eventMu.Lock() + delete(s.endedTurns, sessionID+"\x00"+turnID) + s.eventMu.Unlock() +} - if data, err := json.Marshal(metadata); err == nil { - msg.Metadata = data - } - _ = s.store.AddMessage(context.Background(), msg) +func (s *Service) broadcastAOPEvent(sessionID string, event *aop.Event, cursor int64) { + s.hub.BroadcastAOP(sessionID, AOPDelivery{Cursor: cursor, Event: event}, isReliableAOPEvent(event)) } -func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content string, opts webproto.GoalExt) (*ChatMessage, error) { - now := time.Now() - session, err := s.store.GetSession(ctx, sessionID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) - } - return nil, fmt.Errorf("get message session: %w", err) - } - msg := &ChatMessage{ - ID: generateID(), - SessionID: sessionID, - Role: "user", - Content: content, - CreatedAt: now, - Queued: s.sessionHasActiveTask(sessionID), - } - cursor, err := s.store.AppendMessage(ctx, msg) - if err != nil { - return nil, fmt.Errorf("store message: %w", err) - } - if event, err := messageEventFromChatMessage(msg); err == nil { - s.broadcastAOPEvent(sessionID, event, cursor) +// broadcastHubError emits a hub-originated failure as an AOP error event: the +// code names a translatable template (mirrored under `sys.*` in the frontend +// locales), message is the English fallback, and params feed i18n +// interpolation via the aiscan.web extension. +func (s *Service) broadcastHubError(sessionID, code, message string, params map[string]any) { + event := &aop.Event{ + Id: generateID(), EmittedAt: timestamppb.Now(), SessionId: sessionID, Emitter: "aiscan.web", + Payload: &aop.Event_Error{Error: &aop.ProtocolError{Code: code, Message: message}}, } - - // Update session timestamp and auto-title from first message. - session.UpdatedAt = now - if session.Title == "" { - title := content - if len(title) > 60 { - title = title[:60] + "..." + if len(params) > 0 { + if values, err := structpb.NewStruct(params); err == nil { + _ = ext.SetWebMessage(event, ext.WebMessageExtension{Params: values}) } - session.Title = title } - _ = s.store.UpdateSession(ctx, session) - - //nolint:gosec // Agent dispatch must continue after the HTTP request returns. - go s.dispatchUserMessage(sessionID, msg, opts) + s.BroadcastAOPEvent(sessionID, event) +} - return msg, nil +func (s *Service) broadcastHubTurnEnded(sessionID, turnID, code, message string) { + ended := &aop.TurnEnded{StopReason: "error", Error: &aop.ProtocolError{Code: code, Message: message}} + s.BroadcastAOPEvent(sessionID, &aop.Event{ + SessionId: sessionID, TurnId: turnID, Emitter: "aiscan.web", + Payload: &aop.Event_TurnEnded{TurnEnded: ended}, + }) } -// sessionHasActiveTask reports whether any task (chat turn or scan) is -// currently running on the session — used to mark freshly sent messages as -// queued rather than in-flight. -func (s *Service) sessionHasActiveTask(sessionID string) bool { - s.mu.Lock() - defer s.mu.Unlock() - for _, sid := range s.taskSessions { - if sid == sessionID { +func isReliableAOPEvent(event *aop.Event) bool { + switch payload := event.Payload.(type) { + case *aop.Event_SessionEnded, *aop.Event_Error, *aop.Event_ToolResult, *aop.Event_TurnEnded, *aop.Event_Message: + return true + case *aop.Event_Status: + // Status entries that drive durable UI state (eval/compact banners, + // budget warnings) must survive reconnect; the rest are evictable. + switch payload.Status.State { + case ext.EvalStateEnd, ext.CompactStateEnd, "token_budget_warning": return true } } return false } -func (s *Service) dispatchUserMessage(sessionID string, msg *ChatMessage, opts webproto.GoalExt) { - content := strings.TrimSpace(msg.Content) - if strings.HasPrefix(content, "!") { - s.handleAgentCommand(sessionID, content) - return - } - - // A typed "/verb" is routed by scope. Hub-scope commands (scan pipeline, - // agent roster, merged help) run here. Agent-scope commands (/status, - // /provider, /, ...) and unknown verbs fall through to the agent, - // where the AgentConsole bridge runs the real REPL — so the full REPL - // command set and `!bash` work from the browser without a parallel switch. - if verb, args, ok := parseCommand(content); ok { - // /clear is a true "clear conversation" on the web: it must wipe the - // visible+persisted transcript, not just reset the agent's model context. - // Owned end-to-end by the hub so it does both (see handleClearCommand). - if verb == "clear" { - s.handleClearCommand(sessionID, opts) - return - } - if hubCommands[verb] { - s.runHubCommand(sessionID, verb, args) - return - } - switch verb { - case "stop": - _ = s.CancelSession(context.Background(), sessionID) - return - case "exit", "quit": - s.closeRemoteSession(sessionID) - return - case "continue": - s.handleAgentRun(sessionID, webproto.RunPayload{ - SessionID: sessionID, Continue: true, NoEcho: true, - MaxTurns: opts.PersistMaxTurns, EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, - }) - return - case "followup": - followup := *msg - followup.Content = strings.TrimSpace(args) - s.handleChatMessage(sessionID, &followup, opts) - return - default: - if !strings.HasPrefix(content, "/skill:") { - s.handleAgentCommand(sessionID, content) - return - } - } - } - - s.handleChatMessage(sessionID, msg, opts) -} - -// handleClearCommand implements web /clear as "clear conversation": it deletes the -// session's persisted messages (incl. the "/clear" message itself) and signals the -// open UI to empty its timeline, then forwards /clear to the bound agent so its -// in-memory model context resets too. The agent's "Context cleared." reply lands in -// the now-empty transcript as the sole confirmation line; with no agent bound, the -// emptied view is itself the confirmation. -func (s *Service) handleClearCommand(sessionID string, opts webproto.GoalExt) { - _ = s.store.ClearMessages(context.Background(), sessionID) - // Transient: a live-only signal to connected clients — the cleared state is - // already durable in the store, so a reconnecting client re-derives it on load. - s.BroadcastDomainEvent(sessionID, DomainEvent{Type: DomainEventSessionCleared, Transient: true}) - if s.sessionAgent(sessionID) != nil { - s.handleAgentCommand(sessionID, "/clear") - } -} - -// runHubCommand executes a hub-scope slash command — one that needs hub state -// (the scan pipeline, the connected-agent roster, or the merged help catalog). +// runHubCommand executes a product-level slash command that needs hub state. // name is the canonical catalog name without its leading slash. Agent-scope // commands never reach here; they fall through to the agent bridge. func (s *Service) runHubCommand(sessionID, name, args string) { switch name { - case "scan": - s.handleScanCommand(sessionID, args) case "agents": s.handleAgentsCommand(sessionID) case "help": @@ -1317,11 +1159,10 @@ func (s *Service) handleHelpCommand(sessionID string) { // commands plus the bound agent's reported agent-scope commands (its skills // included). It falls back to the static agent-scope menu when no agent is // bound, so the menu is populated even before an agent connects. This is the -// single source both the "/" menu (GET .../commands) and /help render from. -func (s *Service) SessionMenu(sessionID string) []webproto.CommandSpec { - hubSpecs := []webproto.CommandSpec{ +// single source both SessionService/ListCommands and /help render from. +func (s *Service) SessionMenu(sessionID string) []*transport.CommandSpec { + hubSpecs := []*transport.CommandSpec{ {Name: "/help", Description: "查看命令面板"}, - {Name: "/scan", Description: "在本会话运行扫描", Usage: "/scan [--mode full] [--verify] [--sniper] [--deep]"}, {Name: "/agents", Description: "列出已连接的 agent"}, } agentSpecs := s.sessionAgent(sessionID).commandSpecs() @@ -1333,54 +1174,6 @@ func (s *Service) SessionMenu(sessionID string) []webproto.CommandSpec { return append(hubSpecs, agentSpecs...) } -func (s *Service) handleScanCommand(sessionID, args string) { - ctx := context.Background() - parts := strings.Fields(args) - if len(parts) == 0 { - s.broadcastHubError(sessionID, "scan_usage", "usage: /scan [--mode full] [--verify] [--sniper] [--deep]", nil) - return - } - - target := parts[0] - mode := "quick" - var verify, sniper, deep bool - for _, p := range parts[1:] { - switch p { - case "--mode": - // next arg handled below - case "full": - mode = "full" - case "--verify": - verify = true - case "--sniper": - sniper = true - case "--deep": - deep = true - } - } - for i, p := range parts { - if p == "--mode" && i+1 < len(parts) { - mode = parts[i+1] - } - } - - job, err := s.SubmitScan(ctx, target, mode, verify, sniper, deep) - if err != nil { - s.broadcastHubError(sessionID, "scan_submit", fmt.Sprintf("scan failed: %s", err), map[string]any{"error": err.Error()}) - return - } - - _ = s.store.LinkScanToSession(ctx, sessionID, job.ID) - - s.registerSessionTask(job.ID, sessionID, "") - - s.BroadcastDomainEvent(sessionID, DomainEvent{ - Type: DomainEventScanStarted, - ScanID: job.ID, - Data: fmt.Sprintf("Scan started: %s (%s)", target, mode), - }) -} - func (s *Service) handleAgentsCommand(sessionID string) { if s.agents == nil || s.agents.Count() == 0 { s.broadcastSystemMessage(sessionID, SysNoAgentsConnected, "No agents connected.", nil) @@ -1420,17 +1213,7 @@ func (s *Service) sessionAgent(sessionID string) *remoteAgent { return s.agents.get(session.AgentID) } -func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts webproto.GoalExt) { - run := webproto.RunPayload{ - SessionID: sessionID, - Parts: []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(msg.Content)}}, - NoEcho: true, MaxTurns: opts.PersistMaxTurns, - EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, - } - s.handleAgentRun(sessionID, run) -} - -func (s *Service) handleAgentRun(sessionID string, run webproto.RunPayload) { +func (s *Service) handleAgentRun(sessionID string, request *aop.RunTurnRequest) { agent := s.sessionAgent(sessionID) if agent == nil { s.broadcastSystemMessage(sessionID, SysAgentNotConnected, @@ -1438,55 +1221,86 @@ func (s *Service) handleAgentRun(sessionID string, run webproto.RunPayload) { return } - taskID := generateID() + taskID := strings.TrimSpace(request.TurnId) + if taskID == "" { + taskID = generateID() + } + if request.RequestId == "" { + request.RequestId = taskID + } + request.TurnId = taskID + request.SessionId = sessionID + s.resetTurnTerminal(sessionID, taskID) s.registerSessionTask(taskID, sessionID, agent.id) - s.BroadcastDomainEvent(sessionID, DomainEvent{ - Type: DomainEventAgentJoined, - AgentID: agent.id, - AgentName: agent.name, - }) - - resultCh, err := s.agents.DispatchRun(agent.id, taskID, run) + resultCh, err := s.agents.DispatchRun(agent.id, request) if err != nil { s.finishSessionTask(taskID) - s.broadcastHubError(sessionID, "dispatch_failed", err.Error(), nil) + s.broadcastHubTurnEnded(sessionID, taskID, "dispatch_failed", err.Error()) return } go func() { res, ok := <-resultCh canceled := s.finishSessionTask(taskID) - if !ok { - // Agent dropped mid-run: signal completion so the composer releases - // instead of hanging on the streaming indicator (mirrors the command - // path above). - s.broadcastHubError(sessionID, "agent_disconnected", "agent disconnected", nil) + if canceled { return } - if canceled { + if !ok { + s.broadcastHubTurnEnded(sessionID, taskID, "agent_disconnected", "agent disconnected") return } if res.Err != "" { - s.broadcastHubError(sessionID, "", res.Err, nil) + s.broadcastHubTurnEnded(sessionID, taskID, "agent_run_failed", res.Err) } }() } func (s *Service) handleAgentCommand(sessionID, line string) { + if _, err := s.ExecuteSessionCommand(sessionID, line); err != nil { + s.broadcastHubError(sessionID, "", err.Error(), nil) + } +} + +func (s *Service) ExecuteSessionCommand(sessionID, line string) (string, error) { + line = strings.TrimSpace(line) + if line == "" { + return "", fmt.Errorf("command line is required") + } + if _, err := s.store.GetSession(context.Background(), sessionID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) + } + return "", err + } + if verb, args, ok := parseCommand(line); ok { + switch verb { + case "help", "agents": + operationID := generateID() + go s.runHubCommand(sessionID, verb, args) + return operationID, nil + case "clear": + return "", fmt.Errorf("clear requires ResetSession") + case "stop": + return "", fmt.Errorf("stop requires CancelTurn") + case "exit", "quit": + return "", fmt.Errorf("exit requires CloseSession") + case "continue", "followup": + return "", fmt.Errorf("%s requires RunTurn", verb) + case "scan": + return "", fmt.Errorf("scan is not available through the chat protocol") + } + } agent := s.sessionAgent(sessionID) if agent == nil { - s.broadcastSystemMessage(sessionID, SysAgentNotConnected, - "Agent is not connected. Reconnect the agent to continue chatting.", nil) - return + return "", fmt.Errorf("agent is not connected") } taskID := generateID() s.registerSessionTask(taskID, sessionID, agent.id) - resultCh, err := s.agents.DispatchCommand(agent.id, taskID, webproto.CommandPayload{SessionID: sessionID, Line: line}) + resultCh, err := s.agents.DispatchCommand(agent.id, &transport.CommandRequest{TaskId: taskID, SessionId: sessionID, Line: line}) if err != nil { s.finishSessionTask(taskID) - s.broadcastHubError(sessionID, "dispatch_failed", err.Error(), nil) - return + return "", err } go func() { res, ok := <-resultCh @@ -1498,6 +1312,7 @@ func (s *Service) handleAgentCommand(sessionID, line string) { s.broadcastHubError(sessionID, "", res.Err, nil) } }() + return taskID, nil } func (s *Service) closeRemoteSession(sessionID string) { @@ -1505,8 +1320,13 @@ func (s *Service) closeRemoteSession(sessionID string) { if err != nil || s.agents == nil || session.AgentID == "" { return } - payload, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: sessionID, Reason: "completed"}) - _ = s.agents.SendAgentMessage(session.AgentID, webproto.Message{Type: webproto.TypeSessionClose, Payload: payload}) + requestID := "close:" + sessionID + _ = s.agents.sendAgentFrame(session.AgentID, &transport.ServerFrame{ + CorrelationId: requestID, + Payload: &transport.ServerFrame_CloseSession{CloseSession: &aop.CloseSessionRequest{ + RequestId: requestID, SessionId: sessionID, Reason: "completed", + }}, + }) } // broadcastSystemMessage persists + broadcasts a system message. code names a @@ -1515,29 +1335,20 @@ func (s *Service) closeRemoteSession(sessionID string) { // tests. params feeds i18n interpolation and is stored next to code so the // message stays localizable after a reload. func (s *Service) broadcastSystemMessage(sessionID, code, fallback string, params map[string]any) { - now := time.Now() - var meta json.RawMessage - if code != "" { - meta, _ = json.Marshal(map[string]any{"code": code, "params": params}) - } - msg := &ChatMessage{ - ID: generateID(), - SessionID: sessionID, - Role: "system", - Content: fallback, - Metadata: meta, - CreatedAt: now, - } - cursor, err := s.store.AppendMessage(context.Background(), msg) - if err != nil { - return + event := &aop.Event{ + Id: generateID(), EmittedAt: timestamppb.Now(), SessionId: sessionID, Emitter: "aiscan.web", + Payload: &aop.Event_Message{Message: &aop.Message{ + Id: generateID(), Role: "system", Content: []*aop.Content{aop.Text(fallback)}, + }}, } - if event, err := messageEventFromChatMessage(msg); err == nil { - s.broadcastAOPEvent(sessionID, event, cursor) + if code != "" { + metadata, _ := json.Marshal(map[string]any{"code": code, "params": params}) + _ = ext.SetWebMessage(event, ext.WebMessageExtension{Metadata: metadata}) } + s.BroadcastAOPEvent(sessionID, event) } -func (s *Service) broadcastScanComplete(scanID string, result *output.Result) { +func (s *Service) broadcastScanComplete(scanID string) { s.mu.Lock() sid, ok := s.taskSessions[scanID] s.mu.Unlock() @@ -1547,9 +1358,16 @@ func (s *Service) broadcastScanComplete(scanID string, result *output.Result) { if s.finishSessionTask(scanID) { return } - s.BroadcastDomainEvent(sid, DomainEvent{ - Type: DomainEventScanComplete, - ScanID: scanID, - Result: result, + _ = s.store.LinkScanToSession(context.Background(), sid, scanID) + value, err := aop.ProtoJSONValue(&scanpb.SessionScanEvent{ScanId: scanID, Status: scanpb.ScanStatus_SCAN_STATUS_COMPLETED}) + if err != nil { + return + } + s.BroadcastAOPEvent(sid, &aop.Event{ + SessionId: sid, + Emitter: "aiscan.web", + Payload: &aop.Event_Extension{Extension: &aop.ExtensionEvent{ + Type: "io.chainreactors.aiscan.scan", Value: value, + }}, }) } diff --git a/pkg/web/service_test.go b/pkg/web/service_test.go index 30e69ad1..f5f3f804 100644 --- a/pkg/web/service_test.go +++ b/pkg/web/service_test.go @@ -1,7 +1,6 @@ package web import ( - "bytes" "context" "net/http" "net/http/httptest" @@ -11,18 +10,11 @@ import ( "testing" "time" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/utils/parsers" ) -func TestScanRequestFields(t *testing.T) { - req := ScanRequest{Verify: true, Deep: true} - if !req.Verify || req.Sniper || !req.Deep { - t.Fatalf("scan request = verify:%v sniper:%v deep:%v", req.Verify, req.Sniper, req.Deep) - } -} - func TestScanArgsForSelectedAnalysisOptions(t *testing.T) { job := &ScanJob{ Target: "127.0.0.1", @@ -46,7 +38,7 @@ func TestServiceStatusReportsLLMAvailability(t *testing.T) { } } -func TestHandleUserMessageRejectsMissingSessionBeforePersisting(t *testing.T) { +func TestRunTurnRejectsMissingSessionBeforePersisting(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) if err != nil { t.Fatal(err) @@ -54,8 +46,12 @@ func TestHandleUserMessageRejectsMissingSessionBeforePersisting(t *testing.T) { defer store.Close() svc := NewService(ServiceConfig{Store: store}) - if _, err := svc.HandleUserMessage(context.Background(), "missing", "hello", webproto.GoalExt{}); err == nil { - t.Fatal("HandleUserMessage() accepted a missing session") + response, err := NewAOPChatServer(svc).RunTurn(context.Background(), &aop.RunTurnRequest{ + RequestId: "run-1", SessionId: "missing", TurnId: "turn-1", + Input: &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}}, + }) + if err != nil || response.GetRejected().GetCode() != "NOT_FOUND" { + t.Fatalf("RunTurn = %v, %v", response, err) } var count int if err := store.db.QueryRow(`SELECT COUNT(*) FROM chat_aop_events WHERE session_id = 'missing'`).Scan(&count); err != nil { @@ -66,35 +62,31 @@ func TestHandleUserMessageRejectsMissingSessionBeforePersisting(t *testing.T) { } } -func TestSendMessageReturnsNotFoundForMissingSession(t *testing.T) { +func TestLegacyChatAndScanRoutesReturnNotFoundBeforeSPAFallback(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) if err != nil { t.Fatal(err) } defer store.Close() svc := NewService(ServiceConfig{Store: store}) - handler := NewHandler(svc, nil, nil, nil, nil, "") - req := httptest.NewRequest(http.MethodPost, "/api/chat/sessions/missing/messages", bytes.NewBufferString(`{"content":"hello"}`)) - req.Header.Set("Content-Type", "application/json") - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - if recorder.Code != http.StatusNotFound { - t.Fatalf("status = %d, body = %s; want 404", recorder.Code, recorder.Body.String()) - } -} - -func TestListMessagesReturnsNotFoundForMissingSession(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") - recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/chat/sessions/missing/messages", nil)) - if recorder.Code != http.StatusNotFound { - t.Fatalf("status = %d, body = %s; want 404", recorder.Code, recorder.Body.String()) + handler := NewHandler(svc, nil, nil, nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), "") + for _, test := range []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/api/chat"}, + {method: http.MethodGet, path: "/api/chat/sessions"}, + {method: http.MethodPost, path: "/api/chat/sessions/missing/messages"}, + {method: http.MethodGet, path: "/api/scans"}, + {method: http.MethodGet, path: "/api/scans/missing/events"}, + } { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(test.method, test.path, nil)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("%s %s status = %d, body = %s; want 404", test.method, test.path, recorder.Code, recorder.Body.String()) + } } } diff --git a/pkg/web/session_connect.go b/pkg/web/session_connect.go new file mode 100644 index 00000000..4fb00867 --- /dev/null +++ b/pkg/web/session_connect.go @@ -0,0 +1,333 @@ +package web + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "sync" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" + "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const maxUploadSize = 50 << 20 // 50 MB + +var ErrUploadTooLarge = errors.New("uploaded file exceeds the size limit") + +type connectSessionServer struct { + chatconnect.UnimplementedSessionServiceHandler + service *Service + chat *aopChatServer + mu sync.Mutex +} + +func newConnectSessionServer(service *Service, chat *aopChatServer) *connectSessionServer { + return &connectSessionServer{service: service, chat: chat} +} + +func (s *connectSessionServer) ListSessions(ctx context.Context, req *connect.Request[chatpb.ListSessionsRequest]) (*connect.Response[chatpb.ListSessionsResponse], error) { + if s.service == nil || s.service.store == nil { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("chat service is unavailable")) + } + offset := 0 + if value := strings.TrimSpace(req.Msg.AfterCursor); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid after_cursor %q", value)) + } + offset = parsed + } + limit := int(req.Msg.Limit) + if limit == 0 { + limit = 100 + } + sessions, more, err := s.service.store.ListSessionPage(ctx, offset, limit, req.Msg.IncludeClosed) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + response := &chatpb.ListSessionsResponse{Sessions: make([]*chatpb.SessionRecord, 0, len(sessions))} + for _, session := range sessions { + response.Sessions = append(response.Sessions, sessionRecord(session)) + } + if more { + response.NextCursor = strconv.Itoa(offset + len(sessions)) + } + return connect.NewResponse(response), nil +} + +func (s *connectSessionServer) GetSession(ctx context.Context, req *connect.Request[chatpb.GetSessionRequest]) (*connect.Response[chatpb.GetSessionResponse], error) { + if req.Msg == nil || strings.TrimSpace(req.Msg.SessionId) == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("session_id is required")) + } + session, err := s.service.store.GetSession(ctx, req.Msg.SessionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, connect.NewError(connect.CodeNotFound, errors.New("session not found")) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&chatpb.GetSessionResponse{Session: sessionRecord(session)}), nil +} + +func (s *connectSessionServer) ResetSession(ctx context.Context, req *connect.Request[chatpb.ResetSessionRequest]) (*connect.Response[chatpb.ResetSessionResponse], error) { + request := req.Msg + if request == nil || strings.TrimSpace(request.RequestId) == "" { + return connect.NewResponse(rejectedReset(request, codes.InvalidArgument, "request_id is required")), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(chatpb.ResetSessionResponse) + hash, found, conflict, err := s.beginRequest(ctx, "ResetSession", request.RequestId, request, replayed) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if found { + return connect.NewResponse(replayed), nil + } + if conflict { + return connect.NewResponse(rejectedReset(request, codes.AlreadyExists, "request_id conflicts with another request")), nil + } + finish := func(response *chatpb.ResetSessionResponse) (*connect.Response[chatpb.ResetSessionResponse], error) { + if err := s.finishRequest(ctx, "ResetSession", request.RequestId, hash, response); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(response), nil + } + old, err := s.service.store.GetSession(ctx, request.SessionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedReset(request, codes.NotFound, "session not found")) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + newID := strings.TrimSpace(request.NewSessionId) + if newID == "" { + newID = generateID() + } + openResponse, err := s.chat.OpenSession(ctx, &aop.OpenSessionRequest{ + RequestId: request.RequestId + ":open", SessionId: newID, Participant: old.AgentID, Title: request.Title, + }) + if err != nil { + return nil, asConnectError(err) + } + if rejected := openResponse.GetRejected(); rejected != nil { + return finish(&chatpb.ResetSessionResponse{RequestId: request.RequestId, Outcome: &chatpb.ResetSessionResponse_Rejected{Rejected: rejected}}) + } + closeResponse, err := s.chat.CloseSession(ctx, &aop.CloseSessionRequest{ + RequestId: request.RequestId + ":close", SessionId: old.ID, Reason: "reset", + }) + if err != nil { + _ = s.service.DeleteSession(context.Background(), newID) + return nil, asConnectError(err) + } + if rejected := closeResponse.GetRejected(); rejected != nil { + _ = s.service.DeleteSession(context.Background(), newID) + return finish(&chatpb.ResetSessionResponse{RequestId: request.RequestId, Outcome: &chatpb.ResetSessionResponse_Rejected{Rejected: rejected}}) + } + current, err := s.service.store.GetSession(ctx, newID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return finish(&chatpb.ResetSessionResponse{RequestId: request.RequestId, Outcome: &chatpb.ResetSessionResponse_Accepted{Accepted: &chatpb.ResetSessionReceipt{ + Previous: closeResponse.GetAccepted(), Current: sessionRecord(current), + }}}) +} + +func (s *connectSessionServer) DeleteSession(ctx context.Context, req *connect.Request[chatpb.DeleteSessionRequest]) (*connect.Response[chatpb.DeleteSessionResponse], error) { + request := req.Msg + if request == nil || strings.TrimSpace(request.RequestId) == "" { + return connect.NewResponse(rejectedDelete(request, codes.InvalidArgument, "request_id is required")), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(chatpb.DeleteSessionResponse) + hash, found, conflict, err := s.beginRequest(ctx, "DeleteSession", request.RequestId, request, replayed) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if found { + return connect.NewResponse(replayed), nil + } + if conflict { + return connect.NewResponse(rejectedDelete(request, codes.AlreadyExists, "request_id conflicts with another request")), nil + } + finish := func(response *chatpb.DeleteSessionResponse) (*connect.Response[chatpb.DeleteSessionResponse], error) { + if err := s.finishRequest(ctx, "DeleteSession", request.RequestId, hash, response); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(response), nil + } + session, err := s.service.store.GetSession(ctx, request.SessionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedDelete(request, codes.NotFound, "session not found")) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + if err := s.service.DeleteSession(ctx, request.SessionId); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return finish(&chatpb.DeleteSessionResponse{RequestId: request.RequestId, Outcome: &chatpb.DeleteSessionResponse_Accepted{Accepted: &aop.Session{ + Id: session.ID, State: "deleted", Participant: session.AgentID, Title: session.Title, + }}}) +} + +func (s *connectSessionServer) ListCommands(_ context.Context, req *connect.Request[chatpb.ListCommandsRequest]) (*connect.Response[chatpb.ListCommandsResponse], error) { + if req.Msg == nil || strings.TrimSpace(req.Msg.SessionId) == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("session_id is required")) + } + specs := s.service.SessionMenu(req.Msg.SessionId) + response := &chatpb.ListCommandsResponse{Commands: make([]*chatpb.CommandSpec, 0, len(specs))} + for _, spec := range specs { + response.Commands = append(response.Commands, &chatpb.CommandSpec{Name: spec.Name, Aliases: spec.Aliases, Usage: spec.Usage, Description: spec.Description}) + } + return connect.NewResponse(response), nil +} + +func (s *connectSessionServer) ExecuteCommand(ctx context.Context, req *connect.Request[chatpb.ExecuteCommandRequest]) (*connect.Response[chatpb.ExecuteCommandResponse], error) { + request := req.Msg + if request == nil || strings.TrimSpace(request.RequestId) == "" { + return connect.NewResponse(rejectedCommand(request, codes.InvalidArgument, "request_id is required")), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(chatpb.ExecuteCommandResponse) + hash, found, conflict, err := s.beginRequest(ctx, "ExecuteCommand", request.RequestId, request, replayed) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if found { + return connect.NewResponse(replayed), nil + } + if conflict { + return connect.NewResponse(rejectedCommand(request, codes.AlreadyExists, "request_id conflicts with another request")), nil + } + finish := func(response *chatpb.ExecuteCommandResponse) (*connect.Response[chatpb.ExecuteCommandResponse], error) { + if err := s.finishRequest(ctx, "ExecuteCommand", request.RequestId, hash, response); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(response), nil + } + operationID, err := s.service.ExecuteSessionCommand(request.SessionId, request.Line) + if err != nil { + code := codes.FailedPrecondition + if errors.Is(err, ErrSessionNotFound) { + code = codes.NotFound + } + return finish(rejectedCommand(request, code, err.Error())) + } + return finish(&chatpb.ExecuteCommandResponse{RequestId: request.RequestId, Outcome: &chatpb.ExecuteCommandResponse_Accepted{Accepted: &chatpb.CommandReceipt{ + OperationId: operationID, SessionId: request.SessionId, State: "running", + }}}) +} + +func (s *connectSessionServer) UploadSessionFile(ctx context.Context, req *connect.Request[chatpb.UploadSessionFileRequest]) (*connect.Response[chatpb.UploadSessionFileResponse], error) { + request := req.Msg + if request == nil || strings.TrimSpace(request.RequestId) == "" { + return connect.NewResponse(rejectedUpload(request, codes.InvalidArgument, "request_id is required")), nil + } + if len(request.Data) > maxUploadSize { + return connect.NewResponse(rejectedUpload(request, codes.ResourceExhausted, ErrUploadTooLarge.Error())), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(chatpb.UploadSessionFileResponse) + hash, found, conflict, err := s.beginRequest(ctx, "UploadSessionFile", request.RequestId, request, replayed) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if found { + return connect.NewResponse(replayed), nil + } + if conflict { + return connect.NewResponse(rejectedUpload(request, codes.AlreadyExists, "request_id conflicts with another request")), nil + } + finish := func(response *chatpb.UploadSessionFileResponse) (*connect.Response[chatpb.UploadSessionFileResponse], error) { + if err := s.finishRequest(ctx, "UploadSessionFile", request.RequestId, hash, response); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(response), nil + } + result, err := s.service.HandleFileUpload(ctx, request.SessionId, request.Filename, request.Data) + if err != nil { + code := codes.FailedPrecondition + if errors.Is(err, ErrSessionNotFound) { + code = codes.NotFound + } + return finish(rejectedUpload(request, code, err.Error())) + } + mediaType := request.MediaType + return finish(&chatpb.UploadSessionFileResponse{RequestId: request.RequestId, Outcome: &chatpb.UploadSessionFileResponse_Accepted{Accepted: &chatpb.UploadedFile{ + Filename: result.Filename, Path: result.Path, Size: int64(result.Size), MediaType: mediaType, + }}}) +} + +func (s *connectSessionServer) beginRequest(ctx context.Context, method, requestID string, request, response proto.Message) ([]byte, bool, bool, error) { + raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) + if err != nil { + return nil, false, false, err + } + digest := sha256.Sum256(raw) + found, conflict, err := s.service.store.LoadAOPRequest(ctx, requestID, method, digest[:], response) + return digest[:], found, conflict, err +} + +func (s *connectSessionServer) finishRequest(ctx context.Context, method, requestID string, hash []byte, response proto.Message) error { + return s.service.store.SaveAOPRequest(ctx, requestID, method, hash, response) +} + +func sessionRecord(session *ChatSession) *chatpb.SessionRecord { + if session == nil { + return nil + } + state := "open" + if session.Status != SessionActive { + state = "closed" + } + return &chatpb.SessionRecord{ + Session: &aop.Session{Id: session.ID, State: state, Participant: session.AgentID, Title: session.Title}, + AgentName: session.AgentName, ScanIds: append([]string(nil), session.ScanIDs...), + CreatedAt: timestamppb.New(session.CreatedAt), UpdatedAt: timestamppb.New(session.UpdatedAt), + } +} + +func rejectedReset(req *chatpb.ResetSessionRequest, code codes.Code, message string) *chatpb.ResetSessionResponse { + response := &chatpb.ResetSessionResponse{Outcome: &chatpb.ResetSessionResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} + +func rejectedDelete(req *chatpb.DeleteSessionRequest, code codes.Code, message string) *chatpb.DeleteSessionResponse { + response := &chatpb.DeleteSessionResponse{Outcome: &chatpb.DeleteSessionResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} + +func rejectedCommand(req *chatpb.ExecuteCommandRequest, code codes.Code, message string) *chatpb.ExecuteCommandResponse { + response := &chatpb.ExecuteCommandResponse{Outcome: &chatpb.ExecuteCommandResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} + +func rejectedUpload(req *chatpb.UploadSessionFileRequest, code codes.Code, message string) *chatpb.UploadSessionFileResponse { + response := &chatpb.UploadSessionFileResponse{Outcome: &chatpb.UploadSessionFileResponse_Rejected{Rejected: rejection(code, message)}} + if req != nil { + response.RequestId = req.RequestId + } + return response +} diff --git a/pkg/web/sse.go b/pkg/web/sse.go deleted file mode 100644 index eb70cfb0..00000000 --- a/pkg/web/sse.go +++ /dev/null @@ -1,185 +0,0 @@ -package web - -import ( - "encoding/json" - "fmt" - "net/http" - "slices" - "sync" - "time" - - "github.com/chainreactors/aiscan/pkg/webproto" -) - -// HubEvent is the unit broadcast through the SSE hub. Type is the SSE -// event name, Data is pre-serialized JSON written directly to the stream. -type HubEvent struct { - ID int64 - Type string - Data json.RawMessage - // Reliable marks a terminal event that Broadcast must not drop under - // backpressure: on a full buffer it evicts the oldest queued event to seat - // one, rather than shedding it like a token delta. See isTerminalDomainEvent - // for which events qualify and why a lost one strands the UI. - Reliable bool -} - -type Hub struct { - mu sync.Mutex - subscribers map[string]map[chan HubEvent]struct{} -} - -func NewHub() *Hub { - return &Hub{ - subscribers: make(map[string]map[chan HubEvent]struct{}), - } -} - -func (h *Hub) Subscribe(id string) (<-chan HubEvent, func()) { - ch := make(chan HubEvent, 64) - h.mu.Lock() - if _, ok := h.subscribers[id]; !ok { - h.subscribers[id] = make(map[chan HubEvent]struct{}) - } - h.subscribers[id][ch] = struct{}{} - h.mu.Unlock() - return ch, func() { - h.mu.Lock() - if bucket, ok := h.subscribers[id]; ok { - delete(bucket, ch) - if len(bucket) == 0 { - delete(h.subscribers, id) - } - } - close(ch) - h.mu.Unlock() - } -} - -func (h *Hub) Broadcast(id string, event HubEvent) { - h.mu.Lock() - for ch := range h.subscribers[id] { - select { - case ch <- event: - default: - // Buffer full. A non-reliable event (a token delta) is simply - // dropped — a later cumulative delta and the final message resend the - // same text. A reliable (terminal) event must not be the one dropped, - // so evict the oldest queued event to make room. Safe under h.mu: no - // other Broadcast fills this channel concurrently (so the resend is - // guaranteed room), and unsubscribe takes h.mu before close(ch), so - // ch is still open here. - if event.Reliable { - select { - case <-ch: - default: - } - select { - case ch <- event: - default: - } - } - } - } - h.mu.Unlock() -} - -func ServeSSE(w http.ResponseWriter, r *http.Request, hub *Hub, id string, terminalEvents ...string) { - serveSSE(w, r, hub, id, nil, terminalEvents...) -} - -func ServeSSEWithInitial(w http.ResponseWriter, r *http.Request, hub *Hub, id string, initial []HubEvent, terminalEvents ...string) { - serveSSE(w, r, hub, id, initial, terminalEvents...) -} - -func ServeSSEWithSnapshot( - w http.ResponseWriter, - r *http.Request, - hub *Hub, - id string, - snapshot func() ([]HubEvent, error), - terminalEvents ...string, -) error { - ch, unsubscribe := hub.Subscribe(id) - defer unsubscribe() - initial, err := snapshot() - if err != nil { - return err - } - serveSSEChannel(w, r, ch, initial, terminalEvents...) - return nil -} - -func serveSSE(w http.ResponseWriter, r *http.Request, hub *Hub, id string, initial []HubEvent, terminalEvents ...string) { - ch, unsubscribe := hub.Subscribe(id) - defer unsubscribe() - serveSSEChannel(w, r, ch, initial, terminalEvents...) -} - -func serveSSEChannel(w http.ResponseWriter, r *http.Request, ch <-chan HubEvent, initial []HubEvent, terminalEvents ...string) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming not supported", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - w.WriteHeader(http.StatusOK) - flusher.Flush() - - var lastSentID int64 - for _, event := range initial { - if event.ID > 0 { - fmt.Fprintf(w, "id: %d\n", event.ID) - lastSentID = event.ID - } - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, event.Data) - if isTerminalEvent(event.Type, terminalEvents) { - flusher.Flush() - return - } - } - flusher.Flush() - - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - - for { - select { - case <-r.Context().Done(): - return - case <-ticker.C: - fmt.Fprint(w, ": keepalive\n\n") - flusher.Flush() - case event, ok := <-ch: - if !ok { - return - } - if event.ID > 0 && event.ID <= lastSentID { - continue - } - if event.ID > 0 { - fmt.Fprintf(w, "id: %d\n", event.ID) - lastSentID = event.ID - } - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, event.Data) - flusher.Flush() - if isTerminalEvent(event.Type, terminalEvents) { - return - } - } - } -} - -func isTerminalEvent(eventType string, terminalEvents []string) bool { - if len(terminalEvents) == 0 { - return eventType == "complete" || eventType == "error" - } - return slices.Contains(terminalEvents, eventType) -} - -// mustJSON is a package-local alias for webproto.MustJSON. -var mustJSON = webproto.MustJSON diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go deleted file mode 100644 index 4c8724b1..00000000 --- a/pkg/web/sse_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package web - -import ( - "context" - "encoding/json" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/chainreactors/aiscan/core/aop" - xeval "github.com/chainreactors/aiscan/core/aop/x/eval" - "github.com/chainreactors/aiscan/core/output" -) - -// A saturated subscriber buffer must never swallow a reliable terminal event. -func TestHubBroadcastReliableSurvivesBackpressure(t *testing.T) { - h := NewHub() - ch, unsub := h.Subscribe("s1") - defer unsub() - - // Saturate the 64-slot buffer with droppable deltas while nobody reads. - const bufCap = 64 - for i := 0; i < bufCap; i++ { - h.Broadcast("s1", HubEvent{Type: "delta", Data: mustJSON(i)}) - } - - // One more droppable event has nowhere to go: it is silently dropped, never - // blocking and never displacing a queued event. - h.Broadcast("s1", HubEvent{Type: "delta", Data: mustJSON("overflow")}) - - // A terminal event onto the same full buffer must land, evicting the oldest. - h.Broadcast("s1", HubEvent{Type: "terminal", Data: mustJSON("done"), Reliable: true}) - - drained := make([]HubEvent, 0, bufCap) - for len(ch) > 0 { - drained = append(drained, <-ch) - } - - if len(drained) != bufCap { - t.Fatalf("buffer size = %d, want %d", len(drained), bufCap) - } - - var sawTerminal, sawOverflow bool - for _, e := range drained { - if e.Type == "terminal" { - sawTerminal = true - } - if string(e.Data) == string(mustJSON("overflow")) { - sawOverflow = true - } - } - if !sawTerminal { - t.Error("terminal (reliable) event was dropped under backpressure") - } - if sawOverflow { - t.Error("non-reliable overflow event should have been dropped, not queued") - } -} - -// isTerminalDomainEvent is the only test of the reliability classification: the -// run-ending platform signal must qualify, or the stuck-cursor bug returns. -// Agent lifecycle terminals are AOP events and covered by isReliableAOPEvent. -func TestIsTerminalDomainEvent(t *testing.T) { - if !isTerminalDomainEvent(DomainEventScanComplete) { - t.Errorf("%q should be terminal (reliable)", DomainEventScanComplete) - } - for _, ty := range []string{DomainEventScanStarted, DomainEventScanProgress, DomainEventAgentJoined} { - if isTerminalDomainEvent(ty) { - t.Errorf("%q should not be terminal", ty) - } - } -} - -func TestBroadcastAOPEventPersistsRawEnvelope(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - svc := NewService(ServiceConfig{Store: store}) - - const sid = "sess-aop" - createStoredSession(t, store, sid) - event := aop.Event{ - Type: aop.TypeMessage, - TS: "2026-07-19T00:00:00Z", - SessionID: "agent-session", - Agent: "aiscan", - Seq: 7, - Data: json.RawMessage(`{"message_id":"m-1","role":"assistant","parts":[{"type":"text","text":"hello"}]}`), - } - svc.BroadcastAOPEvent(sid, event) - - events, err := store.ListAOPEvents(context.Background(), sid, 100) - if err != nil { - t.Fatal(err) - } - if len(events) != 1 { - t.Fatalf("persisted AOP events = %d, want 1", len(events)) - } - got := events[0] - if got.Type != event.Type || got.SessionID != event.SessionID || got.Seq != event.Seq || string(got.Data) != string(event.Data) { - t.Fatalf("persisted AOP event = %+v, want %+v", got, event) - } -} - -func TestEvalMetadataPersistsOnlyInAOP(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - svc := NewService(ServiceConfig{Store: store}) - createStoredSession(t, store, "sess-eval") - - event := aop.Event{ - Type: "turn.end", TS: time.Now().UTC().Format(time.RFC3339Nano), - SessionID: "sess-eval", Agent: "aiscan", Data: json.RawMessage(`{"turn":1}`), - } - _ = xeval.SetDetail(&event, xeval.Detail{Round: 2, Pass: false, Reason: "needs one more verified finding"}) - svc.BroadcastAOPEvent("sess-eval", event) - events, err := store.ListAOPEvents(context.Background(), "sess-eval", 100) - if err != nil { - t.Fatal(err) - } - if len(events) != 1 { - t.Fatalf("persisted AOP events = %d, want 1", len(events)) - } - detail, ok, err := xeval.GetDetail(events[0]) - if err != nil || !ok { - t.Fatalf("persisted extension = %#v, %v, %v", events[0].Ext, ok, err) - } - if detail.Round != 2 || detail.Pass || detail.Reason != "needs one more verified finding" { - t.Fatalf("persisted detail = %#v", detail) - } -} - -func TestScanCompletePersistsMarkerMetadata(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - svc := NewService(ServiceConfig{Store: store}) - createStoredSession(t, store, "sess-scan") - - // A completed scan must leave a durable marker so its inline card survives a - // timeline rebuild (reload / session switch). The heavy Result is intentionally - // not stored — only the scan_id, which the client re-hydrates via scan_ids. - svc.BroadcastDomainEvent("sess-scan", DomainEvent{ - Type: DomainEventScanComplete, - ScanID: "scan-123", - }) - - msgs, err := store.ListMessages(context.Background(), "sess-scan", 100) - if err != nil { - t.Fatal(err) - } - if len(msgs) != 1 { - t.Fatalf("persisted messages = %d, want 1", len(msgs)) - } - var metadata map[string]any - if err := json.Unmarshal(msgs[0].Metadata, &metadata); err != nil { - t.Fatalf("metadata json: %v", err) - } - if metadata["event_type"] != DomainEventScanComplete || metadata["scan_id"] != "scan-123" { - t.Fatalf("scan marker metadata = %#v", metadata) - } - - // A marker with no scan id is meaningless — it must not create a phantom row. - svc.BroadcastDomainEvent("sess-scan-empty", DomainEvent{Type: DomainEventScanComplete}) - empty, _ := store.ListMessages(context.Background(), "sess-scan-empty", 100) - if len(empty) != 0 { - t.Fatalf("empty-scanID persisted messages = %d, want 0", len(empty)) - } -} - -func TestScanEventsImmediatelyReplaysStoredTerminalState(t *testing.T) { - for _, tc := range []struct { - name string - status ScanStatus - want string - }{ - {name: "completed", status: StatusCompleted, want: "event: complete"}, - {name: "failed", status: StatusFailed, want: "event: error"}, - {name: "canceled", status: StatusCanceled, want: "\"status\":\"canceled\""}, - } { - t.Run(tc.name, func(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - now := time.Now() - job := &ScanJob{ - ID: "terminal-scan", Target: "127.0.0.1", Mode: "quick", Status: tc.status, - Error: "scan failed", CreatedAt: now, UpdatedAt: now, - } - if tc.status == StatusCompleted { - job.Result = &output.Result{} - } - if err := store.Create(context.Background(), job); err != nil { - t.Fatal(err) - } - - svc := NewService(ServiceConfig{Store: store}) - h := &handlerImpl{service: svc} - req := httptest.NewRequest("GET", "/api/scans/terminal-scan/events", nil) - req.SetPathValue("id", job.ID) - recorder := newLockedResponseRecorder() - done := make(chan struct{}) - go func() { - h.scanEvents(recorder, req) - close(done) - }() - select { - case <-done: - case <-time.After(200 * time.Millisecond): - t.Fatal("terminal scan SSE did not return immediately") - } - if body := recorder.BodyString(); !strings.Contains(body, tc.want) { - t.Fatalf("SSE body = %q, want %q", body, tc.want) - } - }) - } -} - -func TestServeSSEWithSnapshotSubscribesBeforeReadingSnapshot(t *testing.T) { - hub := NewHub() - req := httptest.NewRequest("GET", "/events", nil) - recorder := newLockedResponseRecorder() - - err := ServeSSEWithSnapshot(recorder, req, hub, "session-topic", func() ([]HubEvent, error) { - hub.Broadcast("session-topic", HubEvent{ - Type: "turn.end", Data: mustJSON(map[string]string{"stop": "completed"}), Reliable: true, - }) - return nil, nil - }, "turn.end") - if err != nil { - t.Fatal(err) - } - if body := recorder.BodyString(); !strings.Contains(body, "event: turn.end") { - t.Fatalf("SSE body = %q; event broadcast during snapshot was lost", body) - } -} - -func TestServeSSEWithSnapshotDropsQueuedSnapshotDuplicates(t *testing.T) { - hub := NewHub() - req := httptest.NewRequest("GET", "/events", nil) - recorder := newLockedResponseRecorder() - - err := ServeSSEWithSnapshot(recorder, req, hub, "session-topic", func() ([]HubEvent, error) { - hub.Broadcast("session-topic", HubEvent{ID: 2, Type: "aop", Data: mustJSON("duplicate")}) - hub.Broadcast("session-topic", HubEvent{ID: 3, Type: "done", Data: mustJSON("new"), Reliable: true}) - return []HubEvent{ - {ID: 1, Type: "aop", Data: mustJSON("one")}, - {ID: 2, Type: "aop", Data: mustJSON("duplicate")}, - }, nil - }, "done") - if err != nil { - t.Fatal(err) - } - body := recorder.BodyString() - if strings.Count(body, "id: 2\n") != 1 { - t.Fatalf("snapshot cursor 2 was emitted more than once: %q", body) - } - if !strings.Contains(body, "id: 3\n") { - t.Fatalf("new queued event was not emitted: %q", body) - } -} diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index bdb429f9..a1b96529 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -1,16 +1,19 @@ package web import ( + "bytes" "context" "database/sql" "encoding/json" + "errors" "fmt" "strings" "time" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/webproto" + "google.golang.org/protobuf/encoding/protojson" + protobuf "google.golang.org/protobuf/proto" _ "modernc.org/sqlite" ) @@ -72,7 +75,7 @@ func migrate(db *sql.DB) error { CREATE TABLE IF NOT EXISTS chat_aop_events ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, - hub_seq INTEGER NOT NULL DEFAULT 0, + cursor INTEGER NOT NULL DEFAULT 0, event_json TEXT NOT NULL, created_at TEXT NOT NULL ); @@ -82,10 +85,21 @@ func migrate(db *sql.DB) error { scan_id TEXT NOT NULL, PRIMARY KEY (session_id, scan_id) ); + + CREATE TABLE IF NOT EXISTS aop_request_journal ( + request_id TEXT PRIMARY KEY, + method TEXT NOT NULL, + request_hash BLOB NOT NULL, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); `); err != nil { return err } + if err := renameAOPCursorColumn(db); err != nil { + return err + } for _, column := range []sqliteColumnMigration{ {table: "scans", name: "mode", definition: "TEXT NOT NULL DEFAULT 'quick'"}, {table: "scans", name: "ai", definition: "INTEGER NOT NULL DEFAULT 0"}, @@ -104,30 +118,30 @@ func migrate(db *sql.DB) error { {table: "chat_messages", name: "agent_id", definition: "TEXT NOT NULL DEFAULT ''"}, {table: "chat_messages", name: "agent_name", definition: "TEXT NOT NULL DEFAULT ''"}, {table: "chat_messages", name: "metadata", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_aop_events", name: "hub_seq", definition: "INTEGER NOT NULL DEFAULT 0"}, + {table: "chat_aop_events", name: "cursor", definition: "INTEGER NOT NULL DEFAULT 0"}, } { if err := ensureSQLiteColumn(db, column); err != nil { return err } } if _, err := db.Exec(` - DROP TABLE IF EXISTS temp.aop_seq_backfill; - CREATE TEMP TABLE aop_seq_backfill (row_id INTEGER PRIMARY KEY, hub_seq INTEGER NOT NULL); - INSERT INTO aop_seq_backfill (row_id, hub_seq) + DROP TABLE IF EXISTS temp.aop_cursor_backfill; + CREATE TEMP TABLE aop_cursor_backfill (row_id INTEGER PRIMARY KEY, cursor INTEGER NOT NULL); + INSERT INTO aop_cursor_backfill (row_id, cursor) SELECT target.rowid, COALESCE(( - SELECT MAX(existing.hub_seq) + SELECT MAX(existing.cursor) FROM chat_aop_events AS existing - WHERE existing.session_id = target.session_id AND existing.hub_seq > 0 + WHERE existing.session_id = target.session_id AND existing.cursor > 0 ), 0) + ROW_NUMBER() OVER ( PARTITION BY target.session_id ORDER BY target.created_at, target.rowid ) FROM chat_aop_events AS target - WHERE target.hub_seq = 0; + WHERE target.cursor = 0; UPDATE chat_aop_events - SET hub_seq = (SELECT backfill.hub_seq FROM aop_seq_backfill AS backfill WHERE backfill.row_id = chat_aop_events.rowid) - WHERE rowid IN (SELECT row_id FROM aop_seq_backfill); - DROP TABLE aop_seq_backfill; + SET cursor = (SELECT backfill.cursor FROM aop_cursor_backfill AS backfill WHERE backfill.row_id = chat_aop_events.rowid) + WHERE rowid IN (SELECT row_id FROM aop_cursor_backfill); + DROP TABLE aop_cursor_backfill; `); err != nil { return err } @@ -174,13 +188,53 @@ func migrate(db *sql.DB) error { CREATE INDEX IF NOT EXISTS idx_sessions_updated ON chat_sessions(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_agent ON chat_sessions(agent_id); CREATE INDEX IF NOT EXISTS idx_aop_events_session ON chat_aop_events(session_id, created_at, id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_aop_events_session_seq ON chat_aop_events(session_id, hub_seq); + CREATE UNIQUE INDEX IF NOT EXISTS idx_aop_events_session_cursor ON chat_aop_events(session_id, cursor); CREATE INDEX IF NOT EXISTS idx_sco_nodes_type ON sco_nodes(cstx_type); CREATE INDEX IF NOT EXISTS idx_sco_nodes_scan ON sco_nodes(scan_id); `); err != nil { return err } - return wipeLegacyAOPEvents(db) + return nil +} + +func (s *SQLiteStore) LoadAOPRequest(ctx context.Context, requestID, method string, requestHash []byte, response protobuf.Message) (found, conflict bool, err error) { + if s == nil || strings.TrimSpace(requestID) == "" || response == nil { + return false, false, nil + } + var storedMethod string + var storedHash []byte + var raw string + err = s.db.QueryRowContext(ctx, + `SELECT method, request_hash, response_json FROM aop_request_journal WHERE request_id = ?`, requestID, + ).Scan(&storedMethod, &storedHash, &raw) + if errors.Is(err, sql.ErrNoRows) { + return false, false, nil + } + if err != nil { + return false, false, err + } + if storedMethod != method || !bytes.Equal(storedHash, requestHash) { + return false, true, nil + } + if err := protojson.Unmarshal([]byte(raw), response); err != nil { + return false, false, err + } + return true, false, nil +} + +func (s *SQLiteStore) SaveAOPRequest(ctx context.Context, requestID, method string, requestHash []byte, response protobuf.Message) error { + if s == nil || strings.TrimSpace(requestID) == "" || response == nil { + return nil + } + raw, err := protojson.Marshal(response) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO aop_request_journal (request_id, method, request_hash, response_json, created_at) + VALUES (?, ?, ?, ?, ?) + `, requestID, method, requestHash, string(raw), time.Now().UTC().Format(time.RFC3339Nano)) + return err } func ensureSessionForeignKeys(db *sql.DB) error { @@ -270,12 +324,12 @@ func rebuildAOPEventsWithForeignKey(tx *sql.Tx) error { CREATE TABLE chat_aop_events_fk_migration ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, - hub_seq INTEGER NOT NULL, + cursor INTEGER NOT NULL, event_json TEXT NOT NULL, created_at TEXT NOT NULL ); - INSERT INTO chat_aop_events_fk_migration (rowid, id, session_id, hub_seq, event_json, created_at) - SELECT events.rowid, events.id, events.session_id, events.hub_seq, events.event_json, events.created_at + INSERT INTO chat_aop_events_fk_migration (rowid, id, session_id, cursor, event_json, created_at) + SELECT events.rowid, events.id, events.session_id, events.cursor, events.event_json, events.created_at FROM chat_aop_events AS events WHERE EXISTS ( SELECT 1 FROM chat_sessions WHERE chat_sessions.id = events.session_id @@ -313,6 +367,25 @@ type sqliteColumnMigration struct { definition string } +func renameAOPCursorColumn(db *sql.DB) error { + hasOld, err := sqliteColumnExists(db, "chat_aop_events", "hub_seq") + if err != nil || !hasOld { + return err + } + hasCursor, err := sqliteColumnExists(db, "chat_aop_events", "cursor") + if err != nil { + return err + } + if hasCursor { + return fmt.Errorf("chat_aop_events contains both hub_seq and cursor") + } + _, err = db.Exec(` + DROP INDEX IF EXISTS idx_aop_events_session_seq; + ALTER TABLE chat_aop_events RENAME COLUMN hub_seq TO cursor; + `) + return err +} + func ensureSQLiteColumn(db *sql.DB, column sqliteColumnMigration) error { tableExists, err := sqliteTableExists(db, column.table) if err != nil || !tableExists { @@ -340,75 +413,6 @@ func sqliteTableExists(db *sql.DB, table string) (bool, error) { return count > 0, err } -// wipeLegacyAOPEvents performs the one-time breaking AOP lifecycle cutover. -// Sessions/messages/assets/records stay intact; only protocol event history is -// cleared because old session/turn boundaries cannot be reinterpreted safely. -func wipeLegacyAOPEvents(db *sql.DB) error { - exists, err := sqliteTableExists(db, "chat_aop_events") - if err != nil || !exists { - return err - } - var version int - if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { - return err - } - if version >= 2 { - return nil - } - if _, err := db.Exec(`DELETE FROM chat_aop_events`); err != nil { - return err - } - _, err = db.Exec(`PRAGMA user_version = 2`) - return err -} - -// messageEventFromChatMessage converts a hub-authored chat message (user input, -// system notices) into an AOP message event for persistence and broadcast. -func messageEventFromChatMessage(msg *ChatMessage) (aop.Event, error) { - if msg == nil || msg.SessionID == "" { - return aop.Event{}, fmt.Errorf("chat message requires session_id") - } - createdAt := msg.CreatedAt - if createdAt.IsZero() { - createdAt = time.Now() - } - agentName := strings.TrimSpace(msg.AgentName) - if agentName == "" { - agentName = "aiscan.web" - } - role := msg.Role - if role == "" { - role = "user" - } - data, err := json.Marshal(aop.MessageData{ - MessageID: msg.ID, - Role: role, - Parts: []aop.MessagePart{{Type: aop.PartText, Text: msg.Content}}, - }) - if err != nil { - return aop.Event{}, err - } - ext := webproto.WebMessageExt{AgentID: msg.AgentID} - if len(msg.Metadata) > 0 { - if json.Valid(msg.Metadata) { - ext.Metadata = msg.Metadata - } else if raw, err := json.Marshal(string(msg.Metadata)); err == nil { - ext.Metadata = raw - } - } - event := aop.Event{ - Type: aop.TypeMessage, - TS: createdAt.UTC().Format(time.RFC3339Nano), - SessionID: msg.SessionID, - Agent: agentName, - Data: data, - } - if ext.AgentID != "" || len(ext.Metadata) > 0 { - _ = webproto.SetWebExt(&event, ext) - } - return event, nil -} - func sqliteColumnExists(db *sql.DB, table, column string) (bool, error) { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", quoteSQLiteIdent(table))) if err != nil { @@ -638,6 +642,60 @@ func (s *SQLiteStore) ListSessions(ctx context.Context, limit int) ([]*ChatSessi return sessions, rows.Err() } +func (s *SQLiteStore) ListSessionPage(ctx context.Context, offset, limit int, includeClosed bool) ([]*ChatSession, bool, error) { + if offset < 0 { + offset = 0 + } + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + query := `SELECT id, agent_id, agent_name, title, status, topic_id, created_at, updated_at + FROM chat_sessions ORDER BY updated_at DESC LIMIT ? OFFSET ?` + args := []any{limit + 1, offset} + if !includeClosed { + query = `SELECT id, agent_id, agent_name, title, status, topic_id, created_at, updated_at + FROM chat_sessions WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?` + args = []any{SessionActive, limit + 1, offset} + } + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, false, err + } + sessions := make([]*ChatSession, 0, limit+1) + for rows.Next() { + var cs ChatSession + var createdAt, updatedAt string + if err := rows.Scan(&cs.ID, &cs.AgentID, &cs.AgentName, &cs.Title, &cs.Status, &cs.TopicID, &createdAt, &updatedAt); err != nil { + _ = rows.Close() + return nil, false, err + } + cs.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + cs.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + sessions = append(sessions, &cs) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, false, err + } + if err := rows.Close(); err != nil { + return nil, false, err + } + // SQLiteStore intentionally uses one connection. Enrich only after closing + // the session row set; querying SessionScanIDs inside rows.Next would wait on + // the connection held by the outer query and deadlock every non-empty page. + for _, session := range sessions { + session.ScanIDs, _ = s.SessionScanIDs(ctx, session.ID) + } + hasMore := len(sessions) > limit + if hasMore { + sessions = sessions[:limit] + } + return sessions, hasMore, nil +} + func (s *SQLiteStore) UpdateSession(ctx context.Context, session *ChatSession) error { _, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET title=?, status=?, topic_id=?, updated_at=? WHERE id=?`, @@ -651,51 +709,27 @@ func (s *SQLiteStore) DeleteSession(ctx context.Context, id string) error { return err } -// --- Chat message CRUD --- - -func (s *SQLiteStore) AddMessage(ctx context.Context, msg *ChatMessage) error { - _, err := s.AppendMessage(ctx, msg) - return err -} - -func (s *SQLiteStore) AppendMessage(ctx context.Context, msg *ChatMessage) (int64, error) { - event, err := messageEventFromChatMessage(msg) - if err != nil { - return 0, err - } - cursor, _, err := s.AppendAOPEvent(ctx, msg.SessionID, event) - return cursor, err -} - -// ClearMessages deletes every message in a session without removing the session -// itself — the store half of web /clear ("clear conversation"). Messages are leaf -// rows (nothing references them), so a single delete suffices. -func (s *SQLiteStore) ClearMessages(ctx context.Context, sessionID string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM chat_aop_events WHERE session_id = ?`, sessionID) - return err -} - -func (s *SQLiteStore) AddAOPEvent(ctx context.Context, sessionID string, event aop.Event) error { +func (s *SQLiteStore) AddAOPEvent(ctx context.Context, sessionID string, event *aop.Event) error { _, _, err := s.AppendAOPEvent(ctx, sessionID, event) return err } // AppendAOPEvent persists one durable event and assigns the authoritative -// session-local cursor used by SSE replay and REST pagination. Message deltas +// session-local cursor used by ListEvents/WatchEvents replay. Message deltas // remain transient and return persisted=false. -func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, event aop.Event) (cursor int64, persisted bool, err error) { +func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, event *aop.Event) (cursor int64, persisted bool, err error) { // Deltas are streaming fragments; only complete messages are persisted so a // replayed history holds the authoritative state. - if event.Type == aop.TypeMessageDelta { + if event == nil || event.GetMessageDelta() != nil || event.GetToolCallDelta() != nil { return 0, false, nil } - raw, err := json.Marshal(event) + raw, err := protojson.Marshal(event) if err != nil { return 0, false, err } - createdAt := event.TS - if createdAt == "" { - createdAt = time.Now().UTC().Format(time.RFC3339Nano) + createdAt := time.Now().UTC().Format(time.RFC3339Nano) + if event.EmittedAt != nil { + createdAt = event.EmittedAt.AsTime().UTC().Format(time.RFC3339Nano) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -703,12 +737,12 @@ func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, even } defer func() { _ = tx.Rollback() }() if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(MAX(hub_seq), 0) + 1 FROM chat_aop_events WHERE session_id = ?`, sessionID, + `SELECT COALESCE(MAX(cursor), 0) + 1 FROM chat_aop_events WHERE session_id = ?`, sessionID, ).Scan(&cursor); err != nil { return 0, false, err } if _, err := tx.ExecContext(ctx, - `INSERT INTO chat_aop_events (id, session_id, hub_seq, event_json, created_at) VALUES (?, ?, ?, ?, ?)`, + `INSERT INTO chat_aop_events (id, session_id, cursor, event_json, created_at) VALUES (?, ?, ?, ?, ?)`, generateID(), sessionID, cursor, string(raw), createdAt, ); err != nil { return 0, false, err @@ -719,18 +753,38 @@ func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, even return cursor, true, nil } -func (s *SQLiteStore) ListAOPEvents(ctx context.Context, sessionID string, limit int) ([]aop.Event, error) { +func (s *SQLiteStore) ListAOPEvents(ctx context.Context, sessionID string, limit int) ([]*aop.Event, error) { page, _, err := s.ListAOPEventPage(ctx, sessionID, 0, limit) if err != nil { return nil, err } - events := make([]aop.Event, 0, len(page)) + events := make([]*aop.Event, 0, len(page)) for _, stored := range page { events = append(events, stored.Event) } return events, nil } +func (s *SQLiteStore) MaxAOPEventSeq(ctx context.Context, sessionID string) (uint64, error) { + rows, err := s.db.QueryContext(ctx, `SELECT event_json FROM chat_aop_events WHERE session_id = ?`, sessionID) + if err != nil { + return 0, err + } + defer rows.Close() + var maximum uint64 + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return 0, err + } + event := new(aop.Event) + if protojson.Unmarshal([]byte(raw), event) == nil && event.Seq > maximum { + maximum = event.Seq + } + } + return maximum, rows.Err() +} + func (s *SQLiteStore) ListAOPEventPage(ctx context.Context, sessionID string, before int64, limit int) ([]persistedAOPEvent, int64, error) { if limit <= 0 { limit = 10000 @@ -738,16 +792,16 @@ func (s *SQLiteStore) ListAOPEventPage(ctx context.Context, sessionID string, be if limit > 10000 { limit = 10000 } - query := `SELECT hub_seq, event_json FROM ( - SELECT hub_seq, event_json FROM chat_aop_events - WHERE session_id = ? ORDER BY hub_seq DESC LIMIT ? - ) ORDER BY hub_seq ASC` + query := `SELECT cursor, event_json FROM ( + SELECT cursor, event_json FROM chat_aop_events + WHERE session_id = ? ORDER BY cursor DESC LIMIT ? + ) ORDER BY cursor ASC` args := []any{sessionID, limit + 1} if before > 0 { - query = `SELECT hub_seq, event_json FROM ( - SELECT hub_seq, event_json FROM chat_aop_events - WHERE session_id = ? AND hub_seq < ? ORDER BY hub_seq DESC LIMIT ? - ) ORDER BY hub_seq ASC` + query = `SELECT cursor, event_json FROM ( + SELECT cursor, event_json FROM chat_aop_events + WHERE session_id = ? AND cursor < ? ORDER BY cursor DESC LIMIT ? + ) ORDER BY cursor ASC` args = []any{sessionID, before, limit + 1} } rows, err := s.db.QueryContext(ctx, query, args...) @@ -762,8 +816,8 @@ func (s *SQLiteStore) ListAOPEventPage(ctx context.Context, sessionID string, be if err := rows.Scan(&cursor, &raw); err != nil { return nil, 0, err } - var event aop.Event - if json.Unmarshal([]byte(raw), &event) == nil && event.Valid() { + event := new(aop.Event) + if protojson.Unmarshal([]byte(raw), event) == nil && event.SessionId != "" && event.Payload != nil { events = append(events, persistedAOPEvent{Cursor: cursor, Event: event}) } } @@ -785,7 +839,7 @@ func (s *SQLiteStore) ListAOPEventsAfter(ctx context.Context, sessionID string, events, _, err := s.ListAOPEventPage(ctx, sessionID, 0, limit) return events, err } - query := `SELECT hub_seq, event_json FROM chat_aop_events WHERE session_id = ? AND hub_seq > ? ORDER BY hub_seq ASC` + query := `SELECT cursor, event_json FROM chat_aop_events WHERE session_id = ? AND cursor > ? ORDER BY cursor ASC` args := []any{sessionID, after} if limit > 0 { if limit > 10000 { @@ -806,112 +860,14 @@ func (s *SQLiteStore) ListAOPEventsAfter(ctx context.Context, sessionID string, if err := rows.Scan(&stored.Cursor, &raw); err != nil { return nil, err } - if json.Unmarshal([]byte(raw), &stored.Event) == nil && stored.Event.Valid() { + stored.Event = new(aop.Event) + if protojson.Unmarshal([]byte(raw), stored.Event) == nil && stored.Event.SessionId != "" && stored.Event.Payload != nil { events = append(events, stored) } } return events, rows.Err() } -func (s *SQLiteStore) ListMessages(ctx context.Context, sessionID string, limit int) ([]*ChatMessage, error) { - page, err := s.ListMessagePage(ctx, sessionID, 0, limit) - if err != nil { - return nil, err - } - return page.Items, nil -} - -func (s *SQLiteStore) ListMessagePage(ctx context.Context, sessionID string, before int64, limit int) (ChatMessagePage, error) { - if limit <= 0 { - limit = 500 - } - if limit > 500 { - limit = 500 - } - query := `SELECT hub_seq, event_json FROM ( - SELECT hub_seq, event_json FROM chat_aop_events - WHERE session_id = ? AND json_valid(event_json) AND json_extract(event_json, '$.type') = ? - ORDER BY hub_seq DESC LIMIT ? - ) ORDER BY hub_seq ASC` - args := []any{sessionID, aop.TypeMessage, limit + 1} - if before > 0 { - query = `SELECT hub_seq, event_json FROM ( - SELECT hub_seq, event_json FROM chat_aop_events - WHERE session_id = ? AND hub_seq < ? AND json_valid(event_json) AND json_extract(event_json, '$.type') = ? - ORDER BY hub_seq DESC LIMIT ? - ) ORDER BY hub_seq ASC` - args = []any{sessionID, before, aop.TypeMessage, limit + 1} - } - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return ChatMessagePage{}, err - } - defer rows.Close() - events := make([]persistedAOPEvent, 0, limit+1) - for rows.Next() { - var stored persistedAOPEvent - var raw string - if err := rows.Scan(&stored.Cursor, &raw); err != nil { - return ChatMessagePage{}, err - } - if json.Unmarshal([]byte(raw), &stored.Event) == nil && stored.Event.Valid() { - events = append(events, stored) - } - } - if err := rows.Err(); err != nil { - return ChatMessagePage{}, err - } - var next int64 - if len(events) > limit { - events = events[1:] - if len(events) > 0 { - next = events[0].Cursor - } - } - msgs := make([]*ChatMessage, 0, len(events)) - for _, stored := range events { - event := stored.Event - if event.Type != aop.TypeMessage { - continue - } - var data aop.MessageData - if json.Unmarshal(event.Data, &data) != nil { - continue - } - var sb strings.Builder - for _, part := range data.Parts { - if part.Type != aop.PartText || part.Text == "" { - continue - } - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString(part.Text) - } - msg := &ChatMessage{ - ID: data.MessageID, - SessionID: sessionID, - Role: data.Role, - AgentName: event.Agent, - Content: sb.String(), - Cursor: stored.Cursor, - } - if msg.ID == "" { - msg.ID = generateID() - } - if msg.Role == "" { - msg.Role = "assistant" - } - msg.CreatedAt, _ = time.Parse(time.RFC3339Nano, event.TS) - if ext, ok, err := webproto.GetWebExt(event); err == nil && ok { - msg.AgentID = ext.AgentID - msg.Metadata = ext.Metadata - } - msgs = append(msgs, msg) - } - return ChatMessagePage{Items: msgs, NextCursor: next}, nil -} - // --- Session-scan association --- func (s *SQLiteStore) LinkScanToSession(ctx context.Context, sessionID, scanID string) error { diff --git a/pkg/web/store_sqlite_test.go b/pkg/web/store_sqlite_test.go index fae1760a..a496c610 100644 --- a/pkg/web/store_sqlite_test.go +++ b/pkg/web/store_sqlite_test.go @@ -8,8 +8,10 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/output" + "google.golang.org/protobuf/types/known/timestamppb" ) func createStoredSession(t *testing.T, store *SQLiteStore, id string) { @@ -22,7 +24,25 @@ func createStoredSession(t *testing.T, store *SQLiteStore, id string) { } } -func TestSQLiteStoreWipesLegacyTextEvents(t *testing.T) { +func TestListSessionPageDoesNotDeadlockOnNonEmptyStore(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "session-page.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + createStoredSession(t, store, "session-1") + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + sessions, more, err := store.ListSessionPage(ctx, 0, 100, true) + if err != nil { + t.Fatal(err) + } + if more || len(sessions) != 1 || sessions[0].ID != "session-1" { + t.Fatalf("ListSessionPage = %+v more=%v", sessions, more) + } +} + +func TestSQLiteStoreIgnoresNonProtoJSONEventsWithoutDeletingHistory(t *testing.T) { path := filepath.Join(t.TempDir(), "legacy.db") db, err := sql.Open("sqlite", path) if err != nil { @@ -50,7 +70,11 @@ func TestSQLiteStoreWipesLegacyTextEvents(t *testing.T) { t.Fatal(err) } if len(events) != 0 { - t.Fatalf("legacy text events survived the wipe: %+v", events) + t.Fatalf("non-protobuf JSON event was decoded: %+v", events) + } + var rows int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM chat_aop_events WHERE session_id = 's1'`).Scan(&rows); err != nil || rows != 1 { + t.Fatalf("stored history rows = %d, err=%v; want preserved row", rows, err) } } @@ -80,7 +104,7 @@ func TestSQLiteStoreBackfillsDurableEventSequence(t *testing.T) { t.Fatal(err) } defer store.Close() - rows, err := store.db.Query(`SELECT hub_seq, id FROM chat_aop_events WHERE session_id = 's1' ORDER BY hub_seq`) + rows, err := store.db.Query(`SELECT cursor, id FROM chat_aop_events WHERE session_id = 's1' ORDER BY cursor`) if err != nil { t.Fatal(err) } @@ -94,21 +118,22 @@ func TestSQLiteStoreBackfillsDurableEventSequence(t *testing.T) { } got = append(got, id) if seq != len(got) { - t.Fatalf("hub_seq for %s = %d, want %d", id, seq, len(got)) + t.Fatalf("cursor for %s = %d, want %d", id, seq, len(got)) } } if len(got) != 2 || got[0] != "e1" || got[1] != "e2" { t.Fatalf("backfilled order = %v, want [e1 e2]", got) } - cursor, persisted, err := store.AppendAOPEvent(context.Background(), "s1", aop.Event{ - Type: aop.TypeStatus, TS: "2026-07-19T00:00:03Z", SessionID: "s1", Agent: "aiscan", Data: json.RawMessage(`{}`), + cursor, persisted, err := store.AppendAOPEvent(context.Background(), "s1", &aop.Event{ + Id: "e3", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 3, 0, time.UTC)), SessionId: "s1", Emitter: "aiscan", + Payload: &aop.Event_Status{Status: &aop.Status{State: "running"}}, }) if err != nil || !persisted || cursor != 3 { t.Fatalf("AppendAOPEvent cursor = %d, persisted = %v, err = %v; want 3, true, nil", cursor, persisted, err) } } -func TestSQLiteStoreMessageRoundTrip(t *testing.T) { +func TestSQLiteStoreAOPMessageRoundTrip(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "messages.db")) if err != nil { t.Fatal(err) @@ -118,108 +143,62 @@ func TestSQLiteStoreMessageRoundTrip(t *testing.T) { createStoredSession(t, store, "s1") created := time.Date(2026, 7, 19, 1, 2, 3, 0, time.UTC) - if err := store.AddMessage(ctx, &ChatMessage{ - ID: "m1", SessionID: "s1", Role: "user", Content: "hello", - Metadata: json.RawMessage(`{"code":"x"}`), CreatedAt: created, - }); err != nil { + user := &aop.Event{ + Id: "e-user", EmittedAt: timestamppb.New(created), SessionId: "s1", Emitter: "operator", + Payload: &aop.Event_Message{Message: &aop.Message{Id: "m1", Role: "user", Content: []*aop.Content{aop.Text("hello")}}}, + } + _ = ext.SetWebMessage(user, ext.WebMessageExtension{Metadata: []byte(`{"code":"x"}`)}) + if err := store.AddAOPEvent(ctx, "s1", user); err != nil { t.Fatal(err) } - assistant := aop.Event{ - Type: aop.TypeMessage, - TS: created.Add(time.Second).Format(time.RFC3339Nano), - SessionID: "s1", - Agent: "aiscan", - Data: mustJSON(aop.MessageData{ - MessageID: "m-1", Role: "assistant", - Parts: []aop.MessagePart{{Type: aop.PartText, Text: "hi there"}}, - }), + assistant := &aop.Event{ + Id: "e-message", EmittedAt: timestamppb.New(created.Add(time.Second)), SessionId: "s1", Emitter: "aiscan", + Payload: &aop.Event_Message{Message: &aop.Message{ + Id: "m-1", Role: "assistant", Content: []*aop.Content{aop.Text("hi there")}, + }}, } if err := store.AddAOPEvent(ctx, "s1", assistant); err != nil { t.Fatal(err) } // Deltas are streaming fragments and must never be persisted. - delta := aop.Event{ - Type: aop.TypeMessageDelta, - TS: created.Add(2 * time.Second).Format(time.RFC3339Nano), - SessionID: "s1", - Agent: "aiscan", - Data: mustJSON(aop.MessageDeltaData{ - MessageID: "m-1", PartIndex: 0, PartType: aop.PartText, Delta: "hi", - }), + delta := &aop.Event{ + Id: "e-delta", EmittedAt: timestamppb.New(created.Add(2 * time.Second)), SessionId: "s1", Emitter: "aiscan", + Payload: &aop.Event_MessageDelta{MessageDelta: &aop.MessageDelta{ + MessageId: "m-1", ContentIndex: 0, Value: &aop.MessageDelta_Text{Text: "hi"}, + }}, } if err := store.AddAOPEvent(ctx, "s1", delta); err != nil { t.Fatal(err) } - msgs, err := store.ListMessages(ctx, "s1", 10) + events, err := store.ListAOPEvents(ctx, "s1", 10) if err != nil { t.Fatal(err) } - if len(msgs) != 2 { - t.Fatalf("messages = %+v, want 2", msgs) + if len(events) != 2 { + t.Fatalf("events = %+v, want 2", events) } - if msgs[0].ID != "m1" || msgs[0].Role != "user" || msgs[0].Content != "hello" { - t.Fatalf("user message = %+v", msgs[0]) + if message := events[0].GetMessage(); message.GetId() != "m1" || message.GetRole() != "user" || message.GetContent()[0].GetText().GetText() != "hello" { + t.Fatalf("user event = %+v", events[0]) } var meta map[string]any - if err := json.Unmarshal(msgs[0].Metadata, &meta); err != nil || meta["code"] != "x" { - t.Fatalf("user metadata = %s, err = %v", msgs[0].Metadata, err) + webExtension, ok, err := ext.GetWebMessage(events[0]) + if err != nil || !ok { + t.Fatalf("web extension = %+v, ok = %v, err = %v", webExtension, ok, err) } - if msgs[1].ID != "m-1" || msgs[1].Role != "assistant" || msgs[1].Content != "hi there" { - t.Fatalf("assistant message = %+v", msgs[1]) + if err := json.Unmarshal(webExtension.Metadata, &meta); err != nil || meta["code"] != "x" { + t.Fatalf("user metadata = %s, err = %v", webExtension.Metadata, err) } - - events, err := store.ListAOPEvents(ctx, "s1", 10) - if err != nil { - t.Fatal(err) + if message := events[1].GetMessage(); message.GetId() != "m-1" || message.GetRole() != "assistant" || message.GetContent()[0].GetText().GetText() != "hi there" { + t.Fatalf("assistant event = %+v", events[1]) } for _, e := range events { - if e.Type == aop.TypeMessageDelta { + if e.GetMessageDelta() != nil { t.Fatalf("delta was persisted: %+v", e) } } } -func TestSQLiteStoreMessagePaginationIgnoresNonMessageDensity(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "message-pages.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - ctx := context.Background() - createStoredSession(t, store, "s1") - - for message := 1; message <= 4; message++ { - for event := 0; event < 25; event++ { - if err := store.AddAOPEvent(ctx, "s1", aop.Event{ - Type: aop.TypeStatus, TS: time.Now().UTC().Format(time.RFC3339Nano), SessionID: "s1", Agent: "aiscan", Data: json.RawMessage(`{}`), - }); err != nil { - t.Fatal(err) - } - } - if err := store.AddMessage(ctx, &ChatMessage{ - ID: string(rune('0' + message)), SessionID: "s1", Role: "user", Content: "message", CreatedAt: time.Now().UTC(), - }); err != nil { - t.Fatal(err) - } - } - - latest, err := store.ListMessagePage(ctx, "s1", 0, 2) - if err != nil { - t.Fatal(err) - } - if len(latest.Items) != 2 || latest.Items[0].ID != "3" || latest.Items[1].ID != "4" || latest.NextCursor == 0 { - t.Fatalf("latest page = %+v", latest) - } - older, err := store.ListMessagePage(ctx, "s1", latest.NextCursor, 2) - if err != nil { - t.Fatal(err) - } - if len(older.Items) != 2 || older.Items[0].ID != "1" || older.Items[1].ID != "2" || older.NextCursor != 0 { - t.Fatalf("older page = %+v", older) - } -} - func TestSQLiteStorePersistsAnalysisOptions(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) if err != nil { @@ -313,8 +292,9 @@ func TestSQLiteStoreEnablesForeignKeysAndCascadesSessionData(t *testing.T) { if err := store.CreateSession(ctx, session); err != nil { t.Fatal(err) } - if err := store.AddMessage(ctx, &ChatMessage{ - ID: "message-cascade", SessionID: session.ID, Role: "user", Content: "hello", CreatedAt: now, + if err := store.AddAOPEvent(ctx, session.ID, &aop.Event{ + Id: "event-cascade", EmittedAt: timestamppb.New(now), SessionId: session.ID, Emitter: "operator", + Payload: &aop.Event_Message{Message: &aop.Message{Id: "message-cascade", Role: "user", Content: []*aop.Content{aop.Text("hello")}}}, }); err != nil { t.Fatal(err) } @@ -336,18 +316,19 @@ func TestSQLiteStoreEnablesForeignKeysAndCascadesSessionData(t *testing.T) { } } -func TestSQLiteStoreRejectsMessageForMissingSession(t *testing.T) { +func TestSQLiteStoreRejectsAOPEventForMissingSession(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "foreign-keys.db")) if err != nil { t.Fatal(err) } defer store.Close() - err = store.AddMessage(context.Background(), &ChatMessage{ - ID: "orphan-message", SessionID: "missing", Role: "user", Content: "hello", CreatedAt: time.Now(), + err = store.AddAOPEvent(context.Background(), "missing", &aop.Event{ + Id: "orphan-event", EmittedAt: timestamppb.Now(), SessionId: "missing", Emitter: "operator", + Payload: &aop.Event_Message{Message: &aop.Message{Id: "orphan-message", Role: "user", Content: []*aop.Content{aop.Text("hello")}}}, }) if err == nil { - t.Fatal("AddMessage() created an orphan event") + t.Fatal("AddAOPEvent() created an orphan event") } } diff --git a/pkg/web/terminal/codec.go b/pkg/web/terminal/codec.go new file mode 100644 index 00000000..a511fbc8 --- /dev/null +++ b/pkg/web/terminal/codec.go @@ -0,0 +1,100 @@ +// Package terminal is the single adapter between AIScan's protobuf terminal +// transport and the internal PTY runtime model. +package terminal + +import ( + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + "github.com/chainreactors/utils/pty" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func FromProto(value *transport.TerminalFrame) pty.Frame { + if value == nil { + return pty.Frame{} + } + frame := pty.Frame{ + Type: pty.FrameType(value.Type), StreamID: value.StreamId, SessionID: value.SessionId, + Kind: value.Kind, Name: value.Name, Command: value.Command, Args: value.Args, Data: value.Data, + Cols: int(value.Cols), Rows: int(value.Rows), Bytes: int(value.Bytes), Offset: value.Offset, + Singleton: value.Singleton, Error: value.Error, State: pty.State(value.State), ExitCode: int(value.ExitCode), + } + frame.Session = InfoFromProto(value.Session) + for _, session := range value.Sessions { + if info := InfoFromProto(session); info != nil { + frame.Sessions = append(frame.Sessions, *info) + } + } + return frame +} + +func ToProto(frame pty.Frame) *transport.TerminalFrame { + value := &transport.TerminalFrame{ + Type: string(frame.Type), StreamId: frame.StreamID, SessionId: frame.SessionID, + Kind: frame.Kind, Name: frame.Name, Command: frame.Command, Args: frame.Args, Data: frame.Data, + Cols: int32(frame.Cols), Rows: int32(frame.Rows), Bytes: int32(frame.Bytes), Offset: frame.Offset, + Singleton: frame.Singleton, Error: frame.Error, State: string(frame.State), ExitCode: int32(frame.ExitCode), + } + value.Session = InfoToProto(frame.Session) + for index := range frame.Sessions { + value.Sessions = append(value.Sessions, InfoToProto(&frame.Sessions[index])) + } + return value +} + +func InfoFromProto(value *transport.TerminalInfo) *pty.Info { + if value == nil { + return nil + } + info := &pty.Info{ + ID: value.Id, Kind: value.Kind, Name: value.Name, Command: value.Command, + PID: int(value.Pid), ActivitySeq: value.ActivitySeq, OutputBytes: value.OutputBytes, + ExitCode: int(value.ExitCode), State: pty.State(value.State), KillCause: value.KillCause, + } + if value.StartedAt != nil { + info.StartedAt = value.StartedAt.AsTime() + } + if value.LastActivityAt != nil { + info.LastActivityAt = value.LastActivityAt.AsTime() + } + if value.EndedAt != nil { + info.EndedAt = value.EndedAt.AsTime() + } + return info +} + +func InfoToProto(value *pty.Info) *transport.TerminalInfo { + if value == nil { + return nil + } + info := &transport.TerminalInfo{ + Id: value.ID, Kind: value.Kind, Name: value.Name, Command: value.Command, + Pid: int32(value.PID), ActivitySeq: value.ActivitySeq, OutputBytes: value.OutputBytes, + ExitCode: int32(value.ExitCode), State: string(value.State), KillCause: value.KillCause, + } + if !value.StartedAt.IsZero() { + info.StartedAt = timestamppb.New(value.StartedAt) + } + if !value.LastActivityAt.IsZero() { + info.LastActivityAt = timestamppb.New(value.LastActivityAt) + } + if !value.EndedAt.IsZero() { + info.EndedAt = timestamppb.New(value.EndedAt) + } + return info +} + +// Marshal emits canonical protobuf JSON for the browser terminal WebSocket. +func Marshal(frame pty.Frame) ([]byte, error) { + return protojson.Marshal(ToProto(frame)) +} + +// Unmarshal accepts canonical protobuf JSON from the browser terminal +// WebSocket and returns the runtime PTY frame. +func Unmarshal(data []byte) (pty.Frame, error) { + value := new(transport.TerminalFrame) + if err := protojson.Unmarshal(data, value); err != nil { + return pty.Frame{}, err + } + return FromProto(value), nil +} diff --git a/pkg/web/terminal/codec_test.go b/pkg/web/terminal/codec_test.go new file mode 100644 index 00000000..5c1d175d --- /dev/null +++ b/pkg/web/terminal/codec_test.go @@ -0,0 +1,30 @@ +package terminal + +import ( + "testing" + "time" + + "github.com/chainreactors/utils/pty" +) + +func TestProtoJSONRoundTrip(t *testing.T) { + started := time.Date(2026, 8, 1, 1, 2, 3, 0, time.UTC) + want := pty.Frame{ + Type: pty.FrameSessions, StreamID: "stream-1", SessionID: "session-1", Data: []byte("hello"), + Sessions: []pty.Info{{ID: "session-1", Kind: "repl", StartedAt: started, ActivitySeq: 7}}, + } + raw, err := Marshal(want) + if err != nil { + t.Fatal(err) + } + got, err := Unmarshal(raw) + if err != nil { + t.Fatal(err) + } + if got.Type != want.Type || got.StreamID != want.StreamID || got.SessionID != want.SessionID || string(got.Data) != "hello" { + t.Fatalf("frame = %+v, want %+v", got, want) + } + if len(got.Sessions) != 1 || got.Sessions[0].ID != "session-1" || !got.Sessions[0].StartedAt.Equal(started) { + t.Fatalf("sessions = %+v", got.Sessions) + } +} diff --git a/pkg/web/types.go b/pkg/web/types.go index 76756e0e..9f18e2e4 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -1,19 +1,19 @@ package web import ( - "encoding/json" "errors" "time" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" + config "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/webproto" ) var ( ErrScanNotFound = errors.New("scan not found") ErrScanNotCancelable = errors.New("scan cannot be canceled") ErrSessionNotFound = errors.New("session not found") + ErrTurnNotFound = errors.New("turn not found") ) type ScanStatus string @@ -42,14 +42,6 @@ type ScanJob struct { UpdatedAt time.Time `json:"updated_at"` } -type ScanRequest struct { - Target string `json:"target"` - Mode string `json:"mode"` - Verify bool `json:"verify,omitempty"` - Sniper bool `json:"sniper,omitempty"` - Deep bool `json:"deep,omitempty"` -} - type ServiceStatus struct { Version string `json:"version"` LLMAvailable bool `json:"llm_available"` @@ -124,7 +116,7 @@ type LLMProfileStatus struct { } // ConfigStatusFromDistribute builds a masked ConfigStatus from raw config. -func ConfigStatusFromDistribute(d *webproto.DistributeConfig, path string, loaded bool) ConfigStatus { +func ConfigStatusFromDistribute(d *config.DistributeConfig, path string, loaded bool) ConfigStatus { var cs ConfigStatus cs.ConfigPath = path cs.ConfigLoaded = loaded @@ -138,7 +130,7 @@ func ConfigStatusFromDistribute(d *webproto.DistributeConfig, path string, loade cs.LLM.ContextWindow = active.ContextWindow cs.LLM.ActiveProfile = d.LLM.ActiveProfile for _, profile := range d.LLM.Providers { - profile = webproto.NormalizeLLMProvider(profile) + profile = config.NormalizeLLMProvider(profile) cs.LLM.Profiles = append(cs.LLM.Profiles, LLMProfileStatus{ ID: profile.ID, Name: profile.Name, Provider: profile.Provider, BaseURL: profile.BaseURL, APIKeyConfigured: profile.APIKey != "", @@ -187,40 +179,11 @@ type ChatSession struct { UpdatedAt time.Time `json:"updated_at"` } -type ChatMessage struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - Role string `json:"role"` - AgentID string `json:"agent_id,omitempty"` - AgentName string `json:"agent_name,omitempty"` - Content string `json:"content"` - Metadata json.RawMessage `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - Cursor int64 `json:"cursor,omitempty"` - // Queued is a transient send-time hint: true when the message was accepted - // while another chat task is still running on the session, so the client - // can render it as pending-in-queue rather than in-flight. - Queued bool `json:"queued,omitempty"` -} - -type ChatMessagePage struct { - Items []*ChatMessage `json:"items"` - NextCursor int64 `json:"next_cursor,omitempty"` -} - type persistedAOPEvent struct { Cursor int64 - Event aop.Event + Event *aop.Event } -const ( - DomainEventScanStarted = "scan_started" - DomainEventScanProgress = "scan_progress" - DomainEventScanComplete = "scan_complete" - DomainEventAgentJoined = "agent_joined" - DomainEventSessionCleared = "session_cleared" -) - // System message codes. A backend-generated system message carries a stable // Code (+ optional Params) so the client can localize it via i18n; Content // holds an English fallback for non-i18n consumers, logs and tests. Keys are @@ -233,28 +196,3 @@ const ( SysAgentsList = "agents_list" // params: count, agents[] SysAgentNotConnected = "agent_not_connected" ) - -type DomainEvent struct { - Type string `json:"type"` - SessionID string `json:"session_id"` - AgentID string `json:"agent_id,omitempty"` - AgentName string `json:"agent_name,omitempty"` - ScanID string `json:"scan_id,omitempty"` - Result *output.Result `json:"result,omitempty"` - Data string `json:"data,omitempty"` - Transient bool `json:"-"` -} - -type SendMessageRequest struct { - Content string `json:"content"` - // Goal-mode run controls (optional). The frontend sends these when the user - // enables the Goal panel; a plain chat send leaves them zero. - EvalCriteria string `json:"eval_criteria,omitempty"` - EvalMaxRounds int `json:"eval_max_rounds,omitempty"` - PersistMaxTurns int `json:"persist_max_turns,omitempty"` -} - -type CreateSessionRequest struct { - AgentID string `json:"agent_id"` - Title string `json:"title,omitempty"` -} diff --git a/pkg/web/upload_test.go b/pkg/web/upload_test.go index d6a403d6..351b40d4 100644 --- a/pkg/web/upload_test.go +++ b/pkg/web/upload_test.go @@ -1,79 +1,34 @@ package web import ( - "bytes" "context" "errors" - "mime/multipart" - "net/http" "net/http/httptest" "path/filepath" "testing" "time" - "github.com/chainreactors/aiscan/pkg/webproto" + "connectrpc.com/connect" + chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" + "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" + transport "github.com/chainreactors/aiscan/aop/aiscan/transport" ) -func newMultipartUploadRequest(t *testing.T, filename string, data []byte) *http.Request { - t.Helper() - var body bytes.Buffer - writer := multipart.NewWriter(&body) - part, err := writer.CreateFormFile("file", filename) - if err != nil { - t.Fatal(err) - } - if _, err := part.Write(data); err != nil { - t.Fatal(err) - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - req := httptest.NewRequest("POST", "/upload", &body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - return req -} - -func TestReadMultipartUploadEnforcesExactFileLimit(t *testing.T) { - for _, size := range []int{7, 8} { - req := newMultipartUploadRequest(t, "note.txt", bytes.Repeat([]byte("x"), size)) - filename, data, err := readMultipartUpload(httptest.NewRecorder(), req, 8) - if err != nil { - t.Fatalf("size %d: %v", size, err) - } - if filename != "note.txt" || len(data) != size { - t.Fatalf("size %d: filename=%q bytes=%d", size, filename, len(data)) - } - } - - req := newMultipartUploadRequest(t, "large.txt", bytes.Repeat([]byte("x"), 9)) - if _, _, err := readMultipartUpload(httptest.NewRecorder(), req, 8); !errors.Is(err, ErrUploadTooLarge) { - t.Fatalf("size 9 error = %v, want ErrUploadTooLarge", err) - } -} - -func TestReadMultipartUploadRejectsMalformedBody(t *testing.T) { - req := httptest.NewRequest("POST", "/upload", bytes.NewBufferString("not multipart")) - req.Header.Set("Content-Type", "multipart/form-data; boundary=missing") - if _, _, err := readMultipartUpload(httptest.NewRecorder(), req, 8); err == nil || errors.Is(err, ErrUploadTooLarge) { - t.Fatalf("malformed multipart error = %v", err) - } -} - -func TestUploadReturnsNotFoundForMissingSession(t *testing.T) { +func TestUploadConnectRPCRejectsMissingSession(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "upload.db")) if err != nil { t.Fatal(err) } defer store.Close() - handler := NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "") - req := newMultipartUploadRequest(t, "note.txt", []byte("hello")) - req.URL.Path = "/api/chat/sessions/missing/upload" - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - if recorder.Code != http.StatusNotFound { - t.Fatalf("status = %d, body = %s; want 404", recorder.Code, recorder.Body.String()) + server := httptest.NewServer(NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "")) + defer server.Close() + client := chatconnect.NewSessionServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) + response, err := client.UploadSessionFile(context.Background(), connect.NewRequest(&chatpb.UploadSessionFileRequest{ + RequestId: "upload-1", SessionId: "missing", Filename: "note.txt", Data: []byte("hello"), + })) + if err != nil || response.Msg.GetRejected().GetCode() != "NOT_FOUND" { + t.Fatalf("UploadSessionFile = %v, %v", response, err) } } @@ -105,7 +60,7 @@ func TestHandleFileUploadCancellationRemovesPendingAgentTask(t *testing.T) { done <- err }() - var upload webproto.Message + var upload *transport.ServerFrame select { case upload = <-remote.sendCh: case <-time.After(time.Second): @@ -122,14 +77,15 @@ func TestHandleFileUploadCancellationRemovesPendingAgentTask(t *testing.T) { } remote.mu.Lock() - _, pending := remote.tasks[upload.TaskID] + taskID := upload.GetFileUpload().GetTaskId() + _, pending := remote.tasks[taskID] remote.mu.Unlock() if pending { t.Fatal("canceled upload remained in the agent task map") } select { case msg := <-remote.controlCh: - if msg.Type != webproto.TypeRunCancel || msg.TurnID != upload.TaskID { + if msg.GetCancelTurn().GetTurnId() != taskID { t.Fatalf("upload cancel frame = %+v", msg) } default: diff --git a/pkg/web/validation.go b/pkg/web/validation.go index 487f2d28..01644f1a 100644 --- a/pkg/web/validation.go +++ b/pkg/web/validation.go @@ -7,14 +7,14 @@ import ( "strings" agentprovider "github.com/chainreactors/aiscan/agent/provider" - "github.com/chainreactors/aiscan/pkg/webproto" + config "github.com/chainreactors/aiscan/core/config" ) // ValidateLLMConfig accepts zero limits as "use the model default" and rejects // incomplete profiles before an invalid configuration can be persisted. -func ValidateLLMConfig(cfg webproto.LLMConfig) error { +func ValidateLLMConfig(cfg config.LLMConfig) error { for i, profile := range cfg.Providers { - profile = webproto.NormalizeLLMProvider(profile) + profile = config.NormalizeLLMProvider(profile) if !agentprovider.IsSupportedProvider(profile.Provider) { return fmt.Errorf("LLM provider %q is unsupported: use openai or anthropic", profile.Provider) } diff --git a/pkg/web/validation_test.go b/pkg/web/validation_test.go index 676b1f45..5249106f 100644 --- a/pkg/web/validation_test.go +++ b/pkg/web/validation_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - "github.com/chainreactors/aiscan/pkg/webproto" + config "github.com/chainreactors/aiscan/core/config" ) func TestValidateLLMConfigRejectsUnsupportedProvider(t *testing.T) { - cfg := webproto.LLMConfig{Providers: []webproto.LLMProviderConfig{{ + cfg := config.LLMConfig{Providers: []config.LLMProviderConfig{{ Provider: "deepseek", Model: "deepseek-chat", }}} diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go deleted file mode 100644 index 1c550f6c..00000000 --- a/pkg/webagent/agent_test.go +++ /dev/null @@ -1,513 +0,0 @@ -package webagent - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/capability" - cfg "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" - "github.com/chainreactors/ioa/protocols" - "github.com/chainreactors/utils/pty" - "github.com/gorilla/websocket" -) - -func TestWebNodeRefUsesWebIdentity(t *testing.T) { - ref, err := webNodeRef(&cfg.Option{ - AgentOptions: cfg.AgentOptions{WebURL: "https://secret@example.test/hub"}, - IOAOptions: cfg.IOAOptions{IOANodeName: "worker-1"}, - }) - if err != nil { - t.Fatal(err) - } - if ref.ID != "worker-1" || ref.Authority != "https://example.test/hub" { - t.Fatalf("node ref = %#v", ref) - } - if _, err := webNodeRef(&cfg.Option{AgentOptions: cfg.AgentOptions{WebURL: "https://example.test"}}); err == nil { - t.Fatal("expected missing ioa.node_name error") - } -} - -func connectForTest(ctx context.Context, serverURL, name string, reg *commands.CommandRegistry, bus *eventbus.Bus[aop.Event]) error { - if _, ok := reg.GetTool("bash"); !ok { - bash := commands.NewBashTool(".", 5) - bash.SetCommandResolver(reg.Get) - reg.RegisterTool(bash) - defer bash.Close() - } - return connect(ctx, connectionConfig{ - ServerURL: serverURL, - Name: name, - Registry: reg, - AgentSubscribe: func(fn func(aop.Event)) func() { - if bus == nil { - return func() {} - } - return bus.Subscribe(fn) - }, - DataBus: eventbus.New[output.ToolDataEvent](), - Node: protocols.NodeRef{ID: "node-" + name, Authority: serverURL}, - }) -} - -type webConnectionTestCommand struct{} - -func (c webConnectionTestCommand) Name() string { return "echo" } -func (c webConnectionTestCommand) Usage() string { return "echo" } - -func (c webConnectionTestCommand) Run(ctx context.Context, execution *commands.Execution) (any, error) { - fmt.Fprintf(execution.Stdout, "progress: %s\n", strings.Join(execution.Args, " ")) - return nil, nil -} - -func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) { - var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} - registered := make(chan struct{}) - var registeredOnce sync.Once - messages := make(chan webproto.Message, 8) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/agent/ws" { - http.NotFound(w, r) - return - } - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Errorf("upgrade: %v", err) - return - } - defer conn.Close() - - var reg webproto.Message - if err := conn.ReadJSON(®); err != nil { - t.Errorf("register read: %v", err) - return - } - if reg.Type != "register" || !strings.Contains(string(reg.Payload), "echo") { - t.Errorf("unexpected register: %+v", reg) - return - } - ack, _ := json.Marshal(map[string]string{"agent_id": "agent-1"}) - if err := conn.WriteJSON(webproto.Message{Type: "connected", Payload: ack}); err != nil { - t.Errorf("ack write: %v", err) - return - } - registeredOnce.Do(func() { close(registered) }) - - call := aop.ToolCallData{ - ToolCallID: "task-1", - ToolName: "bash", - Args: map[string]any{"command": `echo "hello world"`}, - } - payload, _ := json.Marshal(toolEvent(t, call)) - if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: payload}); err != nil { - t.Errorf("tool.call write: %v", err) - return - } - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - return - } - messages <- msg - if msg.Type == webproto.TypeAOP { - return - } - } - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - bus := eventbus.New[aop.Event]() - reg := commands.NewRegistry() - impl := webConnectionTestCommand{} - reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run}, "test") - - done := make(chan error, 1) - go func() { - done <- connectForTest(ctx, srv.URL, "worker", reg, bus) - }() - - select { - case <-registered: - case <-time.After(time.Second): - t.Fatal("web agent connection did not register") - } - - seenOutput := false - seenResult := false - deadline := time.After(3 * time.Second) - for !seenResult { - select { - case msg := <-messages: - if msg.Type != webproto.TypeAOP && msg.TaskID != "task-1" { - t.Fatalf("message missing task id: %+v", msg) - } - switch msg.Type { - case "tool.data": - var ev output.ToolDataEvent - if json.Unmarshal(msg.Payload, &ev) == nil && ev.Kind == output.ToolDataProgress { - if line, ok := ev.Data.(string); ok && strings.Contains(line, "hello world") { - seenOutput = true - } - } - case webproto.TypeAOP: - var event aop.Event - if json.Unmarshal(msg.Payload, &event) == nil && event.Type == aop.TypeToolResult { - seenResult = true - } - } - case <-deadline: - t.Fatal("timeout waiting for web agent messages") - } - } - - if !seenOutput { - t.Fatal("web agent connection did not stream command output") - } - - cancel() - <-done -} - -func TestRunConnectionChatWithoutRuntimeReturnsClearError(t *testing.T) { - var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} - registered := make(chan struct{}) - var registeredOnce sync.Once - messages := make(chan webproto.Message, 4) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/agent/ws" { - http.NotFound(w, r) - return - } - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Errorf("upgrade: %v", err) - return - } - defer conn.Close() - - var reg webproto.Message - if err := conn.ReadJSON(®); err != nil { - t.Errorf("register read: %v", err) - return - } - ack, _ := json.Marshal(map[string]string{"agent_id": "agent-1"}) - if err := conn.WriteJSON(webproto.Message{Type: "connected", Payload: ack}); err != nil { - t.Errorf("ack write: %v", err) - return - } - registeredOnce.Do(func() { close(registered) }) - - if err := conn.WriteJSON(webproto.Message{Type: "chat", TaskID: "task-chat", Data: "hello"}); err != nil { - t.Errorf("chat write: %v", err) - return - } - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - return - } - messages <- msg - if msg.Type == "error" { - return - } - } - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - reg := commands.NewRegistry() - impl := webConnectionTestCommand{} - reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run}, "test") - - done := make(chan error, 1) - go func() { - done <- connectForTest(ctx, srv.URL, "worker", reg, nil) - }() - - select { - case <-registered: - case <-time.After(time.Second): - t.Fatal("web agent connection did not register") - } - - // Without a chat handler, the connection does not dispatch "chat" messages, - // so the hub never gets an error reply. This test now verifies that the - // connection stays stable when chat arrives without a handler. - select { - case msg := <-messages: - // If the node happened to reply, accept it. - if msg.Type != "error" { - t.Logf("unexpected message: %+v (expected no reply for chat without handler)", msg) - } - case <-time.After(1 * time.Second): - // Expected: no reply because Chat is nil. - } - - cancel() - <-done -} - -func TestRunConnectionPTYRoundTrip(t *testing.T) { - var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} - registered := make(chan struct{}) - var registeredOnce sync.Once - result := make(chan string, 1) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/agent/ws" { - http.NotFound(w, r) - return - } - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Errorf("upgrade: %v", err) - return - } - defer conn.Close() - - var reg webproto.Message - if err := conn.ReadJSON(®); err != nil { - t.Errorf("register read: %v", err) - return - } - ack, _ := json.Marshal(map[string]string{"agent_id": "agent-pty"}) - if err := conn.WriteJSON(webproto.Message{Type: "connected", Payload: ack}); err != nil { - t.Errorf("ack write: %v", err) - return - } - registeredOnce.Do(func() { close(registered) }) - - if err := conn.WriteJSON(webproto.NewPTYMessage(pty.Frame{Type: pty.FrameOpen, StreamID: "term-1"})); err != nil { - t.Errorf("pty.open write: %v", err) - return - } - - opened := false - inputSent := false - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - return - } - if msg.Type != webproto.TypePTY { - continue - } - frame, err := webproto.DecodePTYMessage(msg) - if err != nil { - result <- "error: " + err.Error() - return - } - switch frame.Type { - case pty.FrameOpened: - opened = true - lineEnding := "\n" - if runtime.GOOS == "windows" { - lineEnding = "\r\n" - } - if err := conn.WriteJSON(webproto.NewPTYMessage(pty.Frame{Type: pty.FrameInput, StreamID: "term-1", Data: []byte("echo pty_web_ok" + lineEnding)})); err != nil { - t.Errorf("pty.input write: %v", err) - return - } - inputSent = true - case pty.FrameOutput: - if opened && inputSent && strings.Contains(string(frame.Data), "pty_web_ok") { - _ = conn.WriteJSON(webproto.NewPTYMessage(pty.Frame{Type: pty.FrameKill, StreamID: "term-1"})) - result <- string(frame.Data) - return - } - case pty.FrameError: - result <- "error: " + frame.Error - return - } - } - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - - reg := commands.NewRegistry() - commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5}, reg) - - done := make(chan error, 1) - go func() { - done <- connectForTest(ctx, srv.URL, "worker", reg, nil) - }() - - select { - case <-registered: - case <-time.After(time.Second): - t.Fatal("web agent connection did not register") - } - - select { - case out := <-result: - if !strings.Contains(out, "pty_web_ok") { - t.Fatalf("unexpected pty output: %q", out) - } - case <-time.After(6 * time.Second): - t.Fatal("timeout waiting for pty output") - } - - cancel() - <-done -} - -func TestRunConnectionPushesPTYSessionsOnManagerEvents(t *testing.T) { - var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} - registered := make(chan struct{}) - var registeredOnce sync.Once - sessionUpdates := make(chan pty.Frame, 8) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/agent/ws" { - http.NotFound(w, r) - return - } - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Errorf("upgrade: %v", err) - return - } - defer conn.Close() - - var reg webproto.Message - if err := conn.ReadJSON(®); err != nil { - t.Errorf("register read: %v", err) - return - } - ack, _ := json.Marshal(map[string]string{"agent_id": "agent-live"}) - if err := conn.WriteJSON(webproto.Message{Type: "connected", Payload: ack}); err != nil { - t.Errorf("ack write: %v", err) - return - } - registeredOnce.Do(func() { close(registered) }) - - if err := conn.WriteJSON(webproto.NewPTYMessage(pty.Frame{Type: pty.FrameList, StreamID: "term-live"})); err != nil { - t.Errorf("pty.list write: %v", err) - return - } - - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - return - } - if msg.Type != webproto.TypePTY { - continue - } - frame, err := webproto.DecodePTYMessage(msg) - if err == nil && frame.Type == pty.FrameSessions && frame.StreamID == "term-live" { - sessionUpdates <- frame - } - } - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - reg := commands.NewRegistry() - commands.BuildPlan(capability.Select(capability.Options{Groups: []string{"core"}}), &commands.Deps{WorkDir: t.TempDir(), BashTimeout: 5}, reg) - mgr := RegistryPTYManager(reg) - if mgr == nil { - t.Fatal("bash command did not expose tmux manager") - } - - done := make(chan error, 1) - go func() { - done <- connectForTest(ctx, srv.URL, "worker", reg, nil) - }() - - select { - case <-registered: - case <-time.After(time.Second): - t.Fatal("web agent connection did not register") - } - - // Drain the explicit pty.list response so later reads prove event-driven pushes. - readSessionUpdate(t, sessionUpdates, func(pty.Frame) bool { return true }) - - release := make(chan struct{}) - info, err := mgr.CreateFunc(ctx, "live-session", 5*time.Second, func(ctx context.Context, w io.Writer) error { - _, _ = w.Write([]byte("live\n")) - select { - case <-release: - return nil - case <-ctx.Done(): - return ctx.Err() - } - }) - if err != nil { - t.Fatalf("CreateFunc: %v", err) - } - - readSessionUpdate(t, sessionUpdates, func(frame pty.Frame) bool { - return frameHasSessionState(frame, info.ID, "running") - }) - readSessionUpdate(t, sessionUpdates, func(frame pty.Frame) bool { - return frameHasSessionActivity(frame, info.ID) - }) - - close(release) - readSessionUpdate(t, sessionUpdates, func(frame pty.Frame) bool { - return frameHasSessionState(frame, info.ID, "completed") - }) - - cancel() - <-done -} - -func readSessionUpdate(t *testing.T, updates <-chan pty.Frame, match func(pty.Frame) bool) pty.Frame { - t.Helper() - deadline := time.After(20 * time.Second) - for { - select { - case frame := <-updates: - if match(frame) { - return frame - } - case <-deadline: - t.Fatal("timeout waiting for pty.sessions update") - return pty.Frame{} - } - } -} - -func frameHasSessionState(frame pty.Frame, sessionID, state string) bool { - for _, session := range frame.Sessions { - if session.ID == sessionID && string(session.State) == state { - return true - } - } - return false -} - -func frameHasSessionActivity(frame pty.Frame, sessionID string) bool { - for _, session := range frame.Sessions { - if session.ID == sessionID && session.ActivitySeq >= 2 && session.OutputBytes > 0 { - return true - } - } - return false -} diff --git a/pkg/webagent/aop_tool_test.go b/pkg/webagent/aop_tool_test.go deleted file mode 100644 index 7c79ab25..00000000 --- a/pkg/webagent/aop_tool_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package webagent - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" -) - -type aopTestExecutor struct{} - -func (aopTestExecutor) ExecuteTool(_ context.Context, name, arguments string) (tool.Result, error) { - return tool.TextResult(name + ":" + arguments), nil -} - -func toolCommand(toolCallID, toolName string, args map[string]any) aop.ToolCallData { - return aop.ToolCallData{ - ToolCallID: toolCallID, - ToolName: toolName, - Args: args, - } -} - -func toolEvent(t *testing.T, call aop.ToolCallData) aop.Event { - t.Helper() - data, err := json.Marshal(call) - if err != nil { - t.Fatal(err) - } - return aop.Event{ - Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano), - SessionID: "session-1", TurnID: call.ToolCallID, Agent: "worker", Data: data, - } -} - -func decodeToolResult(t *testing.T, msg webproto.Message) aop.ToolResultData { - t.Helper() - if msg.Type != webproto.TypeAOP { - t.Fatalf("result envelope = %+v", msg) - } - var event aop.Event - if err := json.Unmarshal(msg.Payload, &event); err != nil { - t.Fatal(err) - } - if event.Type != aop.TypeToolResult { - t.Fatalf("result event = %+v", event) - } - result, err := aop.DecodeData[aop.ToolResultData](event) - if err != nil { - t.Fatal(err) - } - return result -} - -func TestHandleToolCallEvent(t *testing.T) { - var got webproto.Message - call := toolCommand("call-1", "echo", map[string]any{"value": "hello"}) - HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "call-1"}, toolEvent(t, call), - aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg }) - - if got.TaskID != "call-1" { - t.Fatalf("result envelope = %+v", got) - } - result := decodeToolResult(t, got) - if result.ToolCallID != "call-1" || result.ToolName != "echo" || !strings.Contains(aop.ToolResultText(result.Content), "echo") { - t.Fatalf("result data = %+v", result) - } -} - -func TestHandleToolCallEventRejectsMismatchedCorrelation(t *testing.T) { - var got webproto.Message - call := toolCommand("call-1", "echo", map[string]any{"value": "hello"}) - HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "other"}, toolEvent(t, call), - aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg }) - if got.Type != webproto.TypeError || got.TaskID != "other" { - t.Fatalf("error envelope = %+v", got) - } -} - -type recordingBash struct { - command string - options commands.BashExecOptions -} - -func (*recordingBash) Name() string { return "bash" } -func (*recordingBash) Description() string { return "test bash" } -func (*recordingBash) Definition() tool.Definition { - return tool.Def("bash", "test bash", struct { - Command string `json:"command"` - }{}) -} -func (*recordingBash) Execute(context.Context, string) (tool.Result, error) { - return tool.Result{}, nil -} -func (b *recordingBash) RunForegroundTool(_ context.Context, command string, options commands.BashExecOptions) (tool.Result, error) { - b.command = command - b.options = options - options.OnOutput([]byte("streamed\n")) - result := tool.TextResult("streamed") - result.Details = &output.Result{Summary: output.Summary{Targets: 2}} - return result, nil -} - -// TestHandleToolCallEventForeground verifies that a foreground-capable tool is -// run via RunForegroundTool, that output lines stream as tool.data progress -// events correlated by the call session id, and that the tool.result carries -// the text content plus structured Details. -func TestHandleToolCallEventForeground(t *testing.T) { - reg := commands.NewRegistry() - bash := &recordingBash{} - reg.RegisterTool(bash) - - dataBus := eventbus.New[output.ToolDataEvent]() - var progress []output.ToolDataEvent - dataBus.Subscribe(func(ev output.ToolDataEvent) { - if ev.Kind == output.ToolDataProgress { - progress = append(progress, ev) - } - }) - - var got webproto.Message - call := toolCommand("task-1", "bash", map[string]any{"command": "echo test", "timeout": 7}) - HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1"}, toolEvent(t, call), - reg, dataBus, func(msg webproto.Message) { got = msg }) - - if bash.command != "echo test" || bash.options.Timeout != 7*time.Second { - t.Fatalf("bash options = %+v", bash.options) - } - if len(progress) != 1 || progress[0].Data != "streamed" || progress[0].CallID != "task-1" || progress[0].Tool != "bash" { - t.Fatalf("progress events = %+v", progress) - } - - result := decodeToolResult(t, got) - if result.IsError || aop.ToolResultText(result.Content) != "streamed" { - t.Fatalf("result data = %+v", result) - } - details, _ := json.Marshal(result.Details) - var structured output.Result - if err := json.Unmarshal(details, &structured); err != nil { - t.Fatalf("decode structured details: %v", err) - } - if structured.Summary.Targets != 2 { - t.Fatalf("structured details = %+v", structured) - } -} diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go deleted file mode 100644 index 88888bca..00000000 --- a/pkg/webagent/connection.go +++ /dev/null @@ -1,378 +0,0 @@ -package webagent - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "sync" - "time" - - "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/telemetry" - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" - "github.com/chainreactors/ioa/protocols" - "github.com/chainreactors/utils/pty" - "github.com/gorilla/websocket" -) - -// DefaultWSPath is the default WebSocket endpoint for agent connections. -const DefaultWSPath = "/api/agent/ws" - -// connectionConfig holds all the parameters needed to establish and run a -// WebSocket connection to the hub. -type connectionConfig struct { - ServerURL string - WSPath string - Name string - // Token is an explicit bearer token; when empty the token embedded in - // ServerURL's userinfo is used instead. - Token string - Registry *commands.CommandRegistry - AgentSubscribe func(func(aop.Event)) func() - // DataBus and SCO enable tool.data / tool.sco event emission for tool-only - // nodes; both are optional. - DataBus *eventbus.Bus[output.ToolDataEvent] - SCO *output.SCOSidecar - Logger telemetry.Logger - Chat chatHandler - Node protocols.NodeRef - Runtime webproto.AgentRuntime - Status func() webproto.AgentStatus - Menu func() []webproto.CommandSpec // nil = no command menu - // RunnerFileRPC enables runner-only native directory operations. Regular - // aiscan agents neither advertise nor accept these RPCs. - RunnerFileRPC bool - - // PTYRouter creates a connection-scoped router. Agent transports receive it - // from AgentRuntime; tool-only nodes fall back to their registry manager. - PTYRouter func() (*pty.Router, error) -} - -// chatHandler defines the Agent Runtime chat callbacks used by WebSocket transport. -// Implementations live in webagent or other packages that have access to the -// agent runtime and provider. -type chatHandler interface { - HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool - - // HandleUpload processes a file upload message. - HandleUpload(msg webproto.Message, send func(webproto.Message)) - - // HandleConfigReload processes a hub config push (LLM provider/model/key change). - HandleConfigReload(serverURL string, send func(webproto.Message)) -} - -// connect implements the reconnect loop. It calls connectOnce in a loop with -// agent.RetryDelay backoff. This is the main entry point for establishing a -// persistent WebSocket connection. -func connect(ctx context.Context, cc connectionConfig) error { - if cc.WSPath == "" { - cc.WSPath = DefaultWSPath - } - logger := cc.Logger - if logger == nil { - logger = telemetry.NopLogger() - } - - attempt := 0 - for { - if ctx.Err() != nil { - return ctx.Err() - } - err := connectOnce(ctx, cc, logger) - if ctx.Err() != nil { - return ctx.Err() - } - if err != nil { - delay := agent.RetryDelay(attempt) - attempt++ - logger.Warnf("connection lost (attempt %d), retrying in %v: %v", attempt, delay, err) - select { - case <-ctx.Done(): - return nil - case <-time.After(delay): - } - } else { - attempt = 0 - } - } -} - -func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logger) error { - if cc.Registry == nil { - return fmt.Errorf("command registry is nil") - } - dialURL, accessKey := SplitAccessKey(cc.ServerURL) - if cc.Token != "" { - accessKey = cc.Token - } - wsURL := HTTPToWS(dialURL) + cc.WSPath - var reqHeader http.Header - if accessKey != "" { - reqHeader = http.Header{"Authorization": {"Bearer " + accessKey}} - } - conn, wsResp, err := websocket.DefaultDialer.DialContext(ctx, wsURL, reqHeader) - if wsResp != nil && wsResp.Body != nil { - wsResp.Body.Close() - } - if err != nil { - return fmt.Errorf("ws dial: %w", err) - } - defer conn.Close() - connectionCtx, connectionCancel := context.WithCancel(ctx) - defer connectionCancel() - - sendCh := make(chan webproto.Message, 64) - done := make(chan struct{}) - writeErr := make(chan error, 1) - defer close(done) - - send := func(m webproto.Message) { - select { - case sendCh <- m: - case <-done: - } - } - - stats := NewAgentStatsTracker() - registration, err := RegisterPayload(cc.Name, cc.Registry, cc.Node, cc.Runtime, cc.Status, cc.Menu, stats.Snapshot()) - if err != nil { - return err - } - regPayload, _ := json.Marshal(registration) - if err := conn.WriteJSON(webproto.Message{Type: "register", Payload: regPayload}); err != nil { - return fmt.Errorf("register: %w", err) - } - - var ack webproto.Message - if err := conn.ReadJSON(&ack); err != nil || ack.Type != "connected" { - return fmt.Errorf("expected connected ack") - } - - // Writer goroutine: sendCh -> WebSocket. - go func() { - fail := func(err error) { - select { - case writeErr <- err: - default: - } - // A failed writer must wake the reader so connectOnce returns and the - // outer loop establishes a fresh connection. - _ = conn.Close() - } - for { - select { - case msg, ok := <-sendCh: - if !ok { - return - } - if err := conn.WriteJSON(msg); err != nil { - fail(err) - return - } - case <-connectionCtx.Done(): - if err := conn.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")); err != nil { - fail(err) - return - } - _ = conn.Close() - return - case <-done: - return - } - } - }() - - if cc.Status != nil { - go func(last webproto.AgentStatus) { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - next := cc.Status() - if next != last { - payload, _ := json.Marshal(next) - send(webproto.Message{Type: "agent.status", Payload: payload}) - last = next - } - case <-connectionCtx.Done(): - return - case <-done: - return - } - } - }(registration.Status) - } - - // Context close goroutine. - go func() { - select { - case <-connectionCtx.Done(): - conn.Close() - case <-done: - } - }() - - var mu sync.Mutex - execTasks := make(map[string]context.CancelFunc) // active tool.call tasks - - // Tool telemetry: scanner tool.data and normalized tool.sco events ride the - // same connection, correlated to the calling task by call ID. - if detach := attachToolEvents(cc.DataBus, cc.SCO, send); detach != nil { - defer detach() - } - - // Runtime AOP is forwarded verbatim. A Run API is correlated exclusively by - // turn_id, so no connection-local session routing table is needed. - if cc.AgentSubscribe != nil { - unsub := cc.AgentSubscribe(func(e aop.Event) { - if next, ok := stats.Observe(e); ok { - statsPayload, _ := json.Marshal(next) - send(webproto.Message{Type: "agent.stats", Payload: statsPayload}) - } - payload, err := json.Marshal(e) - if err != nil { - return - } - send(webproto.Message{ - Type: webproto.TypeAOP, - TurnID: e.TurnID, - Payload: payload, - }) - }) - defer unsub() - } - - // PTY router setup. - var ptyRouter *pty.Router - if cc.PTYRouter != nil { - ptyRouter, err = cc.PTYRouter() - } else { - ptyRouter = NewPTYRouter(cc.Registry) - } - if err != nil { - return err - } - defer ptyRouter.Close() - if cc.PTYRouter == nil { - if mgr := RegistryPTYManager(cc.Registry); mgr != nil { - unsub := SubscribePTYSessions(connectionCtx, mgr, ptyRouter, send) - defer unsub() - } - } - - // Main message dispatch loop. - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - select { - case writerErr := <-writeErr: - return fmt.Errorf("ws write: %w", writerErr) - default: - } - return err - } - if ctx.Err() != nil { - return nil - } - - if msg.Type == webproto.TypePTY { - frame, err := webproto.DecodePTYMessage(msg) - if err != nil { - send(webproto.NewPTYMessage(pty.Frame{Type: pty.FrameError, StreamID: frame.StreamID, Error: err.Error()})) - continue - } - ptyRouter.Handle(connectionCtx, frame, func(out pty.Frame) { - send(webproto.NewPTYMessage(out)) - }) - continue - } - - switch msg.Type { - case webproto.TypeSessionOpen, webproto.TypeSessionClose, webproto.TypeRun, webproto.TypeRunCancel: - if cc.Chat != nil { - cc.Chat.HandleProtocol(connectionCtx, msg, send) - } - - case webproto.TypeCommand: - if cc.Chat != nil { - cc.Chat.HandleProtocol(connectionCtx, msg, send) - } - - case webproto.TypeAOP: - var event aop.Event - if err := json.Unmarshal(msg.Payload, &event); err != nil { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: "decode AOP: " + err.Error()}) - send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload}) - continue - } - taskCtx, cancel := context.WithCancel(connectionCtx) - mu.Lock() - execTasks[msg.TaskID] = cancel - mu.Unlock() - go func(m webproto.Message, event aop.Event) { - defer cancel() - defer func() { - mu.Lock() - delete(execTasks, m.TaskID) - mu.Unlock() - }() - HandleToolCallEvent(taskCtx, m, event, cc.Registry, cc.DataBus, send) - }(msg, event) - - case "upload": - if cc.Chat != nil { - go cc.Chat.HandleUpload(msg, send) - } - - case "file.read": - go HandleFileRead(msg, cc.Runtime.WorkingDir, send) - - case "file.write": - go HandleFileWrite(msg, cc.Runtime.WorkingDir, send) - - case "file.list": - if cc.RunnerFileRPC { - go HandleFileList(msg, cc.Runtime.WorkingDir, send) - } - - case "file.mkdir": - if cc.RunnerFileRPC { - go HandleFileMkdir(msg, cc.Runtime.WorkingDir, send) - } - - case "exec": - taskCtx, cancel := context.WithCancel(connectionCtx) - mu.Lock() - execTasks[msg.TaskID] = cancel - mu.Unlock() - go func(m webproto.Message) { - defer cancel() - defer func() { - mu.Lock() - delete(execTasks, m.TaskID) - mu.Unlock() - }() - HandleExec(taskCtx, m, cc.Runtime.WorkingDir, send) - }(msg) - - case "config": - if cc.Chat != nil { - go cc.Chat.HandleConfigReload(cc.ServerURL, send) - } - - case "cancel": - mu.Lock() - if cancel, ok := execTasks[msg.TaskID]; ok { - cancel() - } - mu.Unlock() - } - } -} diff --git a/pkg/webagent/connection_lifecycle_test.go b/pkg/webagent/connection_lifecycle_test.go deleted file mode 100644 index a5233f4c..00000000 --- a/pkg/webagent/connection_lifecycle_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package webagent - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/chainreactors/aiscan/core/telemetry" - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" - "github.com/chainreactors/ioa/protocols" - "github.com/gorilla/websocket" -) - -type disconnectChatHandler struct { - started chan struct{} - canceled chan struct{} - once sync.Once -} - -func (h *disconnectChatHandler) HandleProtocol(ctx context.Context, msg webproto.Message, _ func(webproto.Message)) bool { - if msg.Type != webproto.TypeRun { - return false - } - h.once.Do(func() { close(h.started) }) - go func() { - <-ctx.Done() - close(h.canceled) - }() - return true -} -func (*disconnectChatHandler) HandleUpload(webproto.Message, func(webproto.Message)) {} -func (*disconnectChatHandler) HandleConfigReload(string, func(webproto.Message)) {} - -func TestConnectOnceCancelsChatWhenSocketDisconnects(t *testing.T) { - upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} - handler := &disconnectChatHandler{started: make(chan struct{}), canceled: make(chan struct{})} - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Errorf("upgrade: %v", err) - return - } - defer conn.Close() - var registration webproto.Message - if err := conn.ReadJSON(®istration); err != nil { - t.Errorf("read registration: %v", err) - return - } - if err := conn.WriteJSON(webproto.Message{Type: "connected"}); err != nil { - t.Errorf("write connected: %v", err) - return - } - payload, _ := json.Marshal(webproto.RunPayload{SessionID: "chat-1"}) - if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeRun, TurnID: "turn-1", Payload: payload}); err != nil { - t.Errorf("write task: %v", err) - return - } - select { - case <-handler.started: - case <-time.After(time.Second): - t.Error("chat handler did not start") - } - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - err := connectOnce(ctx, connectionConfig{ - ServerURL: srv.URL, - Name: "worker", - Registry: commands.NewRegistry(), - Chat: handler, - Node: protocols.NodeRef{ID: "worker", Authority: srv.URL}, - }, telemetry.NopLogger()) - if err == nil { - t.Fatal("connectOnce returned nil after socket disconnect") - } - - select { - case <-handler.canceled: - case <-time.After(500 * time.Millisecond): - t.Fatal("chat context remained alive after socket disconnect") - } -} diff --git a/pkg/webagent/exec.go b/pkg/webagent/exec.go deleted file mode 100644 index b3cdf52e..00000000 --- a/pkg/webagent/exec.go +++ /dev/null @@ -1,100 +0,0 @@ -package webagent - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os" - "os/exec" - "runtime" - "time" - - "github.com/chainreactors/aiscan/pkg/webproto" -) - -type execPayload struct { - Command string `json:"command"` - Cwd string `json:"cwd,omitempty"` - Timeout int `json:"timeout,omitempty"` - Env map[string]string `json:"env,omitempty"` -} - -type execResult struct { - ExitCode int `json:"exit_code"` - State string `json:"state,omitempty"` - KillCause string `json:"kill_cause,omitempty"` -} - -type execStreamPayload struct { - Stream string `json:"stream"` -} - -// HandleExec runs Cairn's native shell RPC and returns output using the same -// correlated output/complete envelope as the file RPCs. -func HandleExec(ctx context.Context, msg webproto.Message, baseDir string, send func(webproto.Message)) { - var payload execPayload - if len(msg.Payload) > 0 { - _ = json.Unmarshal(msg.Payload, &payload) - } - if payload.Command == "" { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "command required"}) - return - } - - runCtx := ctx - cancel := func() {} - if payload.Timeout > 0 { - runCtx, cancel = context.WithTimeout(ctx, time.Duration(payload.Timeout)*time.Second) - } - defer cancel() - - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.CommandContext(runCtx, "cmd.exe", "/C", payload.Command) - } else { - cmd = exec.CommandContext(runCtx, "/bin/sh", "-c", payload.Command) - } - if payload.Cwd != "" { - cmd.Dir = resolveFileRPCPath(baseDir, payload.Cwd) - } else if baseDir != "" { - cmd.Dir = baseDir - } - cmd.Env = os.Environ() - for key, value := range payload.Env { - cmd.Env = append(cmd.Env, key+"="+value) - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if stdout.Len() > 0 { - send(webproto.Message{Type: "output", TaskID: msg.TaskID, Data: stdout.String(), Payload: webproto.MustJSON(execStreamPayload{Stream: "stdout"})}) - } - if stderr.Len() > 0 { - send(webproto.Message{Type: "output", TaskID: msg.TaskID, Data: stderr.String(), Payload: webproto.MustJSON(execStreamPayload{Stream: "stderr"})}) - } - - result := execResult{State: "completed"} - if err != nil { - var exitErr *exec.ExitError - switch { - case errors.Is(runCtx.Err(), context.DeadlineExceeded): - result.ExitCode = -1 - result.State = "killed" - result.KillCause = "timeout" - case errors.Is(runCtx.Err(), context.Canceled): - result.ExitCode = -1 - result.State = "killed" - result.KillCause = "canceled" - case errors.As(err, &exitErr): - result.ExitCode = exitErr.ExitCode() - default: - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - } - send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Payload: webproto.MustJSON(result)}) -} diff --git a/pkg/webagent/exec_test.go b/pkg/webagent/exec_test.go deleted file mode 100644 index c38a5a35..00000000 --- a/pkg/webagent/exec_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package webagent - -import ( - "context" - "encoding/json" - "runtime" - "testing" - - "github.com/chainreactors/aiscan/pkg/webproto" -) - -func TestHandleExecCompletesWithOutput(t *testing.T) { - command := "printf hello" - if runtime.GOOS == "windows" { - command = "echo|set /p=hello" - } - payload, _ := json.Marshal(execPayload{Command: command, Timeout: 5}) - var messages []webproto.Message - HandleExec(context.Background(), webproto.Message{TaskID: "exec-1", Payload: payload}, t.TempDir(), func(msg webproto.Message) { - messages = append(messages, msg) - }) - if len(messages) != 2 || messages[0].Type != "output" || messages[0].Data != "hello" || messages[1].Type != "complete" { - t.Fatalf("unexpected messages: %#v", messages) - } -} - -func TestHandleExecReportsExitCode(t *testing.T) { - command := "exit 7" - if runtime.GOOS == "windows" { - command = "exit /b 7" - } - payload, _ := json.Marshal(execPayload{Command: command, Timeout: 5}) - var complete webproto.Message - HandleExec(context.Background(), webproto.Message{TaskID: "exec-2", Payload: payload}, t.TempDir(), func(msg webproto.Message) { - if msg.Type == "complete" { - complete = msg - } - }) - var result execResult - if err := json.Unmarshal(complete.Payload, &result); err != nil { - t.Fatal(err) - } - if result.ExitCode != 7 { - t.Fatalf("exit code = %d, want 7", result.ExitCode) - } -} diff --git a/pkg/webagent/file.go b/pkg/webagent/file.go deleted file mode 100644 index d21b30fa..00000000 --- a/pkg/webagent/file.go +++ /dev/null @@ -1,120 +0,0 @@ -package webagent - -import ( - "encoding/base64" - "encoding/json" - "os" - "path/filepath" - - "github.com/chainreactors/aiscan/pkg/webproto" -) - -func resolveFileRPCPath(baseDir, path string) string { - if filepath.IsAbs(path) || baseDir == "" { - return filepath.Clean(path) - } - return filepath.Clean(filepath.Join(baseDir, path)) -} - -// HandleFileRead reads a file from disk and sends its base64-encoded content. -func HandleFileRead(msg webproto.Message, baseDir string, send func(webproto.Message)) { - var payload webproto.FileRPCPayload - if len(msg.Payload) > 0 { - _ = json.Unmarshal(msg.Payload, &payload) - } - if payload.Path == "" { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "file path required"}) - return - } - - data, err := os.ReadFile(resolveFileRPCPath(baseDir, payload.Path)) - if err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - payload.Size = int64(len(data)) - send(webproto.Message{ - Type: "complete", - TaskID: msg.TaskID, - DataB64: base64.StdEncoding.EncodeToString(data), - Payload: webproto.MustJSON(payload), - }) -} - -// HandleFileWrite writes base64-encoded data from a message to a file on disk. -func HandleFileWrite(msg webproto.Message, baseDir string, send func(webproto.Message)) { - var payload webproto.FileRPCPayload - if len(msg.Payload) > 0 { - _ = json.Unmarshal(msg.Payload, &payload) - } - if payload.Path == "" { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "file path required"}) - return - } - - data, err := base64.StdEncoding.DecodeString(msg.DataB64) - if err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "decode file: " + err.Error()}) - return - } - resolved := resolveFileRPCPath(baseDir, payload.Path) - if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - if err := os.WriteFile(resolved, data, 0o644); err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - payload.Size = int64(len(data)) - send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Payload: webproto.MustJSON(payload)}) -} - -// HandleFileList returns a directory listing as structured JSON. It does not -// invoke BashTool or parse terminal output. -func HandleFileList(msg webproto.Message, baseDir string, send func(webproto.Message)) { - var payload webproto.FileRPCPayload - if len(msg.Payload) > 0 { - _ = json.Unmarshal(msg.Payload, &payload) - } - if payload.Path == "" { - payload.Path = "." - } - - entries, err := os.ReadDir(resolveFileRPCPath(baseDir, payload.Path)) - if err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - result := webproto.FileListResult{Path: payload.Path, Entries: make([]webproto.FileEntry, 0, len(entries))} - for _, entry := range entries { - info, err := entry.Info() - if err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - result.Entries = append(result.Entries, webproto.FileEntry{ - Name: entry.Name(), - IsDirectory: entry.IsDir(), - Size: info.Size(), - }) - } - send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Payload: webproto.MustJSON(result)}) -} - -// HandleFileMkdir creates a directory using the host filesystem API. -func HandleFileMkdir(msg webproto.Message, baseDir string, send func(webproto.Message)) { - var payload webproto.FileRPCPayload - if len(msg.Payload) > 0 { - _ = json.Unmarshal(msg.Payload, &payload) - } - if payload.Path == "" { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: "directory path required"}) - return - } - if err := os.MkdirAll(resolveFileRPCPath(baseDir, payload.Path), 0o755); err != nil { - send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) - return - } - send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Payload: webproto.MustJSON(payload)}) -} diff --git a/pkg/webagent/file_test.go b/pkg/webagent/file_test.go deleted file mode 100644 index cd3c6e06..00000000 --- a/pkg/webagent/file_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package webagent - -import ( - "encoding/base64" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/chainreactors/aiscan/pkg/webproto" -) - -func captureFileRPC(t *testing.T, invoke func(func(webproto.Message))) webproto.Message { - t.Helper() - ch := make(chan webproto.Message, 1) - invoke(func(msg webproto.Message) { ch <- msg }) - return <-ch -} - -func TestDefaultAgentRuntimeDoesNotAdvertiseRunnerFileRPCs(t *testing.T) { - for _, capability := range DefaultRuntime().Capabilities { - if capability == "file.list" || capability == "file.mkdir" { - t.Fatalf("regular agent advertised runner-only capability %q", capability) - } - } -} - -func TestHandleFileListReturnsStructuredEntries(t *testing.T) { - base := t.TempDir() - if err := os.WriteFile(filepath.Join(base, "note.txt"), []byte("body"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(base, "nested"), 0o755); err != nil { - t.Fatal(err) - } - - request := webproto.Message{ - Type: "file.list", - TaskID: "list-1", - Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: "."}), - } - response := captureFileRPC(t, func(send func(webproto.Message)) { - HandleFileList(request, base, send) - }) - if response.Type != "complete" { - t.Fatalf("response = %+v", response) - } - var result webproto.FileListResult - if err := json.Unmarshal(response.Payload, &result); err != nil { - t.Fatal(err) - } - if result.Path != "." || len(result.Entries) != 2 { - t.Fatalf("result = %+v", result) - } - byName := map[string]webproto.FileEntry{} - for _, entry := range result.Entries { - byName[entry.Name] = entry - } - if byName["note.txt"].IsDirectory || byName["note.txt"].Size != 4 { - t.Fatalf("file entry = %+v", byName["note.txt"]) - } - if !byName["nested"].IsDirectory { - t.Fatalf("directory entry = %+v", byName["nested"]) - } -} - -func TestNativeFileRPCsResolveRelativeToRuntimeWorkdir(t *testing.T) { - base := t.TempDir() - - mkdirRequest := webproto.Message{ - Type: "file.mkdir", - TaskID: "mkdir-1", - Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: "nested"}), - } - response := captureFileRPC(t, func(send func(webproto.Message)) { - HandleFileMkdir(mkdirRequest, base, send) - }) - if response.Type != "complete" { - t.Fatalf("mkdir response = %+v", response) - } - - writeRequest := webproto.Message{ - Type: "file.write", - TaskID: "write-1", - DataB64: base64.StdEncoding.EncodeToString([]byte("hello")), - Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: filepath.Join("nested", "proof.txt")}), - } - response = captureFileRPC(t, func(send func(webproto.Message)) { - HandleFileWrite(writeRequest, base, send) - }) - if response.Type != "complete" { - t.Fatalf("write response = %+v", response) - } - - readRequest := webproto.Message{ - Type: "file.read", - TaskID: "read-1", - Payload: webproto.MustJSON(webproto.FileRPCPayload{Path: filepath.Join("nested", "proof.txt")}), - } - response = captureFileRPC(t, func(send func(webproto.Message)) { - HandleFileRead(readRequest, base, send) - }) - if response.Type != "complete" { - t.Fatalf("read response = %+v", response) - } - data, err := base64.StdEncoding.DecodeString(response.DataB64) - if err != nil || string(data) != "hello" { - t.Fatalf("read data = %q, err = %v", data, err) - } -} diff --git a/pkg/webagent/identity.go b/pkg/webagent/identity.go deleted file mode 100644 index 7b49feeb..00000000 --- a/pkg/webagent/identity.go +++ /dev/null @@ -1,96 +0,0 @@ -package webagent - -import ( - "fmt" - "net/url" - "os" - "os/user" - "runtime" - "strings" - - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" - "github.com/chainreactors/ioa/protocols" -) - -// DefaultRuntime returns OS process metadata without introducing another -// identity beside the IOA NodeRef. -func DefaultRuntime() webproto.AgentRuntime { - runtimeInfo := webproto.AgentRuntime{ - OS: runtime.GOOS, - Arch: runtime.GOARCH, - PID: os.Getpid(), - Capabilities: []string{"repl", "pty", "tmux", "ioa"}, - Meta: map[string]any{"client": "aiscan", "transport": "websocket"}, - } - if host, err := os.Hostname(); err == nil { - runtimeInfo.Hostname = host - } - if wd, err := os.Getwd(); err == nil { - runtimeInfo.WorkingDir = wd - } - if current, err := user.Current(); err == nil && current != nil { - runtimeInfo.Username = current.Username - } - return runtimeInfo -} - -// RegisterPayload builds the WebSocket registration payload. -func RegisterPayload(name string, reg *commands.CommandRegistry, ref protocols.NodeRef, runtimeInfo webproto.AgentRuntime, statusFn func() webproto.AgentStatus, menuFn func() []webproto.CommandSpec, stats webproto.AgentStats) (webproto.RegisterPayload, error) { - if !ref.Valid() { - return webproto.RegisterPayload{}, fmt.Errorf("valid node reference is required") - } - if runtimeInfo.OS == "" { - runtimeInfo = DefaultRuntime() - } - var status webproto.AgentStatus - if statusFn != nil { - status = statusFn() - } - - var menu []webproto.CommandSpec - if menuFn != nil { - menu = menuFn() - } - - payload := webproto.RegisterPayload{ - Name: name, - Commands: reg.Names(), - Tools: reg.ToolDefinitions(), - CommandsMenu: menu, - Stats: stats, - Node: ref, - Runtime: runtimeInfo, - Status: status, - } - return payload, nil -} - -// SplitAccessKey lifts the access token out of a URL's userinfo -// (http://@host...), returning a userinfo-free URL plus the token. -// A URL without userinfo (or an unparseable one) comes back unchanged -// with an empty token. -func SplitAccessKey(rawURL string) (dialURL, token string) { - u, err := url.Parse(rawURL) - if err != nil || u.User == nil { - return rawURL, "" - } - token = u.User.Username() - u.User = nil - return u.String(), token -} - -// HTTPToWS converts an HTTP(S) URL to a WS(S) URL. -func HTTPToWS(rawURL string) string { - u, err := url.Parse(strings.TrimRight(rawURL, "/")) - if err != nil { - return rawURL - } - switch u.Scheme { - case "https": - u.Scheme = "wss" - default: - u.Scheme = "ws" - } - return u.String() -} diff --git a/pkg/webagent/stream.go b/pkg/webagent/stream.go deleted file mode 100644 index 1e091fc1..00000000 --- a/pkg/webagent/stream.go +++ /dev/null @@ -1,64 +0,0 @@ -package webagent - -import ( - "sync" - - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/pkg/webproto" -) - -// AgentStatsTracker tracks agent event statistics for the WebSocket connection. -type AgentStatsTracker struct { - mu sync.Mutex - stats webproto.AgentStats -} - -// NewAgentStatsTracker creates a new stats tracker. -func NewAgentStatsTracker() *AgentStatsTracker { - return &AgentStatsTracker{} -} - -// Snapshot returns the current stats snapshot. -func (t *AgentStatsTracker) Snapshot() webproto.AgentStats { - if t == nil { - return webproto.AgentStats{} - } - t.mu.Lock() - defer t.mu.Unlock() - return t.stats -} - -// Observe records an AOP event and returns updated stats if the stats changed. -func (t *AgentStatsTracker) Observe(e aop.Event) (webproto.AgentStats, bool) { - if t == nil { - return webproto.AgentStats{}, false - } - t.mu.Lock() - defer t.mu.Unlock() - - t.stats.LastEvent = e.Type - switch e.Type { - case aop.TypeTurnStart: - t.stats.Turns++ - case aop.TypeUsage: - data, err := aop.DecodeData[aop.UsageData](e) - if err != nil { - return t.stats, false - } - t.stats.PromptTokens += data.InputTokens - t.stats.CompletionTokens += data.OutputTokens - t.stats.TotalTokens += data.TotalTokens - t.stats.CacheReadTokens += data.CacheReadTokens - t.stats.CacheWriteTokens += data.CacheWriteTokens - case aop.TypeToolCall: - t.stats.ToolCalls++ - t.stats.RunningTools++ - case aop.TypeToolResult: - if t.stats.RunningTools > 0 { - t.stats.RunningTools-- - } - default: - return t.stats, false - } - return t.stats, true -} diff --git a/pkg/webagent/toolnode_test.go b/pkg/webagent/toolnode_test.go deleted file mode 100644 index 9fbca5ca..00000000 --- a/pkg/webagent/toolnode_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package webagent - -import ( - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" - - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/webproto" -) - -// hubScript simulates the hub dialect: register→connected handshake, a -// structured Command, file.read, and tool.data correlation. -var testUpgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} - -type hubScript struct { - t *testing.T - - registered chan webproto.RegisterPayload - toolResult chan aop.ToolResultData - progress chan string - fileData chan []byte - toolData chan webproto.Message -} - -func newHubScript(t *testing.T) *hubScript { - return &hubScript{ - t: t, - registered: make(chan webproto.RegisterPayload, 1), - toolResult: make(chan aop.ToolResultData, 1), - progress: make(chan string, 16), - fileData: make(chan []byte, 1), - toolData: make(chan webproto.Message, 4), - } -} - -func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer test-token" { - h.t.Errorf("authorization = %q", got) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - conn, err := testUpgrader.Upgrade(w, r, nil) - if err != nil { - h.t.Errorf("upgrade: %v", err) - return - } - defer conn.Close() - - var hello webproto.Message - if err := conn.ReadJSON(&hello); err != nil || hello.Type != "register" { - h.t.Errorf("expected register, got %q (err=%v)", hello.Type, err) - return - } - var reg webproto.RegisterPayload - if err := json.Unmarshal(hello.Payload, ®); err != nil { - h.t.Errorf("register payload: %v", err) - return - } - h.registered <- reg - if err := conn.WriteJSON(webproto.Message{Type: "connected"}); err != nil { - return - } - go h.drive(conn) - - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - return - } - switch msg.Type { - case webproto.TypeAOP: - var event aop.Event - if err := json.Unmarshal(msg.Payload, &event); err != nil || event.Type != aop.TypeToolResult { - h.t.Errorf("tool.result: event=%+v err=%v", event, err) - return - } - result, err := aop.DecodeData[aop.ToolResultData](event) - if err != nil { - h.t.Errorf("tool.result data: %v", err) - return - } - h.toolResult <- result - case "complete": - if strings.HasPrefix(msg.TaskID, "read-") { - data, err := base64.StdEncoding.DecodeString(msg.DataB64) - if err != nil { - h.t.Errorf("file data: %v", err) - return - } - h.fileData <- data - } - case "tool.data": - var event output.ToolDataEvent - if err := json.Unmarshal(msg.Payload, &event); err == nil && event.Kind == output.ToolDataProgress { - if line, ok := event.Data.(string); ok { - h.progress <- line - continue - } - } - h.toolData <- msg - } - } -} - -// drive issues the server→runner calls once the connection is live. -func (h *hubScript) drive(conn *websocket.Conn) { - call := aop.ToolCallData{ - ToolCallID: "exec-1", - ToolName: "bash", - Args: map[string]any{"command": "echo hello"}, - } - event := toolEvent(h.t, call) - payload, _ := json.Marshal(event) - if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "exec-1", TurnID: "exec-1", Payload: payload}); err != nil { - return - } -} - -func (h *hubScript) driveFileRead(conn *websocket.Conn, path string) { - payload, _ := json.Marshal(webproto.FileRPCPayload{Path: path}) - _ = conn.WriteJSON(webproto.Message{Type: "file.read", TaskID: "read-1", Payload: payload}) -} - -func wait[T any](t *testing.T, ch <-chan T, what string) T { - t.Helper() - select { - case v := <-ch: - return v - case <-time.After(5 * time.Second): - t.Fatalf("timed out waiting for %s", what) - var zero T - return zero - } -} - -// TestRunToolNodeWireInterop runs a tool node against a mock hub and verifies -// the full register / AOP tool.call / file.read / tool.data round trip. -func TestRunToolNodeWireInterop(t *testing.T) { - reg := commands.NewRegistry() - reg.RegisterTool(&recordingBash{}) - dataBus := eventbus.New[output.ToolDataEvent]() - - hub := newHubScript(t) - server := httptest.NewServer(http.HandlerFunc(hub.serveHTTP)) - defer server.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - errCh := make(chan error, 1) - go func() { - errCh <- RunToolNode(ctx, ToolNodeConfig{ - ServerURL: server.URL, - WSPath: "/ws/runner", - ID: "runner-1", - Token: "test-token", - Registry: reg, - DataBus: dataBus, - Version: "test", - }) - }() - - registered := wait(t, hub.registered, "register") - if registered.Name != "runner-1" || registered.Node.ID != "runner-1" { - t.Fatalf("register identity = %+v node=%+v", registered.Name, registered.Node) - } - if registered.Runtime.OS == "" { - t.Fatalf("register runtime missing OS: %+v", registered.Runtime) - } - capabilities := map[string]bool{} - for _, capability := range registered.Runtime.Capabilities { - capabilities[capability] = true - } - if !capabilities["file.list"] || !capabilities["file.mkdir"] { - t.Fatalf("runner runtime missing native file capabilities: %+v", registered.Runtime.Capabilities) - } - if len(registered.Tools) != 1 || registered.Tools[0].Function.Name != "bash" { - t.Fatalf("register tools = %+v", registered.Tools) - } - - // The hub issues a structured Command once the runner's first post-handshake - // message arrives; recordingBash streams one progress line and returns. - line := wait(t, hub.progress, "tool.data progress") - if line != "streamed" { - t.Fatalf("progress line = %q", line) - } - result := wait(t, hub.toolResult, "tool.result") - if result.IsError || result.ToolCallID != "exec-1" || result.ToolName != "bash" { - t.Fatalf("tool.result = %+v", result) - } - - // tool.data rides the same connection, correlated by call ID. - dataBus.Emit(output.ToolDataEvent{Tool: "gogo", Kind: "service", CallID: "exec-1"}) - toolMsg := wait(t, hub.toolData, "tool.data") - if toolMsg.TaskID != "exec-1" { - t.Fatalf("tool.data task id = %q", toolMsg.TaskID) - } - - cancel() - select { - case <-errCh: - case <-time.After(5 * time.Second): - t.Fatal("tool node did not stop after cancel") - } -} - -// TestRunToolNodeFileRead verifies the file.read path against a real file. -func TestRunToolNodeFileRead(t *testing.T) { - reg := commands.NewRegistry() - reg.RegisterTool(&recordingBash{}) - - path := filepath.Join(t.TempDir(), "note.txt") - if err := os.WriteFile(path, []byte("file-body"), 0o644); err != nil { - t.Fatal(err) - } - - hub := newHubScript(t) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := testUpgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - var hello webproto.Message - if err := conn.ReadJSON(&hello); err != nil || hello.Type != "register" { - return - } - hub.registered <- webproto.RegisterPayload{} - if err := conn.WriteJSON(webproto.Message{Type: "connected"}); err != nil { - return - } - hub.driveFileRead(conn, path) - for { - var msg webproto.Message - if err := conn.ReadJSON(&msg); err != nil { - return - } - if msg.Type == "complete" && strings.HasPrefix(msg.TaskID, "read-") { - data, err := base64.StdEncoding.DecodeString(msg.DataB64) - if err != nil { - return - } - hub.fileData <- data - } - } - })) - defer server.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - _ = RunToolNode(ctx, ToolNodeConfig{ - ServerURL: server.URL, WSPath: "/ws/runner", ID: "runner-1", - Registry: reg, - }) - }() - - wait(t, hub.registered, "register") - data := wait(t, hub.fileData, "file.read complete") - if string(data) != "file-body" { - t.Fatalf("file.read data = %q", data) - } -} diff --git a/pkg/webagent/upload_test.go b/pkg/webagent/upload_test.go deleted file mode 100644 index 8c4ee057..00000000 --- a/pkg/webagent/upload_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package webagent - -import ( - "encoding/base64" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/chainreactors/aiscan/pkg/webproto" -) - -func TestHandleFileUploadWritesAbsolutePath(t *testing.T) { - const filename = "aiscan_test_upload_probe.txt" - const body = "codex public proof\nkey=appImage/probe" - dest := filepath.Join(os.TempDir(), "aiscan-uploads", filename) - t.Cleanup(func() { _ = os.Remove(dest) }) - - payload, _ := json.Marshal(webproto.FileUploadPayload{Filename: filename, SessionID: "sess-1"}) - msg := webproto.Message{ - Type: "upload", TaskID: "task-1", - DataB64: base64.StdEncoding.EncodeToString([]byte(body)), Payload: payload, - } - - var got webproto.Message - handleFileUpload(msg, func(out webproto.Message) { got = out }) - - var result webproto.FileUploadResult - if err := json.Unmarshal(got.Payload, &result); err != nil { - t.Fatalf("decode result: %v", err) - } - if result.Error != "" || result.Path != dest { - t.Fatalf("result = %+v, want path %q", result, dest) - } - if data, err := os.ReadFile(dest); err != nil || string(data) != body { - t.Fatalf("file on disk = %q, err=%v; want %q", data, err, body) - } -} diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go deleted file mode 100644 index 71a60261..00000000 --- a/pkg/webproto/message.go +++ /dev/null @@ -1,232 +0,0 @@ -package webproto - -import ( - "encoding/json" - "fmt" - - "github.com/chainreactors/aiscan/core/tool" - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/ioa/protocols" - "github.com/chainreactors/utils/pty" -) - -type Message struct { - Type string `json:"type"` - TurnID string `json:"turn_id,omitempty"` - TaskID string `json:"task_id,omitempty"` - Data string `json:"data,omitempty"` - DataB64 string `json:"data_b64,omitempty"` - Payload json.RawMessage `json:"payload,omitempty"` -} - -const ( - TypeSessionOpen = "session.open" - TypeSessionOpened = "session.opened" - TypeSessionClose = "session.close" - TypeSessionClosed = "session.closed" - TypeRun = "run" - TypeRunCancel = "run.cancel" - TypeCommand = "command" - TypeCommandResult = "command.result" - TypeAOP = "aop" - TypeError = "error" -) - -type SessionOpenPayload struct { - SessionID string `json:"session_id"` - ParentSessionID string `json:"parent_session_id,omitempty"` - ParentToolCallID string `json:"parent_tool_call_id,omitempty"` -} - -type SessionLifecyclePayload struct { - SessionID string `json:"session_id"` - Reason string `json:"reason,omitempty"` -} - -type RunPayload struct { - SessionID string `json:"session_id"` - Parts []aop.MessagePart `json:"parts"` - Continue bool `json:"continue,omitempty"` - NoEcho bool `json:"no_echo,omitempty"` - MaxTurns int `json:"max_turns,omitempty"` - EvalCriteria string `json:"eval_criteria,omitempty"` - EvalMaxRounds int `json:"eval_max_rounds,omitempty"` -} - -type CommandPayload struct { - SessionID string `json:"session_id"` - Line string `json:"line"` -} - -type ErrorPayload struct { - Message string `json:"message"` -} - -// CommandSpec is the surface-neutral description of one user-facing "/verb" command. -type CommandSpec struct { - Name string `json:"name"` - Aliases []string `json:"aliases,omitempty"` - Usage string `json:"usage,omitempty"` - Description string `json:"description,omitempty"` -} - -type RegisterPayload struct { - Name string `json:"name"` - // Commands is the LLM tool/pseudo-command registry (pkg/commands) the agent - // exposes to the model — distinct from CommandsMenu. - Commands []string `json:"commands,omitempty"` - // Tools is the structured LLM tool catalog exposed by this node. Commands - // remains the shell/pseudo-command catalog used by the slash menu and Bash. - Tools []tool.Definition `json:"tools,omitempty"` - // CommandsMenu is the agent's user-facing "/verb" catalog: the agent-scope, - // menu-visible commands it can run, plus one per loaded skill. The hub merges - // these with its own hub-scope commands to drive the web "/" menu and /help, - // so the surfaces never drift. - CommandsMenu []CommandSpec `json:"commands_menu,omitempty"` - Node protocols.NodeRef `json:"node"` - Runtime AgentRuntime `json:"runtime,omitempty"` - Status AgentStatus `json:"status,omitempty"` - Stats AgentStats `json:"stats,omitempty"` -} - -// AgentRuntime describes the process exposing an IOA node over the Web -// transport. It is operational metadata, not another identity. -type AgentRuntime struct { - Hostname string `json:"hostname,omitempty"` - Username string `json:"username,omitempty"` - WorkingDir string `json:"working_dir,omitempty"` - OS string `json:"os,omitempty"` - Arch string `json:"arch,omitempty"` - PID int `json:"pid,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - Meta map[string]any `json:"meta,omitempty"` -} - -// AgentStatus contains mutable state. Identity remains the immutable NodeRef. -type AgentStatus struct { - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Space string `json:"space,omitempty"` - Bound bool `json:"bound"` - ConfigError string `json:"config_error,omitempty"` -} - -type ConfigReloadResult struct { - OK bool `json:"ok"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Error string `json:"error,omitempty"` -} - -type AgentStats struct { - Turns int `json:"turns,omitempty"` - ToolCalls int `json:"tool_calls,omitempty"` - RunningTools int `json:"running_tools,omitempty"` - PromptTokens int `json:"prompt_tokens,omitempty"` - CompletionTokens int `json:"completion_tokens,omitempty"` - TotalTokens int `json:"total_tokens,omitempty"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - CacheWriteTokens int `json:"cache_write_tokens,omitempty"` - Assets int `json:"assets,omitempty"` - Loots int `json:"loots,omitempty"` - LastEvent string `json:"last_event,omitempty"` -} - -// GoalExt carries HTTP chat options that are copied into a RunPayload by the -// web boundary. It is not an AOP RPC envelope or a separate runtime lifecycle. -type GoalExt struct { - EvalCriteria string `json:"eval_criteria,omitempty"` - EvalMaxRounds int `json:"eval_max_rounds,omitempty"` - PersistMaxTurns int `json:"persist_max_turns,omitempty"` - // NoEcho suppresses the agent-side echo of this user message; the hub - // already persisted and broadcast its own copy. - NoEcho bool `json:"no_echo,omitempty"` -} - -// NSWeb is the AOP extension namespace the hub uses to attach its own message -// metadata (originating agent id, persisted metadata) to message events. -const NSWeb = "aiscan.web" - -// WebMessageExt is the hub-owned message extension stored under NSWeb. -type WebMessageExt struct { - AgentID string `json:"agent_id,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` - // Params carries i18n interpolation values for hub-emitted error events - // (paired with ErrorData.Code). - Params map[string]any `json:"params,omitempty"` -} - -// SetWebExt writes the hub message extension onto an event. -func SetWebExt(event *aop.Event, ext WebMessageExt) error { - return aop.SetExt(event, NSWeb, ext) -} - -// GetWebExt reads the hub message extension from an event. -func GetWebExt(event aop.Event) (WebMessageExt, bool, error) { - return aop.Ext[WebMessageExt](event, NSWeb) -} - -type FileUploadPayload struct { - Filename string `json:"filename"` - FileSize int64 `json:"file_size"` - MimeType string `json:"mime_type,omitempty"` - SessionID string `json:"session_id,omitempty"` -} - -type FileUploadResult struct { - Filename string `json:"filename"` - Path string `json:"path"` - Size int64 `json:"size"` - Error string `json:"error,omitempty"` -} - -// FileRPCPayload carries the target path for WebAgent file operations. The -// file bytes travel in Message.DataB64 so this transport remains JSON-only. -type FileRPCPayload struct { - Path string `json:"path"` - Size int64 `json:"size,omitempty"` -} - -// FileEntry is one structured directory entry returned by a file.list RPC. -// Names are transported as JSON strings, so unusual characters never need to -// be inferred from shell output. -type FileEntry struct { - Name string `json:"name"` - IsDirectory bool `json:"isDirectory"` - Size int64 `json:"size"` -} - -// FileListResult is carried in the completion payload for file.list. -type FileListResult struct { - Path string `json:"path"` - Entries []FileEntry `json:"entries"` -} - -const TypePTY = "pty" - -func NewPTYMessage(frame pty.Frame) Message { - payload, _ := json.Marshal(frame) - return Message{Type: TypePTY, Payload: payload} -} - -func DecodePTYMessage(msg Message) (pty.Frame, error) { - if msg.Type != TypePTY { - return pty.Frame{}, fmt.Errorf("unsupported PTY envelope %q", msg.Type) - } - var frame pty.Frame - if len(msg.Payload) == 0 { - return frame, fmt.Errorf("PTY frame payload is required") - } - if err := json.Unmarshal(msg.Payload, &frame); err != nil { - return frame, fmt.Errorf("decode PTY frame: %w", err) - } - if frame.Type == "" { - return frame, fmt.Errorf("PTY frame type is required") - } - return frame, nil -} - -func MustJSON(v any) json.RawMessage { - data, _ := json.Marshal(v) - return data -} diff --git a/pkg/webproto/message_test.go b/pkg/webproto/message_test.go deleted file mode 100644 index 320fa7f8..00000000 --- a/pkg/webproto/message_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package webproto - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/chainreactors/aiscan/core/aop" - "github.com/chainreactors/utils/pty" -) - -func TestRunFrameRoundTrip(t *testing.T) { - want := RunPayload{SessionID: "session-1", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "audit target"}}, NoEcho: true, EvalCriteria: "find one SQLi", EvalMaxRounds: 5} - msg := Message{Type: TypeRun, TurnID: "turn-1", Payload: MustJSON(want)} - var got RunPayload - if err := json.Unmarshal(msg.Payload, &got); err != nil { - t.Fatal(err) - } - if msg.TurnID != "turn-1" || got.SessionID != want.SessionID || len(got.Parts) != 1 || got.Parts[0].Text != "audit target" || !got.NoEcho || got.EvalMaxRounds != 5 { - t.Fatalf("run frame = %+v %+v", msg, got) - } - encoded, err := json.Marshal(msg) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(encoded), `"turn_id":"turn-1"`) || strings.Contains(string(encoded), "run_id") { - t.Fatalf("run frame JSON = %s", encoded) - } -} - -func TestPTYMessageRoundTrip(t *testing.T) { - want := pty.Frame{ - Type: pty.FrameOutput, - StreamID: "terminal-1", - SessionID: "session-1", - Data: []byte{0xff, 0x00, 'x'}, - Sessions: []pty.Info{{ - ID: "session-1", - State: pty.StateRunning, - ActivitySeq: 2, - OutputBytes: 10, - }}, - } - - msg := NewPTYMessage(want) - if msg.Type != TypePTY || msg.Data != "" || msg.DataB64 != "" { - t.Fatalf("PTY envelope = %+v", msg) - } - got, err := DecodePTYMessage(msg) - if err != nil { - t.Fatal(err) - } - if got.Type != want.Type || got.StreamID != want.StreamID || got.SessionID != want.SessionID || !bytes.Equal(got.Data, want.Data) { - t.Fatalf("round trip frame = %+v", got) - } - if len(got.Sessions) != 1 || got.Sessions[0].ActivitySeq != 2 || got.Sessions[0].OutputBytes != 10 { - t.Fatalf("round trip sessions = %+v", got.Sessions) - } -} - -func TestDecodePTYMessageRejectsInvalidEnvelope(t *testing.T) { - if _, err := DecodePTYMessage(Message{Type: "pty.open"}); err == nil { - t.Fatal("expected invalid envelope error") - } - if _, err := DecodePTYMessage(Message{Type: TypePTY, Payload: json.RawMessage(`{"stream_id":"x"}`)}); err == nil { - t.Fatal("expected missing frame type error") - } -} diff --git a/proto/aiscan/chat/session.proto b/proto/aiscan/chat/session.proto new file mode 100644 index 00000000..391d0461 --- /dev/null +++ b/proto/aiscan/chat/session.proto @@ -0,0 +1,136 @@ +syntax = "proto3"; + +package aiscan.chat; + +import "aop/chat.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/chainreactors/aiscan/aop/aiscan/chat;chat"; + +message SessionRecord { + aop.Session session = 1; + string agent_name = 2; + repeated string scan_ids = 3; + google.protobuf.Timestamp created_at = 4; + google.protobuf.Timestamp updated_at = 5; +} + +message ListSessionsRequest { + string after_cursor = 1; + uint32 limit = 2; + bool include_closed = 3; +} + +message ListSessionsResponse { + repeated SessionRecord sessions = 1; + string next_cursor = 2; +} + +message GetSessionRequest { + string session_id = 1; +} + +message GetSessionResponse { + SessionRecord session = 1; +} + +message ResetSessionRequest { + string request_id = 1; + string session_id = 2; + string new_session_id = 3; + string title = 4; +} + +message ResetSessionReceipt { + aop.Session previous = 1; + SessionRecord current = 2; +} + +message ResetSessionResponse { + string request_id = 1; + oneof outcome { + ResetSessionReceipt accepted = 2; + aop.Rejection rejected = 3; + } +} + +message DeleteSessionRequest { + string request_id = 1; + string session_id = 2; +} + +message DeleteSessionResponse { + string request_id = 1; + oneof outcome { + aop.Session accepted = 2; + aop.Rejection rejected = 3; + } +} + +message CommandSpec { + string name = 1; + repeated string aliases = 2; + string usage = 3; + string description = 4; +} + +message ListCommandsRequest { + string session_id = 1; +} + +message ListCommandsResponse { + repeated CommandSpec commands = 1; +} + +message ExecuteCommandRequest { + string request_id = 1; + string session_id = 2; + string line = 3; +} + +message CommandReceipt { + string operation_id = 1; + string session_id = 2; + string state = 3; +} + +message ExecuteCommandResponse { + string request_id = 1; + oneof outcome { + CommandReceipt accepted = 2; + aop.Rejection rejected = 3; + } +} + +message UploadSessionFileRequest { + string request_id = 1; + string session_id = 2; + string filename = 3; + string media_type = 4; + bytes data = 5; +} + +message UploadedFile { + string filename = 1; + string path = 2; + int64 size = 3; + string media_type = 4; +} + +message UploadSessionFileResponse { + string request_id = 1; + oneof outcome { + UploadedFile accepted = 2; + aop.Rejection rejected = 3; + } +} + +service SessionService { + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); + rpc GetSession(GetSessionRequest) returns (GetSessionResponse); + rpc ResetSession(ResetSessionRequest) returns (ResetSessionResponse); + rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse); + rpc ListCommands(ListCommandsRequest) returns (ListCommandsResponse); + rpc ExecuteCommand(ExecuteCommandRequest) returns (ExecuteCommandResponse); + rpc UploadSessionFile(UploadSessionFileRequest) returns (UploadSessionFileResponse); +} diff --git a/proto/aiscan/scan/scan.proto b/proto/aiscan/scan/scan.proto new file mode 100644 index 00000000..28bbe636 --- /dev/null +++ b/proto/aiscan/scan/scan.proto @@ -0,0 +1,145 @@ +syntax = "proto3"; + +package aiscan.scan; + +import "aop/chat.proto"; +import "aop/value.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/chainreactors/aiscan/aop/aiscan/scan;scan"; + +enum ScanStatus { + SCAN_STATUS_UNSPECIFIED = 0; + SCAN_STATUS_QUEUED = 1; + SCAN_STATUS_RUNNING = 2; + SCAN_STATUS_COMPLETED = 3; + SCAN_STATUS_FAILED = 4; + SCAN_STATUS_CANCELED = 5; +} + +message ScanOptions { + bool verify = 1; + bool sniper = 2; + bool deep = 3; +} + +message Scan { + string id = 1; + string target = 2; + string mode = 3; + ScanOptions options = 4; + ScanStatus status = 5; + string progress = 6; + string report = 7; + aop.EncodedValue result = 8; + string error = 9; + google.protobuf.Timestamp created_at = 10; + google.protobuf.Timestamp updated_at = 11; +} + +message SubmitScanRequest { + string request_id = 1; + string target = 2; + string mode = 3; + ScanOptions options = 4; +} + +message SubmitScanResponse { + string request_id = 1; + oneof outcome { + Scan accepted = 2; + aop.Rejection rejected = 3; + } +} + +message GetScanRequest { + string scan_id = 1; +} + +message GetScanResponse { + Scan scan = 1; +} + +message ListScansRequest {} + +message ListScansResponse { + repeated Scan scans = 1; +} + +message CancelScanRequest { + string request_id = 1; + string scan_id = 2; +} + +message CancelScanResponse { + string request_id = 1; + oneof outcome { + Scan accepted = 2; + aop.Rejection rejected = 3; + } +} + +message WatchScanEventsRequest { + string scan_id = 1; +} + +message ScanProgress { + string data = 1; +} + +message ScanStats { + map values = 1; +} + +message ScanCompleted { + aop.EncodedValue result = 1; +} + +message ScanFailed { + string message = 1; + bool canceled = 2; +} + +// SessionScanEvent links a completed scan into an AOP session timeline without +// reintroducing a parallel web-only domain event envelope. +message SessionScanEvent { + string scan_id = 1; + ScanStatus status = 2; +} + +message ScanEvent { + string scan_id = 1; + uint64 sequence = 2; + google.protobuf.Timestamp emitted_at = 3; + oneof payload { + Scan snapshot = 10; + ScanStatus status = 11; + ScanProgress progress = 12; + ScanStats stats = 13; + ScanCompleted completed = 14; + ScanFailed failed = 15; + } +} + +message WatchScanEventsResponse { + ScanEvent event = 1; +} + +message GetScanReportRequest { + string scan_id = 1; + string language = 2; +} + +message GetScanReportResponse { + string markdown = 1; + string media_type = 2; +} + +service ScanService { + rpc SubmitScan(SubmitScanRequest) returns (SubmitScanResponse); + rpc GetScan(GetScanRequest) returns (GetScanResponse); + rpc ListScans(ListScansRequest) returns (ListScansResponse); + rpc CancelScan(CancelScanRequest) returns (CancelScanResponse); + rpc WatchScanEvents(WatchScanEventsRequest) returns (stream WatchScanEventsResponse); + rpc GetScanReport(GetScanReportRequest) returns (GetScanReportResponse); +} diff --git a/proto/aiscan/transport/agent.proto b/proto/aiscan/transport/agent.proto new file mode 100644 index 00000000..e1e2328f --- /dev/null +++ b/proto/aiscan/transport/agent.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; + +package aiscan.transport; + +import "aiscan/transport/operation.proto"; +import "aiscan/transport/telemetry.proto"; +import "aiscan/transport/terminal.proto"; +import "aop/chat.proto"; +import "aop/content.proto"; +import "aop/event.proto"; +import "aop/value.proto"; + +option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; + +message AgentHello { + string agent_id = 1; + string name = 2; + string authority = 3; + repeated string commands = 4; + repeated CommandSpec command_menu = 5; + repeated ToolDefinition tools = 6; + AgentRuntimeInfo runtime = 7; + AgentStatus status = 8; + AgentStats stats = 9; +} + +message ConnectionAccepted { + string agent_id = 1; + string name = 2; + repeated string capabilities = 3; +} + +message ToolCallRequest { + string task_id = 1; + string session_id = 2; + string turn_id = 3; + aop.ToolCall call = 4; +} + +message AgentFrame { + string frame_id = 1; + string correlation_id = 2; + oneof payload { + AgentHello hello = 10; + aop.OpenSessionResponse open_session = 11; + aop.RunTurnResponse run_turn = 12; + aop.CancelTurnResponse cancel_turn = 13; + aop.CloseSessionResponse close_session = 14; + aop.Event event = 15; + CommandResult command_result = 16; + FileResult file_result = 17; + ExecOutput exec_output = 18; + ExecResult exec_result = 19; + OperationError operation_error = 20; + AgentStatus status = 21; + AgentStats stats = 22; + ConfigReloadResult config_reload = 23; + TerminalFrame terminal = 24; + ToolTelemetry tool_telemetry = 25; + ScoNodes sco_nodes = 26; + } +} + +message ServerFrame { + string frame_id = 1; + string correlation_id = 2; + oneof payload { + ConnectionAccepted accepted = 10; + aop.OpenSessionRequest open_session = 11; + aop.RunTurnRequest run_turn = 12; + aop.CancelTurnRequest cancel_turn = 13; + aop.CloseSessionRequest close_session = 14; + CommandRequest command = 15; + ToolCallRequest tool_call = 16; + FileReadRequest file_read = 17; + FileWriteRequest file_write = 18; + FileListRequest file_list = 19; + FileMkdirRequest file_mkdir = 20; + FileUploadRequest file_upload = 21; + ExecRequest exec = 22; + CancelOperation cancel_operation = 23; + ReloadConfig reload_config = 24; + TerminalFrame terminal = 25; + aop.Extension extension = 26; + } +} + +service AgentTransportService { + rpc Connect(stream AgentFrame) returns (stream ServerFrame); +} diff --git a/proto/aiscan/transport/extensions.proto b/proto/aiscan/transport/extensions.proto new file mode 100644 index 00000000..2e13502e --- /dev/null +++ b/proto/aiscan/transport/extensions.proto @@ -0,0 +1,59 @@ +syntax = "proto3"; + +package aiscan.transport; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; + +message CommandDetail { + string line = 1; + string presentation = 2; +} + +message CompactDetail { + string error = 1; + uint64 kept_messages = 2; + uint64 tokens_after = 3; + uint64 tokens_before = 4; +} + +message DelegationDetail { + string agent_id = 1; + string agent_name = 2; + string agent_type = 3; + string context_mode = 4; + string run_mode = 5; + string task = 6; +} + +message EvalControl { + string criteria = 1; + uint32 max_rounds = 2; +} + +message EvalDetail { + string error = 1; + uint32 max_rounds = 2; + bool pass = 3; + string reason = 4; + uint32 round = 5; +} + +message BudgetWarning { + uint64 context_tokens = 1; + uint64 token_budget = 2; +} + +message LLMRequestDetail { + string model = 1; + uint32 messages = 2; + uint32 max_tokens = 3; + bool stream = 4; +} + +message WebMessageExtension { + string agent_id = 1; + bytes metadata = 2; + google.protobuf.Struct params = 3; +} diff --git a/proto/aiscan/transport/operation.proto b/proto/aiscan/transport/operation.proto new file mode 100644 index 00000000..096fecc8 --- /dev/null +++ b/proto/aiscan/transport/operation.proto @@ -0,0 +1,115 @@ +syntax = "proto3"; + +package aiscan.transport; + +option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; + +message CommandRequest { + string task_id = 1; + string session_id = 2; + string line = 3; +} + +// RunOptions carries AIScan-only turn behavior in the +// io.chainreactors.aiscan.run AOP extension. +message RunOptions { + string eval_criteria = 1; + uint32 eval_max_rounds = 2; +} + +message CommandResult { + string task_id = 1; + bytes result = 2; + string media_type = 3; +} + +message FileReadRequest { + string task_id = 1; + string path = 2; +} + +message FileWriteRequest { + string task_id = 1; + string path = 2; + bytes data = 3; +} + +message FileListRequest { + string task_id = 1; + string path = 2; +} + +message FileMkdirRequest { + string task_id = 1; + string path = 2; +} + +message FileUploadRequest { + string task_id = 1; + string session_id = 2; + string filename = 3; + string media_type = 4; + bytes data = 5; +} + +message FileEntry { + string name = 1; + bool is_directory = 2; + int64 size = 3; +} + +message FileResult { + string task_id = 1; + string path = 2; + string filename = 3; + int64 size = 4; + bytes data = 5; + repeated FileEntry entries = 6; +} + +enum ExecStream { + EXEC_STREAM_UNSPECIFIED = 0; + EXEC_STREAM_STDOUT = 1; + EXEC_STREAM_STDERR = 2; +} + +message ExecRequest { + string task_id = 1; + string command = 2; + string cwd = 3; + uint32 timeout_seconds = 4; + map env = 5; +} + +message ExecOutput { + string task_id = 1; + ExecStream stream = 2; + bytes data = 3; +} + +message ExecResult { + string task_id = 1; + int32 exit_code = 2; + string state = 3; + string kill_cause = 4; +} + +message CancelOperation { + string task_id = 1; +} + +message OperationError { + string task_id = 1; + string code = 2; + string message = 3; + bool retryable = 4; +} + +message ReloadConfig {} + +message ConfigReloadResult { + bool ok = 1; + string provider = 2; + string model = 3; + string error = 4; +} diff --git a/proto/aiscan/transport/telemetry.proto b/proto/aiscan/transport/telemetry.proto new file mode 100644 index 00000000..bc02a553 --- /dev/null +++ b/proto/aiscan/transport/telemetry.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package aiscan.transport; + +import "aop/value.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; + +message AgentRuntimeInfo { + string hostname = 1; + string username = 2; + string working_dir = 3; + string os = 4; + string arch = 5; + int32 pid = 6; + repeated string capabilities = 7; + aop.EncodedValue metadata = 8; +} + +message AgentStatus { + string provider = 1; + string model = 2; + string space = 3; + bool bound = 4; + string config_error = 5; +} + +message AgentStats { + uint64 turns = 1; + uint64 tool_calls = 2; + uint64 running_tools = 3; + uint64 input_tokens = 4; + uint64 output_tokens = 5; + uint64 total_tokens = 6; + uint64 cache_read_tokens = 7; + uint64 cache_write_tokens = 8; + uint64 assets = 9; + uint64 loots = 10; + string last_event = 11; +} + +message ToolDefinition { + string type = 1; + string name = 2; + string description = 3; + aop.EncodedValue input_schema = 4; +} + +message CommandSpec { + string name = 1; + repeated string aliases = 2; + string usage = 3; + string description = 4; +} + +message ToolTelemetry { + string tool = 1; + string kind = 2; + string target = 3; + aop.EncodedValue data = 4; + string call_id = 5; + google.protobuf.Timestamp timestamp = 6; +} + +message ScoNodes { + string call_id = 1; + repeated bytes nodes = 2; +} diff --git a/proto/aiscan/transport/terminal.proto b/proto/aiscan/transport/terminal.proto new file mode 100644 index 00000000..b675a9f2 --- /dev/null +++ b/proto/aiscan/transport/terminal.proto @@ -0,0 +1,44 @@ +syntax = "proto3"; + +package aiscan.transport; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; + +message TerminalInfo { + string id = 1; + string kind = 2; + string name = 3; + string command = 4; + int32 pid = 5; + google.protobuf.Timestamp started_at = 6; + google.protobuf.Timestamp last_activity_at = 7; + google.protobuf.Timestamp ended_at = 8; + int64 activity_seq = 9; + int64 output_bytes = 10; + int32 exit_code = 11; + string state = 12; + string kill_cause = 13; +} + +message TerminalFrame { + string type = 1; + string stream_id = 2; + string session_id = 3; + string kind = 4; + string name = 5; + string command = 6; + repeated string args = 7; + bytes data = 8; + int32 cols = 9; + int32 rows = 10; + int32 bytes = 11; + int64 offset = 12; + bool singleton = 13; + string error = 14; + string state = 15; + int32 exit_code = 16; + TerminalInfo session = 17; + repeated TerminalInfo sessions = 18; +} diff --git a/proto/generate.go b/proto/generate.go new file mode 100644 index 00000000..95188d7d --- /dev/null +++ b/proto/generate.go @@ -0,0 +1,8 @@ +// Package proto owns reproducible protobuf generation for AIScan. +package proto + +//go:generate protoc -I ../web/frontend/cyber-ui/packages/aop/proto -I . --go_out=.. --go_opt=module=github.com/chainreactors/aiscan --go_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop --go-grpc_out=.. --go-grpc_opt=module=github.com/chainreactors/aiscan --go-grpc_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop --connect-go_out=.. --connect-go_opt=module=github.com/chainreactors/aiscan --connect-go_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --connect-go_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --connect-go_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --connect-go_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop ../web/frontend/cyber-ui/packages/aop/proto/aop/value.proto ../web/frontend/cyber-ui/packages/aop/proto/aop/content.proto ../web/frontend/cyber-ui/packages/aop/proto/aop/event.proto ../web/frontend/cyber-ui/packages/aop/proto/aop/chat.proto aiscan/chat/session.proto aiscan/scan/scan.proto + +//go:generate protoc -I ../web/frontend/cyber-ui/packages/aop/proto -I . --go_out=.. --go_opt=module=github.com/chainreactors/aiscan --go_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop --go-grpc_out=.. --go-grpc_opt=module=github.com/chainreactors/aiscan --go-grpc_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop aiscan/transport/operation.proto aiscan/transport/extensions.proto aiscan/transport/terminal.proto aiscan/transport/telemetry.proto aiscan/transport/agent.proto + +//go:generate go run ./internal/generate_ts diff --git a/proto/internal/generate_ts/main.go b/proto/internal/generate_ts/main.go new file mode 100644 index 00000000..01508175 --- /dev/null +++ b/proto/internal/generate_ts/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +func main() { + protoc, err := exec.LookPath("protoc") + if err != nil { + fatal("find protoc", err) + } + pluginName := "protoc-gen-es" + if runtime.GOOS == "windows" { + pluginName += ".cmd" + } + plugin := filepath.Join("..", "web", "frontend", "node_modules", ".bin", pluginName) + plugin, err = filepath.Abs(plugin) + if err != nil { + fatal("resolve protoc-gen-es", err) + } + if _, err := os.Stat(plugin); err != nil { + if plugin, err = exec.LookPath("protoc-gen-es"); err != nil { + fatal("find protoc-gen-es (run npm install in web/frontend)", err) + } + } + + args := []string{ + "-I", "../web/frontend/cyber-ui/packages/aop/proto", + "-I", ".", + "--plugin=protoc-gen-es=" + plugin, + "--es_out=../web/frontend/cyber-ui/packages/aop/src/gen", + "--es_opt=target=ts,import_extension=js", + "../web/frontend/cyber-ui/packages/aop/proto/aop/value.proto", + "../web/frontend/cyber-ui/packages/aop/proto/aop/content.proto", + "../web/frontend/cyber-ui/packages/aop/proto/aop/event.proto", + "../web/frontend/cyber-ui/packages/aop/proto/aop/chat.proto", + "aiscan/chat/session.proto", + "aiscan/scan/scan.proto", + "aiscan/transport/terminal.proto", + } + command := exec.Command(protoc, args...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + fatal("generate TypeScript protobuf", err) + } +} + +func fatal(action string, err error) { + fmt.Fprintf(os.Stderr, "%s: %v\n", action, err) + os.Exit(1) +} diff --git a/test-skips.json b/test-skips.json index cf012a39..93d067d3 100644 --- a/test-skips.json +++ b/test-skips.json @@ -180,12 +180,5 @@ "count": 1, "category": "external_api", "reason": "The Hunter integration requires a user-supplied service credential." - }, - { - "path": "web/frontend/e2e/aiscan-web.spec.ts", - "format": "LLM_API_KEY env var required", - "count": 2, - "category": "live_llm", - "reason": "Provider connectivity and model discovery checks are retained as opt-in live endpoint coverage." } ] diff --git a/tools/neutron/neutron.go b/tools/neutron/neutron.go index c0078884..035661de 100644 --- a/tools/neutron/neutron.go +++ b/tools/neutron/neutron.go @@ -21,6 +21,7 @@ import ( "github.com/chainreactors/neutron/templates" sdkneutron "github.com/chainreactors/sdk/neutron" "github.com/chainreactors/sdk/pkg/association" + sdktypes "github.com/chainreactors/sdk/pkg/types" goflags "github.com/jessevdk/go-flags" ) @@ -222,6 +223,7 @@ func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any var sb strings.Builder jsonOutput := flags.JSON || flags.JSONL statsEnabled := (flags.Stats || !flags.NoStats) && !flags.NoStats + results := make([]*sdktypes.TemplateResult, 0, len(targets)*len(selected)) for _, target := range targets { targetOpts := opts @@ -242,6 +244,7 @@ func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any summary.Errors++ } record := neutronResultFromExecution(target, result) + results = append(results, result.TemplateResult(target)) if record.Matched { summary.Matched++ c.EmitDataCtx(ctx, "neutron", output.ToolDataVuln, target, &record) @@ -264,7 +267,7 @@ func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any fmt.Fprint(execution.Stdout, line) } _, wErr := c.writeOrReturn(flags.OutputFile, sb.String()) - return nil, wErr + return results, wErr } func normalizeNucleiStyleArgs(args []string) []string { diff --git a/tools/proxy/mitm.go b/tools/proxy/mitm.go index 6e28703d..0eef4e6c 100644 --- a/tools/proxy/mitm.go +++ b/tools/proxy/mitm.go @@ -23,6 +23,7 @@ type MitmCommand struct { store *FlowStore execCommand CommandExecutor registry *commands.CommandRegistry + execMu sync.Mutex } func NewMitmCommand(reg *commands.CommandRegistry) *MitmCommand { @@ -45,7 +46,7 @@ Usage: mitm [args...] Run command with traffic interception mitm flows [--host X] [--last N] List captured flows from last run mitm flow Show full flow details - mitm analyze [--host X] [--last N] Format flows for AI security analysis + mitm analyze [--host X] [--last N] Summarize captured functional traffic mitm clear Clear captured flows Examples: @@ -94,6 +95,11 @@ func (c *MitmCommand) execWithCapture(ctx context.Context, args []string, execut return nil, fmt.Errorf("mitm: command executor not available") } + // Scanner commands share mutable proxy configuration. Serialize captured + // executions so one run cannot steal another run's proxy or flows. + c.execMu.Lock() + defer c.execMu.Unlock() + state := &mitmState{store: c.store} if err := state.start(); err != nil { return nil, err @@ -115,10 +121,18 @@ func (c *MitmCommand) execWithCapture(ctx context.Context, args []string, execut details, err := c.execCommand(ctx, args, execution) - flowCount := c.store.Count() - summary := fmt.Sprintf("\n[mitm] %d flows captured. Use 'mitm flows' or 'mitm analyze' to inspect.", flowCount) + flowCount := len(state.Records()) + summary := fmt.Sprintf("\n[mitm] %d flows captured.", flowCount) fmt.Fprint(execution.Stdout, summary) - return details, err + return &CaptureResult{Command: details, Flows: state.Records()}, err +} + +// CaptureResult is returned as tool-result details. FlowRecord is the canonical +// immutable traffic snapshot from utils/mitmproxy; callers should persist it +// directly instead of translating it through another flow DTO. +type CaptureResult struct { + Command any `json:"command,omitempty"` + Flows []*mitmproxy.FlowRecord `json:"flows"` } type flowQueryFlags struct { @@ -169,9 +183,11 @@ func (c *MitmCommand) analyze(args []string) (string, error) { // --------------------------------------------------------------------------- type mitmState struct { - server *mitmproxy.Proxy - addr string - store *FlowStore + server *mitmproxy.Proxy + addr string + store *FlowStore + recordMu sync.Mutex + records []*mitmproxy.FlowRecord } func (s *mitmState) start() error { @@ -183,7 +199,7 @@ func (s *mitmState) start() error { if err != nil { return fmt.Errorf("create MITM proxy: %w", err) } - p.AddAddon(&captureAddon{store: s.store}) + p.AddAddon(&captureAddon{store: s.store, record: s.addRecord}) listenAddr, _, err := p.StartAsync() if err != nil { return fmt.Errorf("start MITM proxy: %w", err) @@ -193,6 +209,21 @@ func (s *mitmState) start() error { return nil } +func (s *mitmState) addRecord(record *mitmproxy.FlowRecord) { + if record == nil { + return + } + s.recordMu.Lock() + s.records = append(s.records, record) + s.recordMu.Unlock() +} + +func (s *mitmState) Records() []*mitmproxy.FlowRecord { + s.recordMu.Lock() + defer s.recordMu.Unlock() + return append([]*mitmproxy.FlowRecord(nil), s.records...) +} + func (s *mitmState) stop() { if s.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -215,6 +246,7 @@ const maxBodySnip = 4096 type captureAddon struct { mitmproxy.BaseAddon store *FlowStore + record func(*mitmproxy.FlowRecord) pending sync.Map } @@ -223,6 +255,9 @@ func (a *captureAddon) Requestheaders(f *mitmproxy.Flow) { } func (a *captureAddon) Response(f *mitmproxy.Flow) { + if a.record != nil { + a.record(mitmproxy.NewFlowRecord(f, 0)) + } var dur time.Duration if start, ok := a.pending.LoadAndDelete(f.Id.String()); ok { if t, ok := start.(time.Time); ok { @@ -253,6 +288,11 @@ func (a *captureAddon) Response(f *mitmproxy.Flow) { } func (a *captureAddon) RequestError(f *mitmproxy.Flow, err error) { + if a.record != nil { + record := mitmproxy.NewFlowRecord(f, 0) + record.Error = err.Error() + a.record(record) + } var dur time.Duration if start, ok := a.pending.LoadAndDelete(f.Id.String()); ok { if t, ok := start.(time.Time); ok { @@ -460,7 +500,7 @@ func formatFlowAnalysis(flows []Flow) string { return "[mitm] no flows to analyze" } var sb strings.Builder - sb.WriteString(fmt.Sprintf("=== MITM Traffic Analysis (%d flows) ===\n\n", len(flows))) + sb.WriteString(fmt.Sprintf("=== Captured Traffic Summary (%d flows) ===\n\n", len(flows))) hostCounts := map[string]int{} statusCounts := map[int]int{} diff --git a/tools/scan/command.go b/tools/scan/command.go index de4c5800..f3cca1af 100644 --- a/tools/scan/command.go +++ b/tools/scan/command.go @@ -8,7 +8,7 @@ import ( "path/filepath" "github.com/chainreactors/aiscan/agent" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" @@ -151,7 +151,7 @@ func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) var scanWriter *scanJSONLWriter if flags.OutputFile != "" { - var agentBus *eventbus.Bus[aop.Event] + var agentBus *eventbus.Bus[*aop.Event] if c.parent != nil { agentBus = c.parent.Cfg.Bus } diff --git a/tools/scan/jsonl_writer.go b/tools/scan/jsonl_writer.go index e56d2d17..9fd81256 100644 --- a/tools/scan/jsonl_writer.go +++ b/tools/scan/jsonl_writer.go @@ -1,13 +1,13 @@ package scan import ( - "encoding/json" "strings" - "github.com/chainreactors/aiscan/core/aop" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/tools/scan/pipeline" + "google.golang.org/protobuf/encoding/protojson" ) type scanJSONLWriter struct { @@ -16,7 +16,7 @@ type scanJSONLWriter struct { agentUnsub func() } -func newScanJSONLWriter(path string, scanBus *eventbus.Bus[pipeline.Observation], agentBus *eventbus.Bus[aop.Event]) (*scanJSONLWriter, error) { +func newScanJSONLWriter(path string, scanBus *eventbus.Bus[pipeline.Observation], agentBus *eventbus.Bus[*aop.Event]) (*scanJSONLWriter, error) { tw, err := output.NewTimelineWriter(path) if err != nil { return nil, err @@ -58,8 +58,8 @@ func (w *scanJSONLWriter) handleObservation(obs pipeline.Observation) { } } -func (w *scanJSONLWriter) handleAgentEvent(event aop.Event) { - raw, _ := json.Marshal(event) +func (w *scanJSONLWriter) handleAgentEvent(event *aop.Event) { + raw, _ := protojson.Marshal(event) w.w.WriteRaw(raw) } diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui index 2ab3d12a..486b17e5 160000 --- a/web/frontend/cyber-ui +++ b/web/frontend/cyber-ui @@ -1 +1 @@ -Subproject commit 2ab3d12a1f312aacfd9319081fae6fd35a6733d8 +Subproject commit 486b17e5b97af90a16719700991d94dda4d01001 diff --git a/web/frontend/e2e/aiscan-web.spec.ts b/web/frontend/e2e/aiscan-web.spec.ts index 7b297178..cc35152c 100644 --- a/web/frontend/e2e/aiscan-web.spec.ts +++ b/web/frontend/e2e/aiscan-web.spec.ts @@ -1,15 +1,63 @@ import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; const API_TOKEN = process.env.ACCESS_KEY || 'test-token'; -const LLM_PROVIDER = process.env.LLM_PROVIDER || 'openai'; -const LLM_BASE_URL = process.env.LLM_BASE_URL || ''; -const LLM_API_KEY = process.env.LLM_API_KEY || ''; -const LLM_MODEL = process.env.LLM_MODEL || ''; +const WEB_BASE_URL = process.env.BASE_URL || `http://127.0.0.1:${process.env.AISCAN_E2E_PORT || '38080'}`; +const execFileAsync = promisify(execFile); +const externalGoClientDir = fileURLToPath(new URL('../../../examples/external-go-client/', import.meta.url)); function apiHeaders() { return { Authorization: `Bearer ${API_TOKEN}` }; } +function rpcID(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +async function connectRPC(request: APIRequestContext, procedure: string, data: Record) { + const response = await request.post(procedure, { + headers: { + ...apiHeaders(), + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', + }, + data, + }); + if (!response.ok()) { + const body = await response.text(); + expect(response.ok(), `${procedure}: ${body}`).toBeTruthy(); + } + return response.json(); +} + +async function openChatSession(request: APIRequestContext, participant: string) { + const sessionID = rpcID('session'); + const response = await connectRPC(request, '/aop.ChatService/OpenSession', { + requestId: rpcID('open'), sessionId: sessionID, participant, + }); + expect(response.accepted?.id).toBe(sessionID); + return response.accepted; +} + +async function deleteChatSession(request: APIRequestContext, sessionID: string) { + return connectRPC(request, '/aiscan.chat.SessionService/DeleteSession', { + requestId: rpcID('delete'), sessionId: sessionID, + }); +} + +async function runChatTurn(request: APIRequestContext, sessionID: string, content: string) { + const turnID = rpcID('turn'); + const messageID = rpcID('message'); + const response = await connectRPC(request, '/aop.ChatService/RunTurn', { + requestId: rpcID('run'), sessionId: sessionID, turnId: turnID, + input: { id: messageID, role: 'user', content: [{ text: { text: content } }] }, + }); + expect(response.accepted?.turnId).toBe(turnID); + return { turnID, messageID }; +} + async function openAuthenticatedApp(page: Page) { const login = await page.request.post('/api/auth/login', { data: { token: API_TOKEN }, @@ -279,14 +327,17 @@ test.describe('Config API', () => { }); test('LLM connectivity test succeeds with explicit config', async ({ request }) => { - test.skip(!LLM_API_KEY, 'LLM_API_KEY env var required'); + const configResponse = await request.get('/api/config', { headers: apiHeaders() }); + expect(configResponse.ok()).toBeTruthy(); + const config = await configResponse.json(); const res = await request.post('/api/config/llm/test', { headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, data: { - provider: LLM_PROVIDER, - base_url: LLM_BASE_URL, - api_key: LLM_API_KEY, - model: LLM_MODEL, + profile_id: config.llm.active_profile, + provider: config.llm.provider, + base_url: config.llm.base_url, + api_key: '', + model: config.llm.model, }, }); expect(res.ok()).toBeTruthy(); @@ -315,32 +366,26 @@ test.describe('Agents API', () => { test.describe('Chat Session CRUD', () => { test('create, list, and delete a session', async ({ request }) => { - // First, get available agents const agents = await requireRegisteredAgents(request); const agentID = agents[0].id; - // Create - const createRes = await request.post('/api/chat/sessions', { - headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, - data: { agent_id: agentID }, - }); - expect(createRes.ok()).toBeTruthy(); - const session = await createRes.json(); + const session = await openChatSession(request, agentID); expect(session.id).toBeTruthy(); - expect(session.agent_id).toBe(agentID); - - // List - const listRes = await request.get('/api/chat/sessions', { headers: apiHeaders() }); - expect(listRes.ok()).toBeTruthy(); - const sessions = await listRes.json(); - expect(Array.isArray(sessions)).toBeTruthy(); - expect(sessions.some((s: any) => s.id === session.id)).toBeTruthy(); - - // Delete - const delRes = await request.delete(`/api/chat/sessions/${session.id}`, { - headers: apiHeaders(), + expect(session.participant).toBe(agentID); + + const listed = await connectRPC(request, '/aiscan.chat.SessionService/ListSessions', { + limit: 100, includeClosed: true, }); - expect(delRes.ok()).toBeTruthy(); + expect(Array.isArray(listed.sessions)).toBeTruthy(); + expect(listed.sessions.some((record: any) => record.session?.id === session.id)).toBeTruthy(); + + const deleted = await deleteChatSession(request, session.id); + expect(deleted.accepted?.id).toBe(session.id); + }); + + test('legacy chat REST routes are removed', async ({ request }) => { + const response = await request.get('/api/chat/sessions', { headers: apiHeaders() }); + expect(response.status()).toBe(404); }); }); @@ -350,37 +395,24 @@ test.describe('Chat Session CRUD', () => { test.describe('Chat LLM round-trip', () => { test('send a message and receive an assistant response', async ({ request }) => { - // Get agent const agents = await requireRegisteredAgents(request); const agentID = agents[0].id; - // Create session - const createRes = await request.post('/api/chat/sessions', { - headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, - data: { agent_id: agentID }, - }); - const session = await createRes.json(); + const session = await openChatSession(request, agentID); const sessionID = session.id; try { - // Send message - const sendRes = await request.post(`/api/chat/sessions/${sessionID}/messages`, { - headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, - data: { content: 'Reply with exactly one word: PONG' }, - }); - expect(sendRes.ok()).toBeTruthy(); + await runChatTurn(request, sessionID, 'Reply with exactly one word: PONG'); - // Poll for assistant response (up to 30s) let assistantMsg: any = null; for (let i = 0; i < 15; i++) { await new Promise((r) => setTimeout(r, 2000)); - const msgRes = await request.get(`/api/chat/sessions/${sessionID}/messages`, { - headers: apiHeaders(), + const listed = await connectRPC(request, '/aop.ChatService/ListEvents', { + sessionId: sessionID, limit: 500, }); - const page = await msgRes.json(); - expect(Array.isArray(page.items)).toBeTruthy(); - const messages = page.items; - const assistantMsgs = messages.filter((m: any) => m.role === 'assistant'); + const assistantMsgs = listed.events + .map((delivery: any) => delivery.event?.message) + .filter((message: any) => message?.role === 'assistant'); if (assistantMsgs.length > 0) { assistantMsg = assistantMsgs[assistantMsgs.length - 1]; break; @@ -388,42 +420,50 @@ test.describe('Chat LLM round-trip', () => { } expect(assistantMsg).not.toBeNull(); - expect(assistantMsg.content).toBeTruthy(); - expect(assistantMsg.content.length).toBeGreaterThan(0); + expect(assistantMsg.content?.length).toBeGreaterThan(0); } finally { - // Cleanup - await request.delete(`/api/chat/sessions/${sessionID}`, { - headers: apiHeaders(), - }); + await deleteChatSession(request, sessionID); } }); }); +test.describe('External Go Connect client', () => { + test('an independent Go module opens, runs, and streams a turn', async ({ request }) => { + const agents = await requireRegisteredAgents(request); + const { stdout, stderr } = await execFileAsync('go', [ + 'run', '.', + '-url', WEB_BASE_URL, + '-token', API_TOKEN, + '-agent', agents[0].id, + '-prompt', 'Reply with exactly one word: PONG', + '-timeout', '30s', + ], { + cwd: externalGoClientDir, + timeout: 45_000, + env: { ...process.env, GOWORK: 'off' }, + }); + expect(stderr).not.toContain('error:'); + expect(stdout).toContain('PONG'); + expect(stdout).toContain('stop=completed'); + }); +}); + // --------------------------------------------------------------------------- -// 10. SSE reconnect and durable event cursor +// 10. Connect stream reconnect and durable event cursor // --------------------------------------------------------------------------- -test.describe('SSE reconnect', () => { +test.describe('Connect stream reconnect', () => { test('replays missing durable events after the browser reconnects', async ({ page, request, context }) => { const agents = await requireRegisteredAgents(request); - const createRes = await request.post('/api/chat/sessions', { - headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, - data: { agent_id: agents[0].id }, - }); - expect(createRes.ok()).toBeTruthy(); - const session = await createRes.json(); + const session = await openChatSession(request, agents[0].id); await openAuthenticatedApp(page); await page.goto(`/sessions/${session.id}`); + await expect(page.getByRole('textbox', { name: 'Type a message... (/ for commands)' })).toBeVisible(); const prompt = 'Reply with exactly one word: PONG'; - const sendRes = await request.post(`/api/chat/sessions/${session.id}/messages`, { - headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, - data: { content: prompt }, - }); - expect(sendRes.ok()).toBeTruthy(); + await runChatTurn(request, session.id, prompt); - await expect(page.locator('p').filter({ hasText: prompt })).toBeVisible({ timeout: 10_000 }); await context.setOffline(true); await page.waitForTimeout(3500); await context.setOffline(false); @@ -432,7 +472,7 @@ test.describe('SSE reconnect', () => { await expect(resumed).toBeVisible({ timeout: 15_000 }); await expect(resumed).toHaveCount(1); - await request.delete(`/api/chat/sessions/${session.id}`, { headers: apiHeaders() }); + await deleteChatSession(request, session.id); }); }); @@ -460,12 +500,12 @@ test.describe('Asset Pool API', () => { // 11. Scans API // --------------------------------------------------------------------------- -test.describe('Scans API', () => { - test('list scans returns array', async ({ request }) => { - const res = await request.get('/api/scans', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(Array.isArray(body)).toBeTruthy(); +test.describe('Scan ConnectRPC', () => { + test('lists scans through ScanService and retires the REST route', async ({ request }) => { + const body = await connectRPC(request, '/aiscan.scan.ScanService/ListScans', {}); + expect(Array.isArray(body.scans ?? [])).toBeTruthy(); + const legacy = await request.get('/api/scans', { headers: apiHeaders() }); + expect(legacy.status()).toBe(404); }); }); @@ -474,6 +514,62 @@ test.describe('Scans API', () => { // --------------------------------------------------------------------------- test.describe('Chat UI', () => { + test('sends natural language and receives the streamed answer in the browser', async ({ page, request }) => { + const agents = await requireRegisteredAgents(request); + const session = await openChatSession(request, agents[0].id); + const browserErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') browserErrors.push(`console: ${message.text()}`); + }); + page.on('pageerror', (error) => browserErrors.push(`page: ${error.message}`)); + page.on('requestfailed', (failed) => { + if (!failed.failure()?.errorText.includes('ERR_ABORTED')) { + browserErrors.push(`request: ${failed.method()} ${failed.url()} ${failed.failure()?.errorText}`); + } + }); + + try { + await openAuthenticatedApp(page); + await page.goto(`/sessions/${session.id}`); + const input = page.getByRole('textbox', { name: 'Type a message... (/ for commands)' }); + await input.fill('Reply with exactly one word: PONG'); + await page.getByRole('button', { name: 'Send message' }).click(); + await expect(page.getByText('PONG', { exact: true })).toBeVisible({ timeout: 20_000 }); + expect(browserErrors).toEqual([]); + } finally { + await deleteChatSession(request, session.id); + } + }); + + test('terminal WebSocket exchanges TerminalFrame protobuf JSON', async ({ page, request }) => { + const agents = await requireRegisteredAgents(request); + await openAuthenticatedApp(page); + const result = await page.evaluate(async ({ agentID }) => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${protocol}//${window.location.host}/api/agents/${encodeURIComponent(agentID)}/terminal/ws`; + return await new Promise<{ type?: string; sessions?: unknown[]; error?: string }>((resolve, reject) => { + const socket = new WebSocket(url); + const timer = window.setTimeout(() => { + socket.close(); + reject(new Error('terminal WebSocket timeout')); + }, 10_000); + socket.onopen = () => socket.send(JSON.stringify({ type: 'list' })); + socket.onerror = () => reject(new Error('terminal WebSocket error')); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame.type !== 'sessions' && frame.type !== 'error') return; + window.clearTimeout(timer); + socket.send(JSON.stringify({ type: 'detach' })); + socket.close(); + resolve(frame); + }; + }); + }, { agentID: agents[0].id }); + expect(result.error).toBeFalsy(); + expect(result.type).toBe('sessions'); + expect(Array.isArray(result.sessions)).toBeTruthy(); + }); + test('UI renders the main chat area', async ({ page }) => { await openAuthenticatedApp(page); await page.waitForLoadState('networkidle'); @@ -545,13 +641,16 @@ test.describe('Theme', () => { test.describe('LLM Models', () => { test('can fetch available models from provider', async ({ request }) => { - test.skip(!LLM_API_KEY, 'LLM_API_KEY env var required'); + const configResponse = await request.get('/api/config', { headers: apiHeaders() }); + expect(configResponse.ok()).toBeTruthy(); + const config = await configResponse.json(); const res = await request.post('/api/config/llm/models', { headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, data: { - provider: LLM_PROVIDER, - base_url: LLM_BASE_URL, - api_key: LLM_API_KEY, + profile_id: config.llm.active_profile, + provider: config.llm.provider, + base_url: config.llm.base_url, + api_key: '', }, }); expect(res.ok()).toBeTruthy(); diff --git a/web/frontend/e2e/start-server.mjs b/web/frontend/e2e/start-server.mjs index ba975dde..fa4d0fa5 100644 --- a/web/frontend/e2e/start-server.mjs +++ b/web/frontend/e2e/start-server.mjs @@ -73,6 +73,19 @@ await writeFile(configPath, `llm: model: deepseek-chat `, { mode: 0o600 }) +const npmCommand = process.platform === 'win32' ? 'cmd.exe' : 'npm' +const npmArgs = process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run build'] : ['run', 'build'] +const frontendBuild = spawnSync(npmCommand, npmArgs, { + cwd: resolve(root, 'web/frontend'), + stdio: 'inherit', +}) +if (frontendBuild.status !== 0) { + if (frontendBuild.error) console.error(frontendBuild.error) + mockLLM.close() + await rm(workDir, { recursive: true, force: true }) + process.exit(frontendBuild.status ?? 1) +} + const build = spawnSync('go', ['build', '-tags', 'full', '-o', binary, './cmd/aiscan'], { cwd: root, stdio: 'inherit', diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 64a2d88f..4866e3f9 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "aiscan-web-frontend", "version": "0.2.0", "dependencies": { + "@bufbuild/protobuf": "^2.9.0", + "@connectrpc/connect": "^2.1.2", + "@connectrpc/connect-web": "^2.1.2", "@radix-ui/react-context-menu": "^2.3.3", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -43,6 +46,7 @@ "yaml": "^2.8.1" }, "devDependencies": { + "@bufbuild/protoc-gen-es": "^2.13.0", "@playwright/test": "^1.61.1", "@tailwindcss/typography": "^0.5.15", "@types/chroma-js": "^2.4.5", @@ -363,6 +367,82 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz", + "integrity": "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.13.0.tgz", + "integrity": "sha512-ylI1vrLksdnXrVZRs9xGxmrQxKGhUm6pPszv26kqBvNiO3qPTktk+hgfwbLISBY4M/reShkT2dFLGT9fbydBXg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.13.0", + "@bufbuild/protoplugin": "2.13.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.13.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.13.0.tgz", + "integrity": "sha512-32eMChKaL/A8Hh5AfMmXSdnuyznN85uoEjoyWiWeRrvtQOtpqX/v1R9PDe0g9vMIgzznK9inMT3CUaal0kjLUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.13.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz", + "integrity": "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.2.tgz", + "integrity": "sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.2" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2784,6 +2864,19 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", diff --git a/web/frontend/package.json b/web/frontend/package.json index 2693a032..0ef08ac7 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -11,6 +11,9 @@ "test:e2e:headed": "playwright test --headed" }, "dependencies": { + "@bufbuild/protobuf": "^2.9.0", + "@connectrpc/connect": "^2.1.2", + "@connectrpc/connect-web": "^2.1.2", "@radix-ui/react-context-menu": "^2.3.3", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -46,6 +49,7 @@ "yaml": "^2.8.1" }, "devDependencies": { + "@bufbuild/protoc-gen-es": "^2.13.0", "@playwright/test": "^1.61.1", "@tailwindcss/typography": "^0.5.15", "@types/chroma-js": "^2.4.5", diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index ec2d8ece..aaaad5bd 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -19,10 +19,34 @@ export interface ScanJob { } import type { SCONode } from '@cyber/cstx-easm'; -import type { AOPEvent } from '@cyber/agent-protocol'; +import { Code, ConnectError, createClient } from '@connectrpc/connect' +import { createConnectTransport } from '@connectrpc/connect-web' +import { + ChatService, + ScanService, + ScanStatus, + SessionService, + type Event as AOPEvent, + type EventDelivery, + type Scan as ProtoScan, + type SessionRecord, +} from '@cyber/aop'; export type { SCONode }; export type { AOPEvent }; +const connectTransport = createConnectTransport({ + baseUrl: window.location.origin, + useBinaryFormat: false, +}) + +// One AIScan facade is initialized for the application. The generated service +// clients are lightweight API groups and all share this single transport. +const aiscanRPC = { + chat: createClient(ChatService, connectTransport), + sessions: createClient(SessionService, connectTransport), + scans: createClient(ScanService, connectTransport), +} + export interface ScanResult { summary: ScanResultSummary; assets?: Asset[]; @@ -112,8 +136,6 @@ export interface ScanEvent { result?: ScanResult; } -type RawScanEventType = ScanEvent['type'] | 'output'; - export interface ScanOptions { verify: boolean; sniper: boolean; @@ -470,11 +492,14 @@ export async function stopLocalAgent(name: string): Promise { } export async function submitScan(target: string, mode: string, options: ScanOptions, project?: string): Promise { - return apiJSON('/api/scans', 'Failed to submit scan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ target, mode, ...options, project }), - }); + void project + try { + const response = await aiscanRPC.scans.submitScan({ requestId: newRPCID(), target, mode, options }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to submit scan') + return scanToView(response.outcome.value) + } catch (error) { + throw connectFailure(error, 'Failed to submit scan') + } } export async function getAssets(project?: string): Promise { @@ -549,96 +574,72 @@ export async function deleteProject(id: string): Promise { } export async function getScan(id: string): Promise { - return apiJSON(`/api/scans/${encodeURIComponent(id)}`, 'Scan not found'); + try { + const response = await aiscanRPC.scans.getScan({ scanId: id }) + if (!response.scan) throw new Error('Scan not found') + return scanToView(response.scan) + } catch (error) { + throw connectFailure(error, 'Scan not found') + } } export async function listScans(project?: string): Promise { - const q = project ? `?project=${encodeURIComponent(project)}` : ''; - return apiJSON(`/api/scans${q}`, 'Failed to list scans'); + void project + try { + const response = await aiscanRPC.scans.listScans({}) + return response.scans.map(scanToView) + } catch (error) { + throw connectFailure(error, 'Failed to list scans') + } } export async function deleteScan(id: string): Promise { - await apiJSON(`/api/scans/${encodeURIComponent(id)}`, 'Failed to delete scan', { method: 'DELETE' }); -} - -// subscribeSSE is the module-private EventSource primitive: one place that -// wires named handlers, extracts the data string, and manages lifecycle. -// Handlers receive the raw data string (possibly empty) and decide on parsing. -function subscribeSSE( - url: string, - handlers: Record void>, - opts?: { onOpen?: () => void; onError?: () => void }, -): EventSource { - const es = new EventSource(url) - if (opts?.onOpen) es.addEventListener('open', () => opts.onOpen!()) - if (opts?.onError) es.addEventListener('error', () => opts.onError!()) - for (const [type, handler] of Object.entries(handlers)) { - es.addEventListener(type, (e: Event) => { - const data = 'data' in e ? (e as MessageEvent).data : undefined - if (typeof data !== 'string') return - handler(data, e) - }) - } - return es + try { + const response = await aiscanRPC.scans.cancelScan({ requestId: newRPCID(), scanId: id }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to cancel scan') + } catch (error) { + throw connectFailure(error, 'Failed to cancel scan') + } } export function subscribeScanEvents( id: string, onEvent: (event: ScanEvent) => void, ): () => void { - let es: EventSource | null = null - const close = () => es?.close() - const handler = (type: RawScanEventType) => (data: string) => { - if (data === '') { - if (type === 'error') { - void getScan(id) - .then((job) => { - if (job.status === 'completed') { - onEvent({ type: 'complete', scan_id: id, status: job.status }); - close(); - } else if (job.status === 'failed' || job.status === 'canceled') { - onEvent({ - type: 'error', - scan_id: id, - error: job.error || `Scan ${job.status}`, - }); - close(); - } - }) - .catch(() => {}); - } - return; - } - - let event: ScanEvent; - try { - const parsed = JSON.parse(data); - const normalizedType = type === 'output' ? 'progress' : type; - const parsedType = parsed?.type === 'output' ? 'progress' : parsed?.type || normalizedType; - event = { - scan_id: id, - ...parsed, - type: parsedType, - }; - } catch { - event = { type: type === 'output' ? 'progress' : type, scan_id: id, data }; - } - - onEvent(event); - if (event.type === 'complete' || event.type === 'error') { - close(); - } - }; - es = subscribeSSE(`/api/scans/${encodeURIComponent(id)}/events`, { - progress: handler('progress'), - status: handler('status'), - stats: handler('stats'), - complete: handler('complete'), - error: handler('error'), - output: handler('output'), - }); - - return () => es?.close(); + const controller = new AbortController() + void (async () => { + try { + for await (const response of aiscanRPC.scans.watchScanEvents({ scanId: id }, { signal: controller.signal })) { + const value = response.event + if (!value) continue + switch (value.payload.case) { + case 'snapshot': { + const scan = scanToView(value.payload.value) + onEvent({ type: scan.status === 'completed' ? 'complete' : scan.status === 'failed' || scan.status === 'canceled' ? 'error' : 'status', scan_id: id, status: scan.status, error: scan.error, result: scan.result }) + break + } + case 'status': + onEvent({ type: 'status', scan_id: id, status: scanStatusName(value.payload.value) }) + break + case 'progress': + onEvent({ type: 'progress', scan_id: id, data: value.payload.value.data }) + break + case 'stats': + onEvent({ type: 'stats', scan_id: id }) + break + case 'completed': + onEvent({ type: 'complete', scan_id: id, status: 'completed', result: decodeEncoded(value.payload.value.result) }) + break + case 'failed': + onEvent({ type: 'error', scan_id: id, status: value.payload.value.canceled ? 'canceled' : 'failed', error: value.payload.value.message }) + break + } + } + } catch (error) { + if (!controller.signal.aborted) connectFailure(error, 'Scan event stream disconnected') + } + })() + return () => controller.abort() } // --- Chat session types --- @@ -664,49 +665,73 @@ export interface ChatMessage { metadata?: Record created_at: string cursor?: number -} - -export interface ChatMessagePage { - items: ChatMessage[] - next_cursor?: number -} - -export type DomainEventType = - | 'scan_started' | 'scan_progress' | 'scan_complete' - | 'agent_joined' | 'session_cleared' - -export interface DomainEvent { - type: DomainEventType - session_id: string - agent_id?: string - agent_name?: string - scan_id?: string - result?: ScanResult - data?: string + turn_id?: string } // --- Chat session API --- export async function createChatSession(agentID: string, title?: string, scanID?: string): Promise { - return apiJSON('/api/chat/sessions', 'Failed to create session', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agent_id: agentID, title: title || '', scan_id: scanID || '' }), - }) + void scanID + try { + const response = await aiscanRPC.chat.openSession({ + requestId: newRPCID(), + sessionId: newRPCID(), + participant: agentID, + title: title || '', + }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to create session') + const now = new Date().toISOString() + return { + id: response.outcome.value.id, + agent_id: response.outcome.value.participant, + title: response.outcome.value.title, + status: response.outcome.value.state === 'closed' ? 'archived' : 'active', + created_at: now, + updated_at: now, + } + } catch (error) { + throw connectFailure(error, 'Failed to create session') + } } export async function listChatSessions(): Promise { - return apiJSON('/api/chat/sessions', 'Failed to list sessions') + try { + const response = await aiscanRPC.sessions.listSessions({ limit: 100, includeClosed: true }) + return response.sessions.map(sessionRecordToView) + } catch (error) { + throw connectFailure(error, 'Failed to list sessions') + } } export async function getChatSession(id: string): Promise { - return apiJSON(`/api/chat/sessions/${encodeURIComponent(id)}`, 'Session not found') + try { + const response = await aiscanRPC.sessions.getSession({ sessionId: id }) + if (!response.session) throw new Error('Session not found') + return sessionRecordToView(response.session) + } catch (error) { + throw connectFailure(error, 'Session not found') + } } export async function deleteChatSession(id: string): Promise { - await apiJSON(`/api/chat/sessions/${encodeURIComponent(id)}`, 'Failed to delete session', { - method: 'DELETE', - }) + try { + const response = await aiscanRPC.sessions.deleteSession({ requestId: newRPCID(), sessionId: id }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to delete session') + } catch (error) { + throw connectFailure(error, 'Failed to delete session') + } +} + +export async function resetChatSession(id: string): Promise { + try { + const response = await aiscanRPC.sessions.resetSession({ requestId: newRPCID(), sessionId: id }) + if (response.outcome.case !== 'accepted' || !response.outcome.value.current) { + throw rejectionError(response.outcome.case === 'rejected' ? response.outcome.value : undefined, 'Failed to reset session') + } + return sessionRecordToView(response.outcome.value.current) + } catch (error) { + throw connectFailure(error, 'Failed to reset session') + } } // SlashCommandSpec mirrors pkg/slashcmd.Spec — the server's canonical view of a @@ -722,37 +747,101 @@ export interface SlashCommandSpec { } export async function fetchSessionCommands(sessionID: string): Promise { - return apiJSON(`/api/chat/sessions/${encodeURIComponent(sessionID)}/commands`, 'Failed to load commands') + try { + const response = await aiscanRPC.sessions.listCommands({ sessionId: sessionID }) + return response.commands.map((command) => ({ + name: command.name, + aliases: command.aliases, + usage: command.usage, + description: command.description, + scope: 0, + })) + } catch (error) { + throw connectFailure(error, 'Failed to load commands') + } } export async function sendChatMessage( sessionID: string, content: string, - opts?: { persist?: boolean; evalCriteria?: string; evalMaxRounds?: number }, + opts?: { + persist?: boolean + evalCriteria?: string + evalMaxRounds?: number + messageID?: string + turnID?: string + requestID?: string + continueSession?: boolean + }, ): Promise { - const body: Record = { content } - // Goal mode: the only run-control the backend acts on is a natural-language - // completion criteria judged by an independent evaluator for up to N rounds - // (webagent runChatEval). Persist without criteria is just a normal message. - if (opts?.persist) { - const criteria = opts.evalCriteria?.trim() - if (criteria) { - body.persist = true - body.eval_criteria = criteria - if (opts.evalMaxRounds && opts.evalMaxRounds > 0) body.eval_max_rounds = opts.evalMaxRounds + const messageID = opts?.messageID || newRPCID() + const turnID = opts?.turnID || newRPCID() + const extensions = [] + const criteria = opts?.persist ? opts.evalCriteria?.trim() : '' + if (criteria) { + const value = JSON.stringify({ evalCriteria: criteria, evalMaxRounds: Math.max(opts?.evalMaxRounds || 0, 0) }) + extensions.push({ + namespace: 'io.chainreactors.aiscan.run', + value: { data: new TextEncoder().encode(value), mediaType: 'application/protobuf+json' }, + }) + } + try { + const response = await aiscanRPC.chat.runTurn({ + requestId: opts?.requestID || newRPCID(), + sessionId: sessionID, + turnId: turnID, + continueSession: opts?.continueSession === true, + input: { + id: messageID, + role: 'user', + content: opts?.continueSession ? [] : [{ value: { case: 'text', value: { text: content } } }], + }, + extensions, + }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to send message') + return { + id: messageID, + session_id: sessionID, + role: 'user', + content, + created_at: new Date().toISOString(), + turn_id: response.outcome.value.turnId, } + } catch (error) { + throw connectFailure(error, 'Failed to send message') } - return apiJSON(`/api/chat/sessions/${encodeURIComponent(sessionID)}/messages`, 'Failed to send message', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) } -export async function cancelChatSession(sessionID: string): Promise { - await apiJSON(`/api/chat/sessions/${encodeURIComponent(sessionID)}/cancel`, 'Failed to pause response', { - method: 'POST', - }) +export async function executeChatCommand(sessionID: string, line: string): Promise { + try { + const response = await aiscanRPC.sessions.executeCommand({ requestId: newRPCID(), sessionId: sessionID, line }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to execute command') + } catch (error) { + throw connectFailure(error, 'Failed to execute command') + } +} + +export async function cancelChatSession(sessionID: string, turnID: string): Promise { + if (!turnID) throw new Error('No active turn') + try { + const response = await aiscanRPC.chat.cancelTurn({ + requestId: newRPCID(), sessionId: sessionID, turnId: turnID, reason: 'user_requested', + }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to pause response') + } catch (error) { + throw connectFailure(error, 'Failed to pause response') + } +} + +export async function closeChatSession(sessionID: string): Promise { + try { + const response = await aiscanRPC.chat.closeSession({ + requestId: newRPCID(), sessionId: sessionID, reason: 'completed', + }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Failed to close session') + } catch (error) { + throw connectFailure(error, 'Failed to close session') + } } export interface FileUploadResult { @@ -763,79 +852,194 @@ export interface FileUploadResult { } export async function uploadChatFile(sessionID: string, file: File): Promise { - const form = new FormData() - form.append('file', file) - const resp = await authenticatedFetch(`/api/chat/sessions/${encodeURIComponent(sessionID)}/upload`, { - method: 'POST', - body: form, - }) - if (!resp.ok) { - const body = await resp.text() - throw new Error(body || `Upload failed: ${resp.status}`) + try { + const response = await aiscanRPC.sessions.uploadSessionFile({ + requestId: newRPCID(), + sessionId: sessionID, + filename: file.name, + mediaType: file.type || 'application/octet-stream', + data: new Uint8Array(await file.arrayBuffer()), + }) + if (response.outcome.case !== 'accepted') throw rejectionError(response.outcome.value, 'Upload failed') + return { + filename: response.outcome.value.filename, + path: response.outcome.value.path, + size: Number(response.outcome.value.size), + } + } catch (error) { + throw connectFailure(error, 'Upload failed') } - return resp.json() } export async function listChatMessages(sessionID: string): Promise { - const page: ChatMessagePage = await apiJSON( - `/api/chat/sessions/${encodeURIComponent(sessionID)}/messages`, - 'Failed to list messages', - ) - return page.items + try { + const response = await aiscanRPC.chat.listEvents({ sessionId: sessionID, limit: 500 }) + return response.events.flatMap((delivery) => deliveryToChatMessage(delivery) || []) + } catch (error) { + throw connectFailure(error, 'Failed to list messages') + } } // Fetch a scan's markdown report, re-rendered server-side in the given language // ('en' | 'zh'). Returns '' when the report isn't ready yet (404) so callers can // just show a placeholder. export async function fetchScanReport(scanID: string, lang: string): Promise { - const res = await authenticatedFetch(`/api/scans/${encodeURIComponent(scanID)}/report?lang=${encodeURIComponent(lang)}`) - if (!res.ok) return '' - return res.text() + try { + const response = await aiscanRPC.scans.getScanReport({ scanId: scanID, language: lang }) + return response.markdown + } catch { + return '' + } } -export function subscribeDomainEvents( +export function subscribeAOPEvents( sessionID: string, - onEvent: (event: DomainEvent) => void, + onEvent: (event: AOPEvent) => void, onReconnect?: () => void, - onAOP?: (event: AOPEvent) => void, - onOpen?: () => void, ): () => void { - const eventTypes: DomainEventType[] = [ - 'scan_started', 'scan_progress', 'scan_complete', - 'agent_joined', 'session_cleared', - ] - - const handlers: Record void> = {} - for (const type of eventTypes) { - handlers[type] = (data: string) => { - if (data === '') return + const controller = new AbortController() + let cursor = '' + void (async () => { + let retry = 250 + while (!controller.signal.aborted) { try { - const parsed = JSON.parse(data) - onEvent({ ...parsed, type }) - } catch { - onEvent({ type, session_id: sessionID, data } as DomainEvent) + for await (const response of aiscanRPC.chat.watchEvents( + { sessionId: sessionID, afterCursor: cursor }, + { signal: controller.signal }, + )) { + const delivery = response.delivery + if (!delivery?.event) continue + cursor = delivery.cursor + onEvent(delivery.event) + retry = 250 + } + } catch (error) { + if (controller.signal.aborted) return + connectFailure(error, 'Event stream disconnected') + onReconnect?.() + await new Promise((resolve) => window.setTimeout(resolve, retry)) + retry = Math.min(retry * 2, 5000) } } + })() + return () => controller.abort() +} + +function sessionRecordToView(record: SessionRecord): ChatSession { + const session = record.session + if (!session) throw new Error('Session record is missing its AOP session') + return { + id: session.id, + agent_id: session.participant, + agent_name: record.agentName || undefined, + title: session.title, + status: session.state === 'closed' ? 'archived' : 'active', + scan_ids: record.scanIds, + created_at: timestampToISOString(record.createdAt), + updated_at: timestampToISOString(record.updatedAt), } - handlers['aop'] = (data: string) => { - if (data === '') return +} + +function timestampToISOString(value?: { seconds: bigint; nanos: number }): string { + if (!value) return new Date(0).toISOString() + return new Date(Number(value.seconds) * 1000 + Math.floor(value.nanos / 1_000_000)).toISOString() +} + +function scanStatusName(value: ScanStatus): ScanJob['status'] { + switch (value) { + case ScanStatus.QUEUED: return 'queued' + case ScanStatus.RUNNING: return 'running' + case ScanStatus.COMPLETED: return 'completed' + case ScanStatus.FAILED: return 'failed' + case ScanStatus.CANCELED: return 'canceled' + default: return 'queued' + } +} + +function decodeEncoded(value?: { data: Uint8Array }): T | undefined { + if (!value?.data?.length) return undefined + try { + return JSON.parse(new TextDecoder().decode(value.data)) as T + } catch { + return undefined + } +} + +function scanToView(scan: ProtoScan): ScanJob { + return { + id: scan.id, + target: scan.target, + mode: scan.mode, + verify: scan.options?.verify, + sniper: scan.options?.sniper, + deep: scan.options?.deep, + status: scanStatusName(scan.status), + progress: scan.progress || undefined, + report: scan.report || undefined, + result: decodeEncoded(scan.result), + error: scan.error || undefined, + created_at: timestampToISOString(scan.createdAt), + updated_at: timestampToISOString(scan.updatedAt), + } +} + +function deliveryToChatMessage(delivery: EventDelivery): ChatMessage | null { + const event = delivery.event + if (!event || event.payload.case !== 'message') return null + const message = event.payload.value + const text = message.content + .filter((part) => part.value.case === 'text') + .map((part) => part.value.case === 'text' ? part.value.value.text : '') + .join('\n') + const webExtension = event.extensions.find((extension) => extension.namespace === 'io.chainreactors.aiscan.web') + let metadata: Record | undefined + let agentID: string | undefined + if (webExtension?.value?.data?.length) { try { - const parsed = JSON.parse(data) as AOPEvent - if (parsed.session_id && parsed.agent && parsed.type && parsed.ts && parsed.data) onAOP?.(parsed) - } catch { - // Ignore malformed protocol frames; platform events continue normally. - } + const decoded = JSON.parse(new TextDecoder().decode(webExtension.value.data)) as Record + agentID = typeof decoded.agentId === 'string' ? decoded.agentId : undefined + metadata = decoded.metadata && typeof decoded.metadata === 'object' ? decoded.metadata as Record : undefined + } catch {} + } + const role = message.role === 'assistant' || message.role === 'system' ? message.role : 'user' + return { + id: message.id, + session_id: event.sessionId, + role, + agent_id: agentID, + agent_name: event.emitter, + content: text, + metadata, + created_at: timestampToISOString(event.emittedAt), + cursor: delivery.cursor ? Number(delivery.cursor) : undefined, + turn_id: event.turnId || undefined, } +} - // EventSource reconnects automatically. Reconcile platform-domain state - // from REST; AOP itself is replayed from durable storage by the SSE endpoint. - const es = subscribeSSE( - `/api/chat/sessions/${encodeURIComponent(sessionID)}/events`, - handlers, - { onOpen, onError: onReconnect }, - ) +function rejectionError(value: { code?: string; message?: string } | undefined, fallback: string): Error { + return new Error(value?.message || value?.code || fallback) +} + +function connectFailure(error: unknown, fallback: string): Error { + const failure = ConnectError.from(error) + if (failure.code === Code.Unauthenticated) window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT)) + return new Error(failure.rawMessage || failure.message || fallback) +} - return () => es.close() +function newRPCID(): string { + const value = globalThis.crypto + if (value && typeof value.randomUUID === 'function') { + try { return value.randomUUID() } catch {} + } + if (value && typeof value.getRandomValues === 'function') { + const bytes = new Uint8Array(16) + value.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (item) => item.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` } export function agentTerminalWebSocketURL(agentID: string): string { diff --git a/web/frontend/src/compat/agent-protocol.ts b/web/frontend/src/compat/agent-protocol.ts deleted file mode 100644 index 56bb6a92..00000000 --- a/web/frontend/src/compat/agent-protocol.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { - AOPData, - AOPEvent as CyberAOPEvent, -} from '../../cyber-ui/packages/agent-protocol/src' - -export * from '../../cyber-ui/packages/agent-protocol/src' - -export interface AOPEvent extends CyberAOPEvent { - turn_id?: string -} - -export interface MessageImagePart { - base64?: string - media_type?: string - path?: string -} - -export interface MessagePart { - type: string - text?: string - image?: MessageImagePart -} - -export interface MessageData { - message_id: string - role?: string - parts: MessagePart[] -} - -export interface MessageDeltaData { - message_id: string - delta: string - part_type?: string -} diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx index 7bdf8e18..2819b21e 100644 --- a/web/frontend/src/components/ChatPanel.tsx +++ b/web/frontend/src/components/ChatPanel.tsx @@ -82,12 +82,9 @@ function toExtensionItem(item: TimelineItem): ExtensionTimelineItem | null { // already renders them as scan cards. Drop them from the stream handed to the // AOP reducer so they don't also appear as bare "scan complete" bubbles. function isPlatformMarkerEvent(event: AOPEvent): boolean { - if (event.type !== 'message') return false - const data = event.data as { role?: string } | undefined - if (data?.role !== 'system') return false - const ext = event.ext - if (!ext) return false - for (const value of Object.values(ext)) { + if (event.payload.case !== 'message' || event.payload.value.role !== 'system') return false + for (const extension of event.extensions) { + const value = decodeExtension(extension.value?.data) if (!value || typeof value !== 'object') continue const meta = (value as Record).metadata if (meta && typeof meta === 'object' && (meta as Record).event_type) return true @@ -99,91 +96,81 @@ function isPlatformMarkerEvent(event: AOPEvent): boolean { // execution inputs, not operator-authored chat messages. The hub is the sole // author of user messages on this surface, so keep only its canonical copy. function isInternalUserEvent(event: AOPEvent): boolean { - if (event.type !== 'message' || event.agent === webUserAgent) return false - const data = event.data as { role?: string } | undefined - return data?.role === 'user' + return event.payload.case === 'message' + && event.emitter !== webUserAgent + && event.payload.value.role === 'user' } function eventText(event: AOPEvent): string { - const data = event.data as { - content?: string - parts?: Array<{ type?: string; text?: string }> - } | undefined - if (typeof data?.content === 'string') return data.content - return (data?.parts ?? []) - .filter((part) => part.type === 'text' && part.text) - .map((part) => part.text as string) + if (event.payload.case !== 'message') return '' + return event.payload.value.content + .filter((part) => part.value.case === 'text') + .map((part) => part.value.case === 'text' ? part.value.value.text : '') .join('\n') } -function markdownCodeFence(text: string): string { - let fence = '```' - while (text.includes(fence)) fence += '`' - return `${fence}\n${text}\n${fence}` -} - function presentAOPEvent(event: AOPEvent): AOPEvent { - if (event.type !== 'message') return event - const command = event.ext?.command as { presentation?: string } | undefined - if (command?.presentation !== 'preformatted') return event - const data = event.data as { parts?: Array<{ type?: string; text?: string }> } - return { - ...event, - data: { - ...data, - parts: (data.parts ?? []).map((part) => ( - part.type === 'text' && part.text ? { ...part, text: markdownCodeFence(part.text) } : part - )), - }, - } + return event } function extensionBlock(event: AOPEvent): Record { - for (const value of Object.values(event.ext ?? {})) { + for (const extension of event.extensions) { + const value = decodeExtension(extension.value?.data) if (value && typeof value === 'object') return value as Record } return {} } +function decodeExtension(data?: Uint8Array): unknown { + if (!data?.length) return undefined + try { return JSON.parse(new TextDecoder().decode(data)) } + catch { return undefined } +} + +function eventTimestamp(event: AOPEvent): number { + if (!event.emittedAt) return 0 + return Number(event.emittedAt.seconds) * 1000 + event.emittedAt.nanos / 1_000_000 +} + function reduceConversationAOP( events: AOPEvent[], sourceEvents: AOPEvent[], streaming: boolean, ): ViewerTimelineItem[] { const childStarts = new Map() - const visibleSessionIDs = new Set(events.map((event) => event.session_id)) + const visibleSessionIDs = new Set(events.map((event) => event.sessionId)) for (const event of events) { - if (event.type !== 'session.start') continue - const data = event.data as { parent_session_id?: string; parent_tool_call_id?: string } + if (event.payload.case !== 'sessionStarted') continue + const data = event.payload.value const ext = extensionBlock(event) - const delegated = !!data.parent_tool_call_id + const delegated = !!data.parentToolCallId || (ext.delegation !== null && typeof ext.delegation === 'object') // The root agent also points at the platform chat session, which is not an // AOP stream. Only fold a run when its parent is another visible AOP session. - if (delegated && data.parent_session_id && visibleSessionIDs.has(data.parent_session_id)) { - childStarts.set(event.session_id, event) + if (delegated && data.parentSessionId && visibleSessionIDs.has(data.parentSessionId)) { + childStarts.set(event.sessionId, event) } } const childIDs = new Set(childStarts.keys()) const topLevel = reduceAOPToTimeline( - events.filter((event) => !childIDs.has(event.session_id)).map(presentAOPEvent), + events.filter((event) => !childIDs.has(event.sessionId)).map(presentAOPEvent), { streaming, lifecycle: 'errors' }, ) as ViewerTimelineItem[] const childRuns: ViewerTimelineItem[] = [] for (const [sessionID, start] of childStarts) { - const childEvents = events.filter((event) => event.session_id === sessionID) - const end = [...childEvents].reverse().find((event) => event.type === 'session.end') - const endData = end?.data as { stop?: string; error?: string } | undefined + const childEvents = events.filter((event) => event.sessionId === sessionID) + const end = [...childEvents].reverse().find((event) => event.payload.case === 'sessionEnded') + const endReason = end?.payload.case === 'sessionEnded' ? end.payload.value.reason : undefined const ext = extensionBlock(start) const delegation = ext.delegation && typeof ext.delegation === 'object' ? ext.delegation as Record : ext const promptEvent = sourceEvents.find( - (event) => event.session_id === sessionID && isInternalUserEvent(event), + (event) => event.sessionId === sessionID && isInternalUserEvent(event), ) - const stop = endData?.stop + const stop = endReason const status = !end ? 'running' : stop === 'error' @@ -191,7 +178,7 @@ function reduceConversationAOP( : stop === 'canceled' || stop === 'terminated' || stop === 'stopped' ? 'canceled' : 'completed' - const timestamp = Date.parse(start.ts) + const timestamp = eventTimestamp(start) const items = reduceAOPToTimeline(childEvents.map(presentAOPEvent), { streaming: streaming && !end, lifecycle: 'errors', @@ -201,8 +188,8 @@ function reduceConversationAOP( id: `subagent:${sessionID}`, kind: 'subagent_run', timestamp: Number.isFinite(timestamp) ? timestamp : 0, - actorName: start.agent, - name: typeof delegation.agent_name === 'string' ? delegation.agent_name : start.agent || 'Sub-agent', + actorName: start.emitter, + name: typeof delegation.agent_name === 'string' ? delegation.agent_name : start.emitter || 'Sub-agent', prompt: typeof delegation.task === 'string' ? delegation.task : (promptEvent ? eventText(promptEvent) : ''), mode: typeof delegation.run_mode === 'string' ? delegation.run_mode : undefined, sessionID, @@ -414,7 +401,7 @@ export default function ChatPanel({ setEvalMaxRounds(3) } - // The "/" command menu is served by the hub (GET .../commands): hub-scope + // The "/" command menu comes from SessionService/ListCommands: hub-scope // commands merged with the bound agent's reported commands (skills included), // so it always mirrors the real command set instead of a hardcoded list. // Descriptions prefer the local i18n string (keyed cmd) and fall back to @@ -490,7 +477,7 @@ export default function ChatPanel({ } else if (a.mode === 'upload' && activeSessionID) { try { await uploadChatFile(activeSessionID, a.file) - } catch { /* upload error shown via SSE system message */ } + } catch { /* upload error is surfaced by the Connect call */ } } } const fullContent = contextParts.length > 0 diff --git a/web/frontend/src/components/terminal/AgentTerminal.tsx b/web/frontend/src/components/terminal/AgentTerminal.tsx index 0cb730ef..e2da9c82 100644 --- a/web/frontend/src/components/terminal/AgentTerminal.tsx +++ b/web/frontend/src/components/terminal/AgentTerminal.tsx @@ -8,12 +8,14 @@ import type { AgentInfo } from '../../api' import { Button, Tooltip, TooltipTrigger, TooltipContent } from '@cyber/ui' import { type PTYSession, + type PTYFrameInit, type TerminalStatus, activitySeq, compareSessionsByActivity, encodeTerminalData, mergeSession, parsePTYFrame, + serializePTYFrame, sessionFromFrame, sessionsFromFrame, sessionTitle, @@ -96,8 +98,8 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { const fitTerminal = () => { try { fit.fit() } catch {} } - const sendTo = (message: Record) => { - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message)) + const sendTo = (message: PTYFrameInit) => { + if (ws.readyState === WebSocket.OPEN) ws.send(serializePTYFrame(message)) } const requestDesiredSession = (knownSessions: PTYSession[] = sessionsRef.current) => { if (ws.readyState !== WebSocket.OPEN) return @@ -108,24 +110,24 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { fitTerminal() term.reset() if (desired?.id) { - sendTo({ type: 'attach', session_id: desired.id, ...size() }) + sendTo({ type: 'attach', sessionId: desired.id, ...size() }) return } desiredSessionIDRef.current = '' const repl = knownSessions.find((s) => s.state === 'running' && s.kind === 'repl' && (s.name === REPL_NAME || !s.name)) || knownSessions.find((s) => s.state === 'running' && s.kind === 'repl') if (repl?.id) { - sendTo({ type: 'attach', session_id: repl.id, ...size() }) + sendTo({ type: 'attach', sessionId: repl.id, ...size() }) } } const dataDisposable = term.onData((data) => { if (!activeRef.current) return - sendTo({ type: 'input', session_id: activeRef.current, data: encodeTerminalData(data) }) + sendTo({ type: 'input', sessionId: activeRef.current, data: encodeTerminalData(data) }) }) const resizeDisposable = term.onResize(({ cols, rows }) => { if (!activeRef.current) return - sendTo({ type: 'resize', session_id: activeRef.current, cols, rows }) + sendTo({ type: 'resize', sessionId: activeRef.current, cols, rows }) }) ws.onopen = () => { @@ -145,7 +147,7 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { case 'opened': case 'attached': { const session = sessionFromFrame(msg) - const id = msg.session_id || session?.id || '' + const id = msg.sessionId || session?.id || '' if (session) rememberSession(session) if (id) { activeRef.current = id @@ -155,13 +157,13 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { } setStatus('connected') fitTerminal() - if (id) sendTo({ type: 'resize', session_id: id, ...size() }) + if (id) sendTo({ type: 'resize', sessionId: id, ...size() }) sendTo({ type: 'list' }) term.focus() break } case 'output': { - const id = msg.session_id || '' + const id = msg.sessionId || '' if (id && activeRef.current && id !== activeRef.current) { markSessionUnread(id); break } writeTerminalData(term, msg) markSessionRead(id || activeRef.current) @@ -169,7 +171,7 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { } case 'closed': { const session = sessionFromFrame(msg) - const id = msg.session_id || session?.id || '' + const id = msg.sessionId || session?.id || '' const known = sessionsRef.current.find((s) => s.id === id) || null const current = session ? { ...known, ...session } : known if (session) rememberSession(session) @@ -207,7 +209,7 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { ws.onclose = null ws.onerror = null ws.onopen = null - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'detach' })) + if (ws.readyState === WebSocket.OPEN) ws.send(serializePTYFrame({ type: 'detach' })) ws.close() resizeDisposable.dispose() dataDisposable.dispose() @@ -233,8 +235,8 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { } }, [agent.id, terminalReadySeq]) - function send(message: Record) { - if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(JSON.stringify(message)) + function send(message: PTYFrameInit) { + if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(serializePTYFrame(message)) } function terminalSize() { @@ -286,7 +288,7 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { activeRef.current = session.id setActiveID(session.id) markSessionRead(session.id, session) - send({ type: 'attach', session_id: session.id, ...terminalSize() }) + send({ type: 'attach', sessionId: session.id, ...terminalSize() }) } function attachRepl() { @@ -306,7 +308,7 @@ export default function AgentTerminal({ agent }: AgentTerminalProps) { function stopActiveSession() { if (!activeID || activeSession?.kind === 'repl') return - send({ type: 'kill', session_id: activeID }) + send({ type: 'kill', sessionId: activeID }) } const activeTitle = activeSession ? sessionTitle(activeSession) : activeID diff --git a/web/frontend/src/components/terminal/TerminalDetails.tsx b/web/frontend/src/components/terminal/TerminalDetails.tsx index a2d772eb..a58f8fad 100644 --- a/web/frontend/src/components/terminal/TerminalDetails.tsx +++ b/web/frontend/src/components/terminal/TerminalDetails.tsx @@ -59,12 +59,12 @@ export function TerminalDetails({ - - - - - - + + + + + + ) : ( diff --git a/web/frontend/src/hooks/useChatSession.ts b/web/frontend/src/hooks/useChatSession.ts index 640dbba8..f44e3e34 100644 --- a/web/frontend/src/hooks/useChatSession.ts +++ b/web/frontend/src/hooks/useChatSession.ts @@ -1,19 +1,24 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { fromJson, type JsonValue } from '@bufbuild/protobuf' +import { ScanStatus, SessionScanEventSchema } from '@cyber/aop' import { usePolling } from './usePolling' import { cancelChatSession, + closeChatSession, createChatSession, deleteChatSession, + executeChatCommand, getChatSession, listAgents, listChatMessages, listChatSessions, + resetChatSession, sendChatMessage, - subscribeDomainEvents, + subscribeAOPEvents, getScan, } from '../api' -import type { AgentInfo, AOPEvent, DomainEvent, ChatMessage, ChatSession, ScanResult } from '../api' +import type { AgentInfo, AOPEvent, ChatMessage, ChatSession, ScanResult } from '../api' import { isRootPath, parseRoute, @@ -44,6 +49,13 @@ function safeUUID(): string { return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } +function aopExtension(event: AOPEvent, namespace: string): Record | undefined { + const extension = event.extensions.find((item) => item.namespace === namespace) + if (!extension?.value?.data.length) return undefined + try { return JSON.parse(new TextDecoder().decode(extension.value.data)) as Record } + catch { return undefined } +} + export type TimelineItemKind = 'message' | 'scan_started' | 'scan_progress' | 'scan_complete' | 'thinking' export interface TimelineItem { @@ -109,8 +121,8 @@ export function useChatSession() { const [error, setError] = useState('') const unsubRef = useRef<(() => void) | null>(null) const activationRef = useRef(0) - const scanLinesRef = useRef>(new Map()) const activeSessionRef = useRef(null) + const activeTurnRef = useRef('') // Latest roster (mirrors `agents`) so click handlers can resolve an id → node // key without waiting for a re-render, and the stable key of the node the user // last chose. Selection is tracked by this key, not the transient id, so the @@ -127,7 +139,7 @@ export function useChatSession() { // session id. activateSession repaints this snapshot synchronously on // re-entry, so switching back to a session jumps straight to its conversation // instead of blanking for a round-trip. Writing on every durable change (vs. - // snapshotting on leave) keeps the cache live with streamed SSE updates + // snapshotting on leave) keeps the cache live with streamed Connect updates // without threading cache writes through every setMessages call site — and // because each render's id and messages are captured together, a switch can // never file the incoming session's state under the outgoing session's key. @@ -189,10 +201,10 @@ export function useChatSession() { // Both a cold open and a cache restore want this cleared — only their handling // of the durable state (messages/timeline/scans) differs. function resetTransientState() { + activeTurnRef.current = '' setIsThinking(false) setPendingResponse(false) setError('') - scanLinesRef.current = new Map() } function resetSessionState() { @@ -211,9 +223,8 @@ export function useChatSession() { setMessages(snap.messages) timelineRef.current = snap.timeline setTimeline(snap.timeline) - // The SSE endpoint replays the complete AOP history on every connection. - // Restoring a cached copy here would append that history again each time - // the user reopens this session. + // A new WatchEvents subscription starts at cursor zero and replays the + // complete AOP history. Avoid restoring another AOP copy from the cache. setAOPEvents([]) setScanResults(snap.scanResults) resetTransientState() @@ -235,114 +246,71 @@ export function useChatSession() { setTimelineItems((prev) => prev.map((item) => item.id === id ? updater(item) : item)) } - // A Run converges only on turn.end. Session lifecycle is independent and a - // turn-scoped error is diagnostic until its terminal turn.end arrives. + // A Run converges only on turn_ended. Session lifecycle is independent and a + // turn-scoped error is diagnostic until its terminal turn_ended arrives. function finalizeRun() { + activeTurnRef.current = '' setIsThinking(false) setPendingResponse(false) } - function handleDomainEvent(event: DomainEvent) { - const now = Date.now() - - switch (event.type) { - case 'session_cleared': - // Web /clear wiped this session's transcript server-side; mirror it in the - // UI. resetSessionState() empties messages+timeline — and since the timeline - // is a projection of messages, a page reload re-derives an empty view too. - resetSessionState() - break - - case 'scan_started': - if (event.scan_id) { - scanLinesRef.current.set(event.scan_id, []) - appendTimeline({ - id: `scan-${event.scan_id}`, - kind: 'scan_started', - timestamp: now, - scanID: event.scan_id, - scanLines: [], - content: event.data, - }) - } - break - - case 'scan_progress': - if (event.scan_id && event.data) { - const lines = scanLinesRef.current.get(event.scan_id) || [] - lines.push(event.data) - scanLinesRef.current.set(event.scan_id, lines) - updateTimelineItem(`scan-${event.scan_id}`, (item) => ({ - ...item, - scanLines: [...lines], - })) - } - break - - case 'scan_complete': - if (event.scan_id && event.result) { - setScanResults((prev) => { - const next = new Map(prev) - next.set(event.scan_id!, event.result!) - return next - }) - appendTimeline({ - id: `scanres-${event.scan_id}`, - kind: 'scan_complete', - timestamp: now, - scanID: event.scan_id, - scanResult: event.result, - }) - } - setPendingResponse(false) - break - - case 'agent_joined': - break - } - } - function handleAOPEvent(event: AOPEvent) { setAOPEvents((previous) => { - if (event.seq !== undefined && previous.some( - (item) => item.session_id === event.session_id - && item.agent === event.agent - && item.seq === event.seq - && item.type === event.type - && item.ts === event.ts, - )) return previous + if (event.id && previous.some((item) => item.id === event.id)) return previous return [...previous, event] }) - switch (event.type) { - case 'turn.start': + switch (event.payload.case) { + case 'turnStarted': + activeTurnRef.current = event.turnId setPendingResponse(true) setIsThinking(true) break - case 'message.delta': - case 'tool.call': + case 'messageDelta': + case 'toolCall': setPendingResponse(true) setIsThinking(false) break - case 'turn.end': + case 'turnEnded': finalizeRun() break - case 'session.end': + case 'sessionEnded': + break + case 'extension': { + const extension = event.payload.value + if (extension.type !== 'io.chainreactors.aiscan.scan' || !extension.value?.data.length) break + try { + const raw = JSON.parse(new TextDecoder().decode(extension.value.data)) as JsonValue + const scan = fromJson(SessionScanEventSchema, raw) + if (!scan.scanId || scan.status !== ScanStatus.COMPLETED) break + const timelineID = `scanres-${scan.scanId}` + setTimelineItems((previous) => previous.some((item) => item.id === timelineID) + ? previous + : [...previous, { id: timelineID, kind: 'scan_complete', timestamp: Date.now(), scanID: scan.scanId }]) + void getScan(scan.scanId).then((job) => { + if (!job.result) return + setScanResults((previous) => new Map(previous).set(scan.scanId, job.result!)) + updateTimelineItem(timelineID, (item) => ({ ...item, scanResult: job.result })) + }).catch(() => {}) + } catch { + // Ignore malformed product extensions; the AOP stream remains usable. + } break + } case 'error': { - const data = event.data as { code?: string; message?: string } + const data = event.payload.value // Hub-originated failures carry a translatable code plus i18n params // in the aiscan.web extension; agent errors are plain text. - const params = event.ext?.['aiscan.web']?.params as Record | undefined + const params = aopExtension(event, 'io.chainreactors.aiscan.web')?.params as Record | undefined if (data.code) setError(t(`sys.${data.code}`, { ...(params || {}), defaultValue: data.message || '' })) else setError(String(data.message ?? 'Agent error')) - if (!event.turn_id) finalizeRun() + if (!event.turnId) finalizeRun() break } } } // Rebuild the platform timeline from persisted messages. Assistant content is - // NOT rebuilt here — the SSE AOP replay is the sole source of agent history + // NOT rebuilt here — WatchEvents replay is the sole source of agent history // (it carries the complete message/tool/status stream); this only restores the // platform artifacts the AOP stream doesn't render: scan-result cards // (persisted as system markers) and the user/system conversation shell shown @@ -351,36 +319,16 @@ export function useChatSession() { const built: TimelineItem[] = [] for (const msg of msgs) { const timestamp = new Date(msg.created_at).getTime() - if (metadataString(msg.metadata, 'event_type') === 'scan_complete') { - const scanID = metadataString(msg.metadata, 'scan_id') - if (!scanID) continue - // The heavy Result isn't persisted in the marker — the card pulls it from - // the scanResults map (loaded from the session's scan_ids on activation). - // Same id as the live append so a rebuild that races the live event - // upserts instead of duplicating. - built.push({ - id: `scanres-${scanID}`, - kind: 'scan_complete', - timestamp, - scanID, - }) - continue - } if (msg.role === 'assistant') continue built.push({ id: msg.id, kind: 'message', timestamp, message: msg }) } return built } - // Chat SSE has no server-side backlog, so a terminal event lost during an - // EventSource reconnect would strand the composer as "busy" forever. On each - // SSE connection error, reconcile against persisted truth: if the run's - // aggregate assistant reply is already the tail, the turn ended during the gap - // — rebuild the timeline from messages (which clears streaming) and release the - // composer. If the tail is still the user's message (or a mid-run tool step), - // the run is in flight; leave it for the reconnected stream to finish. This is - // conservative by design — it never finalizes a turn that hasn't persisted its - // reply — and it's idempotent, so firing on every reconnect attempt is safe. + // WatchEvents reconnects from its last durable cursor. This extra reconciliation + // is a conservative UI fallback when transport failure and component state + // updates cross: a persisted assistant tail proves the run progressed far enough + // to rebuild the visible message projection while cursor replay catches up. async function reconcileAfterReconnect(id: string) { if (id !== activeSessionRef.current) return const activation = activationRef.current @@ -460,16 +408,10 @@ export function useChatSession() { } catch {} if (activation !== activationRef.current) return - unsubRef.current = subscribeDomainEvents( + unsubRef.current = subscribeAOPEvents( id, - handleDomainEvent, - () => reconcileAfterReconnect(id), handleAOPEvent, - () => { - // Reconnects also replay the complete history, so each connection is a - // replacement snapshot rather than an incremental continuation. - if (id === activeSessionRef.current) setAOPEvents([]) - }, + () => reconcileAfterReconnect(id), ) } @@ -508,28 +450,67 @@ export function useChatSession() { if (!sessionID) return const trimmed = content.trim() if (!trimmed) return + const lower = trimmed.toLowerCase() + if (lower === '/clear') { + try { + const next = await resetChatSession(sessionID) + await refreshSessions() + await activateSession(next.id, 'push') + } catch (err: any) { + setError(err.message || 'Failed to reset session') + } + return + } + if (lower === '/stop') { + await handleCancelMessage() + return + } + if (lower === '/exit' || lower === '/quit') { + try { + await closeChatSession(sessionID) + await refreshSessions() + } catch (err: any) { + setError(err.message || 'Failed to close session') + } + return + } + const continueSession = lower === '/continue' + let runContent = trimmed + if (lower.startsWith('/followup ')) runContent = trimmed.slice(trimmed.indexOf(' ') + 1).trim() + const command = !continueSession + && (runContent.startsWith('!') || (runContent.startsWith('/') && !runContent.startsWith('/skill:') && !lower.startsWith('/followup '))) + if (command) { + const msgID = safeUUID() + const optimistic: ChatMessage = { id: msgID, session_id: sessionID, role: 'user', content: runContent, created_at: new Date().toISOString() } + setMessages((prev) => [...prev, optimistic]) + appendTimeline({ id: msgID, kind: 'message', timestamp: Date.now(), message: optimistic }) + try { + await executeChatCommand(sessionID, runContent) + } catch (err: any) { + setError(err.message || 'Failed to execute command') + } + return + } const msgID = safeUUID() - const optimistic: ChatMessage = { + const optimistic: ChatMessage = { id: msgID, session_id: sessionID, role: 'user', - content: trimmed, + content: runContent, created_at: new Date().toISOString(), } - setMessages((prev) => [...prev, optimistic]) - appendTimeline({ - id: msgID, - kind: 'message', - timestamp: Date.now(), - message: optimistic, - }) + if (!continueSession) { + setMessages((prev) => [...prev, optimistic]) + appendTimeline({ id: msgID, kind: 'message', timestamp: Date.now(), message: optimistic }) + } setError('') setPendingResponse(true) try { - await sendChatMessage(sessionID, trimmed, opts) + const sent = await sendChatMessage(sessionID, runContent, { ...opts, messageID: msgID, continueSession }) + activeTurnRef.current = sent.turn_id || '' await refreshSessions() } catch (err: any) { setPendingResponse(false) @@ -661,9 +642,9 @@ export function useChatSession() { async function handleCancelMessage() { const sessionID = activeSessionRef.current if (!sessionID) return - finalizeRun() try { - await cancelChatSession(sessionID) + await cancelChatSession(sessionID, activeTurnRef.current) + finalizeRun() await refreshSessions() } catch (err: any) { setError(err.message || 'Failed to pause response') @@ -724,8 +705,3 @@ export function useChatSession() { clearError, } } - -function metadataString(metadata: Record | undefined, key: string): string { - const value = metadata?.[key] - return typeof value === 'string' ? value : '' -} diff --git a/web/frontend/src/viewer/index.ts b/web/frontend/src/viewer/index.ts index 08722f52..84ac5e7c 100644 --- a/web/frontend/src/viewer/index.ts +++ b/web/frontend/src/viewer/index.ts @@ -45,7 +45,7 @@ export type { TimelineItem as CyberTimelineItem, ExtensionTimelineItem, } from '../../cyber-ui/packages/viewer/src/types/timeline' -export type { AOPEvent } from '@cyber/agent-protocol' +export type { Event as AOPEvent } from '@cyber/aop' export type { MessageBubbleProps, MessageBubbleVariant } from '../../cyber-ui/packages/viewer/src/components/chat/MessageBubble' export type { ChatThinkingProps } from '../../cyber-ui/packages/viewer/src/components/chat/ChatThinking' diff --git a/web/frontend/tsconfig.json b/web/frontend/tsconfig.json index 46a9df64..a07c9661 100644 --- a/web/frontend/tsconfig.json +++ b/web/frontend/tsconfig.json @@ -23,9 +23,9 @@ "@cyber/theme": ["./cyber-ui/packages/theme/src"], "@cyber/markdown": ["./cyber-ui/packages/markdown/src"], "@cyber/terminal": ["./cyber-ui/packages/terminal/src"], + "@cyber/aop": ["./cyber-ui/packages/aop/src"], "@cyber/cstx": ["./cyber-ui/packages/cstx/src"], "@cyber/cstx-easm": ["./cyber-ui/packages/cstx-easm/src"], - "@cyber/agent-protocol": ["./src/compat/agent-protocol.ts"], "@cyber/viewer": ["./cyber-ui/packages/viewer/src"], "@cyber/ioa": ["./src/compat/ioa.tsx"] } @@ -36,9 +36,9 @@ "cyber-ui/packages/theme/src", "cyber-ui/packages/markdown/src", "cyber-ui/packages/terminal/src", + "cyber-ui/packages/aop/src", "cyber-ui/packages/cstx/src", "cyber-ui/packages/cstx-easm/src", - "cyber-ui/packages/agent-protocol/src", "cyber-ui/packages/viewer/src", "cyber-ui/packages/ioa/src" ], diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts index 0c0592c9..b5638b1e 100644 --- a/web/frontend/vite.config.ts +++ b/web/frontend/vite.config.ts @@ -19,9 +19,9 @@ export default defineConfig({ '@cyber/theme': path.resolve(cyberUI, 'theme/src'), '@cyber/markdown': path.resolve(cyberUI, 'markdown/src'), '@cyber/terminal': path.resolve(cyberUI, 'terminal/src'), + '@cyber/aop': path.resolve(cyberUI, 'aop/src'), '@cyber/cstx': path.resolve(cyberUI, 'cstx/src'), '@cyber/cstx-easm': path.resolve(cyberUI, 'cstx-easm/src'), - '@cyber/agent-protocol': path.resolve(__dirname, './src/compat/agent-protocol.ts'), '@cyber/viewer': path.resolve(cyberUI, 'viewer/src'), '@cyber/ioa': path.resolve(__dirname, './src/compat/ioa.tsx'), }, diff --git a/web/static/.gitkeep b/web/static/.gitkeep deleted file mode 100644 index 3e262833..00000000 --- a/web/static/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Generated frontend assets replace this placeholder during release builds. From 7201b2ab7d234cf9247789afa08bba2a07421dec Mon Sep 17 00:00:00 2001 From: wuchulonly Date: Sat, 1 Aug 2026 14:08:13 -0700 Subject: [PATCH 156/348] fix(neutron): emit the captured request/response in JSON results The engine already records the exchange on the operator result (protocols/http/request.go sets Request/Response), and the SDK's own TemplateResult copies both out. neutronResult dropped them, so every consumer of the JSON output saw a bare matched=true/false with no reviewable evidence of what was actually sent. Cairn's reproduce flow is the visible casualty: it parses request and response from this output and only stores an evidence exchange when one of them is present, so reproduction traffic was always empty in the UI. Co-Authored-By: Claude Fable 5 --- tools/neutron/neutron.go | 7 +++++++ tools/neutron/neutron_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/tools/neutron/neutron.go b/tools/neutron/neutron.go index c0078884..54254bb5 100644 --- a/tools/neutron/neutron.go +++ b/tools/neutron/neutron.go @@ -70,6 +70,8 @@ type neutronResult struct { Tags []string `json:"tags,omitempty"` Fingers []string `json:"fingers,omitempty"` Extracts []string `json:"extracts,omitempty"` + Request string `json:"request,omitempty"` + Response string `json:"response,omitempty"` Error string `json:"error,omitempty"` } @@ -444,6 +446,11 @@ func neutronResultFromExecution(target string, result *sdkneutron.ExecuteResult) } if opResult := result.Value(); opResult != nil { record.Extracts = append([]string(nil), opResult.OutputExtracts()...) + // The engine captures the exchange (protocols/http/request.go sets these + // on the operator result); dropping it here left every consumer unable to + // show what was actually sent, so a match had no reviewable evidence. + record.Request = opResult.Request + record.Response = opResult.Response } if err := result.Error(); err != nil { record.Error = err.Error() diff --git a/tools/neutron/neutron_test.go b/tools/neutron/neutron_test.go index dd4e7ddd..4dedf5ef 100644 --- a/tools/neutron/neutron_test.go +++ b/tools/neutron/neutron_test.go @@ -15,6 +15,7 @@ import ( "github.com/chainreactors/neutron/templates" sdkneutron "github.com/chainreactors/sdk/neutron" "github.com/chainreactors/sdk/pkg/association" + sdktypes "github.com/chainreactors/sdk/pkg/types" ) func TestNormalizeNucleiStyleArgs(t *testing.T) { @@ -167,3 +168,30 @@ func templateIDs(items []*templates.Template) []string { } return out } + +func TestNeutronResultFromExecutionCarriesExchange(t *testing.T) { + op := &operators.Result{ + Request: "GET /am/version HTTP/1.1\r\nHost: example.test\r\n\r\n", + Response: "HTTP/1.1 200 OK\r\n\r\nidentifier=3.3M2.0", + } + op.Matched = true + record := neutronResultFromExecution( + "https://example.test", + &sdkneutron.ExecuteResult{TypedResult: sdktypes.NewResult(true, nil, op)}, + ) + if record.Request != op.Request { + t.Fatalf("request not carried through: got %q, want %q", record.Request, op.Request) + } + if record.Response != op.Response { + t.Fatalf("response not carried through: got %q, want %q", record.Response, op.Response) + } + + // Consumers read the JSON, not the struct: a missing tag is the same outage. + var decoded map[string]any + if err := json.Unmarshal([]byte(formatNeutronResult(record, true)), &decoded); err != nil { + t.Fatalf("decode neutron JSON: %v", err) + } + if decoded["request"] != op.Request || decoded["response"] != op.Response { + t.Fatalf("exchange missing from JSON output: %#v", decoded) + } +} From 326e020fc38465a3900b2706a27c21c1867bfe25 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sun, 2 Aug 2026 12:01:30 +0800 Subject: [PATCH 157/348] feat(protocol): adopt namespaced AOP and split product RPC types --- aop/aiscan/chat/session.pb.go | 1841 ------------ aop/aiscan/chat/session_grpc.pb.go | 331 --- aop/aiscan/client.go | 45 - aop/aiscan/client_test.go | 15 - aop/aiscan/scan/scan_grpc.pb.go | 322 --- aop/aiscan/transport/agent.pb.go | 1330 --------- aop/aiscan/transport/agent_grpc.pb.go | 141 - aop/aiscan/transport/extensions.pb.go | 790 ----- aop/aiscan/transport/operation.pb.go | 1556 ---------- aop/aiscan/transport/telemetry.pb.go | 861 ------ aop/aiscan/transport/terminal.pb.go | 506 ---- aop/aopconnect/chat.connect.go | 249 -- aop/chat.pb.go | 520 ++-- aop/chat_grpc.pb.go | 322 --- aop/content.pb.go | 339 +-- aop/envelope.pb.go | 181 ++ aop/event.pb.go | 538 ++-- aop/exec/protocol.pb.go | 519 ++++ aop/file/protocol.pb.go | 814 ++++++ aop/helpers.go | 82 +- aop/interop_fixture_test.go | 39 +- aop/mux.go | 81 + aop/mux_test.go | 36 + aop/protocol.pb.go | 1172 ++++++++ aop/pty/protocol.pb.go | 1869 ++++++++++++ aop/sco/protocol.pb.go | 244 ++ aop/stream.go | 11 + aop/tool/protocol.pb.go | 374 +++ aop/value.pb.go | 98 +- aop/wire.go | 38 + cmd/gen/main.go | 216 ++ core/deps/architecture_test.go | 48 +- docs/protocol-architecture.md | 162 ++ go.mod | 13 +- go.sum | 24 +- pkg/rpc/agent/agent.pb.go | 109 + pkg/rpc/agent/agentconnect/agent.connect.go | 196 ++ pkg/rpc/chat/chat.pb.go | 126 + .../rpc/chat/chatconnect/chat.connect.go | 129 +- pkg/rpc/config/config.pb.go | 128 + .../config/configconnect/config.connect.go | 254 ++ pkg/rpc/scan/scan.pb.go | 114 + .../rpc}/scan/scanconnect/scan.connect.go | 94 +- pkg/rpc/sco/sco.pb.go | 122 + pkg/rpc/sco/scoconnect/sco.connect.go | 250 ++ pkg/rpc/system/system.pb.go | 79 + .../system/systemconnect/system.connect.go | 109 + pkg/types/agent/agent.pb.go | 1566 ++++++++++ pkg/types/chat/chat.pb.go | 1103 +++++++ pkg/types/command/command.pb.go | 612 ++++ pkg/types/config/config.pb.go | 2566 +++++++++++++++++ .../types}/extensions/extensions.go | 49 +- pkg/types/reload/reload.pb.go | 351 +++ {aop/aiscan => pkg/types}/scan/scan.pb.go | 805 +++--- pkg/types/sco/sco.pb.go | 888 ++++++ pkg/types/system/system.pb.go | 345 +++ proto/aiscan/chat/session.proto | 136 - proto/aiscan/rpc/agent.proto | 14 + proto/aiscan/rpc/chat.proto | 17 + proto/aiscan/rpc/config.proto | 17 + proto/aiscan/rpc/scan.proto | 15 + proto/aiscan/rpc/sco.proto | 16 + proto/aiscan/rpc/system.proto | 11 + proto/aiscan/transport/agent.proto | 90 - proto/aiscan/transport/extensions.proto | 59 - proto/aiscan/transport/operation.proto | 115 - proto/aiscan/transport/telemetry.proto | 69 - proto/aiscan/transport/terminal.proto | 44 - proto/aiscan/types/agent.proto | 93 + proto/aiscan/types/chat.proto | 77 + proto/aiscan/types/command.proto | 39 + proto/aiscan/types/config.proto | 170 ++ proto/aiscan/types/reload.proto | 23 + proto/aiscan/{scan => types}/scan.proto | 32 +- proto/aiscan/types/sco.proto | 33 + proto/aiscan/types/system.proto | 21 + proto/generate.go | 8 - proto/internal/generate_ts/main.go | 56 - web/frontend/cyber-ui | 2 +- 79 files changed, 16372 insertions(+), 10507 deletions(-) delete mode 100644 aop/aiscan/chat/session.pb.go delete mode 100644 aop/aiscan/chat/session_grpc.pb.go delete mode 100644 aop/aiscan/client.go delete mode 100644 aop/aiscan/client_test.go delete mode 100644 aop/aiscan/scan/scan_grpc.pb.go delete mode 100644 aop/aiscan/transport/agent.pb.go delete mode 100644 aop/aiscan/transport/agent_grpc.pb.go delete mode 100644 aop/aiscan/transport/extensions.pb.go delete mode 100644 aop/aiscan/transport/operation.pb.go delete mode 100644 aop/aiscan/transport/telemetry.pb.go delete mode 100644 aop/aiscan/transport/terminal.pb.go delete mode 100644 aop/aopconnect/chat.connect.go delete mode 100644 aop/chat_grpc.pb.go create mode 100644 aop/envelope.pb.go create mode 100644 aop/exec/protocol.pb.go create mode 100644 aop/file/protocol.pb.go create mode 100644 aop/mux.go create mode 100644 aop/mux_test.go create mode 100644 aop/protocol.pb.go create mode 100644 aop/pty/protocol.pb.go create mode 100644 aop/sco/protocol.pb.go create mode 100644 aop/stream.go create mode 100644 aop/tool/protocol.pb.go create mode 100644 aop/wire.go create mode 100644 cmd/gen/main.go create mode 100644 docs/protocol-architecture.md create mode 100644 pkg/rpc/agent/agent.pb.go create mode 100644 pkg/rpc/agent/agentconnect/agent.connect.go create mode 100644 pkg/rpc/chat/chat.pb.go rename aop/aiscan/chat/chatconnect/session.connect.go => pkg/rpc/chat/chatconnect/chat.connect.go (63%) create mode 100644 pkg/rpc/config/config.pb.go create mode 100644 pkg/rpc/config/configconnect/config.connect.go create mode 100644 pkg/rpc/scan/scan.pb.go rename {aop/aiscan => pkg/rpc}/scan/scanconnect/scan.connect.go (69%) create mode 100644 pkg/rpc/sco/sco.pb.go create mode 100644 pkg/rpc/sco/scoconnect/sco.connect.go create mode 100644 pkg/rpc/system/system.pb.go create mode 100644 pkg/rpc/system/systemconnect/system.connect.go create mode 100644 pkg/types/agent/agent.pb.go create mode 100644 pkg/types/chat/chat.pb.go create mode 100644 pkg/types/command/command.pb.go create mode 100644 pkg/types/config/config.pb.go rename {aop/aiscan => pkg/types}/extensions/extensions.go (56%) create mode 100644 pkg/types/reload/reload.pb.go rename {aop/aiscan => pkg/types}/scan/scan.pb.go (59%) create mode 100644 pkg/types/sco/sco.pb.go create mode 100644 pkg/types/system/system.pb.go delete mode 100644 proto/aiscan/chat/session.proto create mode 100644 proto/aiscan/rpc/agent.proto create mode 100644 proto/aiscan/rpc/chat.proto create mode 100644 proto/aiscan/rpc/config.proto create mode 100644 proto/aiscan/rpc/scan.proto create mode 100644 proto/aiscan/rpc/sco.proto create mode 100644 proto/aiscan/rpc/system.proto delete mode 100644 proto/aiscan/transport/agent.proto delete mode 100644 proto/aiscan/transport/extensions.proto delete mode 100644 proto/aiscan/transport/operation.proto delete mode 100644 proto/aiscan/transport/telemetry.proto delete mode 100644 proto/aiscan/transport/terminal.proto create mode 100644 proto/aiscan/types/agent.proto create mode 100644 proto/aiscan/types/chat.proto create mode 100644 proto/aiscan/types/command.proto create mode 100644 proto/aiscan/types/config.proto create mode 100644 proto/aiscan/types/reload.proto rename proto/aiscan/{scan => types}/scan.proto (76%) create mode 100644 proto/aiscan/types/sco.proto create mode 100644 proto/aiscan/types/system.proto delete mode 100644 proto/generate.go delete mode 100644 proto/internal/generate_ts/main.go diff --git a/aop/aiscan/chat/session.pb.go b/aop/aiscan/chat/session.pb.go deleted file mode 100644 index 4853d0a9..00000000 --- a/aop/aiscan/chat/session.pb.go +++ /dev/null @@ -1,1841 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.34.1 -// protoc v6.33.0 -// source: aiscan/chat/session.proto - -package chat - -import ( - aop "github.com/chainreactors/aiscan/aop" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type SessionRecord struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Session *aop.Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` - AgentName string `protobuf:"bytes,2,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - ScanIds []string `protobuf:"bytes,3,rep,name=scan_ids,json=scanIds,proto3" json:"scan_ids,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` -} - -func (x *SessionRecord) Reset() { - *x = SessionRecord{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *SessionRecord) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SessionRecord) ProtoMessage() {} - -func (x *SessionRecord) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SessionRecord.ProtoReflect.Descriptor instead. -func (*SessionRecord) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{0} -} - -func (x *SessionRecord) GetSession() *aop.Session { - if x != nil { - return x.Session - } - return nil -} - -func (x *SessionRecord) GetAgentName() string { - if x != nil { - return x.AgentName - } - return "" -} - -func (x *SessionRecord) GetScanIds() []string { - if x != nil { - return x.ScanIds - } - return nil -} - -func (x *SessionRecord) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *SessionRecord) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -type ListSessionsRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - AfterCursor string `protobuf:"bytes,1,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` - Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` - IncludeClosed bool `protobuf:"varint,3,opt,name=include_closed,json=includeClosed,proto3" json:"include_closed,omitempty"` -} - -func (x *ListSessionsRequest) Reset() { - *x = ListSessionsRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ListSessionsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListSessionsRequest) ProtoMessage() {} - -func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. -func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{1} -} - -func (x *ListSessionsRequest) GetAfterCursor() string { - if x != nil { - return x.AfterCursor - } - return "" -} - -func (x *ListSessionsRequest) GetLimit() uint32 { - if x != nil { - return x.Limit - } - return 0 -} - -func (x *ListSessionsRequest) GetIncludeClosed() bool { - if x != nil { - return x.IncludeClosed - } - return false -} - -type ListSessionsResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Sessions []*SessionRecord `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` - NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` -} - -func (x *ListSessionsResponse) Reset() { - *x = ListSessionsResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ListSessionsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListSessionsResponse) ProtoMessage() {} - -func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. -func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{2} -} - -func (x *ListSessionsResponse) GetSessions() []*SessionRecord { - if x != nil { - return x.Sessions - } - return nil -} - -func (x *ListSessionsResponse) GetNextCursor() string { - if x != nil { - return x.NextCursor - } - return "" -} - -type GetSessionRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` -} - -func (x *GetSessionRequest) Reset() { - *x = GetSessionRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetSessionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetSessionRequest) ProtoMessage() {} - -func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. -func (*GetSessionRequest) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{3} -} - -func (x *GetSessionRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -type GetSessionResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Session *SessionRecord `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` -} - -func (x *GetSessionResponse) Reset() { - *x = GetSessionResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetSessionResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetSessionResponse) ProtoMessage() {} - -func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. -func (*GetSessionResponse) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{4} -} - -func (x *GetSessionResponse) GetSession() *SessionRecord { - if x != nil { - return x.Session - } - return nil -} - -type ResetSessionRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - NewSessionId string `protobuf:"bytes,3,opt,name=new_session_id,json=newSessionId,proto3" json:"new_session_id,omitempty"` - Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` -} - -func (x *ResetSessionRequest) Reset() { - *x = ResetSessionRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ResetSessionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ResetSessionRequest) ProtoMessage() {} - -func (x *ResetSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ResetSessionRequest.ProtoReflect.Descriptor instead. -func (*ResetSessionRequest) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{5} -} - -func (x *ResetSessionRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *ResetSessionRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *ResetSessionRequest) GetNewSessionId() string { - if x != nil { - return x.NewSessionId - } - return "" -} - -func (x *ResetSessionRequest) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -type ResetSessionReceipt struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Previous *aop.Session `protobuf:"bytes,1,opt,name=previous,proto3" json:"previous,omitempty"` - Current *SessionRecord `protobuf:"bytes,2,opt,name=current,proto3" json:"current,omitempty"` -} - -func (x *ResetSessionReceipt) Reset() { - *x = ResetSessionReceipt{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ResetSessionReceipt) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ResetSessionReceipt) ProtoMessage() {} - -func (x *ResetSessionReceipt) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ResetSessionReceipt.ProtoReflect.Descriptor instead. -func (*ResetSessionReceipt) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{6} -} - -func (x *ResetSessionReceipt) GetPrevious() *aop.Session { - if x != nil { - return x.Previous - } - return nil -} - -func (x *ResetSessionReceipt) GetCurrent() *SessionRecord { - if x != nil { - return x.Current - } - return nil -} - -type ResetSessionResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // Types that are assignable to Outcome: - // - // *ResetSessionResponse_Accepted - // *ResetSessionResponse_Rejected - Outcome isResetSessionResponse_Outcome `protobuf_oneof:"outcome"` -} - -func (x *ResetSessionResponse) Reset() { - *x = ResetSessionResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ResetSessionResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ResetSessionResponse) ProtoMessage() {} - -func (x *ResetSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ResetSessionResponse.ProtoReflect.Descriptor instead. -func (*ResetSessionResponse) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{7} -} - -func (x *ResetSessionResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (m *ResetSessionResponse) GetOutcome() isResetSessionResponse_Outcome { - if m != nil { - return m.Outcome - } - return nil -} - -func (x *ResetSessionResponse) GetAccepted() *ResetSessionReceipt { - if x, ok := x.GetOutcome().(*ResetSessionResponse_Accepted); ok { - return x.Accepted - } - return nil -} - -func (x *ResetSessionResponse) GetRejected() *aop.Rejection { - if x, ok := x.GetOutcome().(*ResetSessionResponse_Rejected); ok { - return x.Rejected - } - return nil -} - -type isResetSessionResponse_Outcome interface { - isResetSessionResponse_Outcome() -} - -type ResetSessionResponse_Accepted struct { - Accepted *ResetSessionReceipt `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` -} - -type ResetSessionResponse_Rejected struct { - Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` -} - -func (*ResetSessionResponse_Accepted) isResetSessionResponse_Outcome() {} - -func (*ResetSessionResponse_Rejected) isResetSessionResponse_Outcome() {} - -type DeleteSessionRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` -} - -func (x *DeleteSessionRequest) Reset() { - *x = DeleteSessionRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *DeleteSessionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteSessionRequest) ProtoMessage() {} - -func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteSessionRequest.ProtoReflect.Descriptor instead. -func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{8} -} - -func (x *DeleteSessionRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *DeleteSessionRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -type DeleteSessionResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // Types that are assignable to Outcome: - // - // *DeleteSessionResponse_Accepted - // *DeleteSessionResponse_Rejected - Outcome isDeleteSessionResponse_Outcome `protobuf_oneof:"outcome"` -} - -func (x *DeleteSessionResponse) Reset() { - *x = DeleteSessionResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *DeleteSessionResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteSessionResponse) ProtoMessage() {} - -func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteSessionResponse.ProtoReflect.Descriptor instead. -func (*DeleteSessionResponse) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{9} -} - -func (x *DeleteSessionResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (m *DeleteSessionResponse) GetOutcome() isDeleteSessionResponse_Outcome { - if m != nil { - return m.Outcome - } - return nil -} - -func (x *DeleteSessionResponse) GetAccepted() *aop.Session { - if x, ok := x.GetOutcome().(*DeleteSessionResponse_Accepted); ok { - return x.Accepted - } - return nil -} - -func (x *DeleteSessionResponse) GetRejected() *aop.Rejection { - if x, ok := x.GetOutcome().(*DeleteSessionResponse_Rejected); ok { - return x.Rejected - } - return nil -} - -type isDeleteSessionResponse_Outcome interface { - isDeleteSessionResponse_Outcome() -} - -type DeleteSessionResponse_Accepted struct { - Accepted *aop.Session `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` -} - -type DeleteSessionResponse_Rejected struct { - Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` -} - -func (*DeleteSessionResponse_Accepted) isDeleteSessionResponse_Outcome() {} - -func (*DeleteSessionResponse_Rejected) isDeleteSessionResponse_Outcome() {} - -type CommandSpec struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Aliases []string `protobuf:"bytes,2,rep,name=aliases,proto3" json:"aliases,omitempty"` - Usage string `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` - Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` -} - -func (x *CommandSpec) Reset() { - *x = CommandSpec{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CommandSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CommandSpec) ProtoMessage() {} - -func (x *CommandSpec) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CommandSpec.ProtoReflect.Descriptor instead. -func (*CommandSpec) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{10} -} - -func (x *CommandSpec) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *CommandSpec) GetAliases() []string { - if x != nil { - return x.Aliases - } - return nil -} - -func (x *CommandSpec) GetUsage() string { - if x != nil { - return x.Usage - } - return "" -} - -func (x *CommandSpec) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -type ListCommandsRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` -} - -func (x *ListCommandsRequest) Reset() { - *x = ListCommandsRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ListCommandsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListCommandsRequest) ProtoMessage() {} - -func (x *ListCommandsRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListCommandsRequest.ProtoReflect.Descriptor instead. -func (*ListCommandsRequest) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{11} -} - -func (x *ListCommandsRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -type ListCommandsResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Commands []*CommandSpec `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` -} - -func (x *ListCommandsResponse) Reset() { - *x = ListCommandsResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ListCommandsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListCommandsResponse) ProtoMessage() {} - -func (x *ListCommandsResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListCommandsResponse.ProtoReflect.Descriptor instead. -func (*ListCommandsResponse) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{12} -} - -func (x *ListCommandsResponse) GetCommands() []*CommandSpec { - if x != nil { - return x.Commands - } - return nil -} - -type ExecuteCommandRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - Line string `protobuf:"bytes,3,opt,name=line,proto3" json:"line,omitempty"` -} - -func (x *ExecuteCommandRequest) Reset() { - *x = ExecuteCommandRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ExecuteCommandRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecuteCommandRequest) ProtoMessage() {} - -func (x *ExecuteCommandRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecuteCommandRequest.ProtoReflect.Descriptor instead. -func (*ExecuteCommandRequest) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{13} -} - -func (x *ExecuteCommandRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *ExecuteCommandRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *ExecuteCommandRequest) GetLine() string { - if x != nil { - return x.Line - } - return "" -} - -type CommandReceipt struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` -} - -func (x *CommandReceipt) Reset() { - *x = CommandReceipt{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CommandReceipt) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CommandReceipt) ProtoMessage() {} - -func (x *CommandReceipt) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CommandReceipt.ProtoReflect.Descriptor instead. -func (*CommandReceipt) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{14} -} - -func (x *CommandReceipt) GetOperationId() string { - if x != nil { - return x.OperationId - } - return "" -} - -func (x *CommandReceipt) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *CommandReceipt) GetState() string { - if x != nil { - return x.State - } - return "" -} - -type ExecuteCommandResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // Types that are assignable to Outcome: - // - // *ExecuteCommandResponse_Accepted - // *ExecuteCommandResponse_Rejected - Outcome isExecuteCommandResponse_Outcome `protobuf_oneof:"outcome"` -} - -func (x *ExecuteCommandResponse) Reset() { - *x = ExecuteCommandResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ExecuteCommandResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecuteCommandResponse) ProtoMessage() {} - -func (x *ExecuteCommandResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[15] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecuteCommandResponse.ProtoReflect.Descriptor instead. -func (*ExecuteCommandResponse) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{15} -} - -func (x *ExecuteCommandResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (m *ExecuteCommandResponse) GetOutcome() isExecuteCommandResponse_Outcome { - if m != nil { - return m.Outcome - } - return nil -} - -func (x *ExecuteCommandResponse) GetAccepted() *CommandReceipt { - if x, ok := x.GetOutcome().(*ExecuteCommandResponse_Accepted); ok { - return x.Accepted - } - return nil -} - -func (x *ExecuteCommandResponse) GetRejected() *aop.Rejection { - if x, ok := x.GetOutcome().(*ExecuteCommandResponse_Rejected); ok { - return x.Rejected - } - return nil -} - -type isExecuteCommandResponse_Outcome interface { - isExecuteCommandResponse_Outcome() -} - -type ExecuteCommandResponse_Accepted struct { - Accepted *CommandReceipt `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` -} - -type ExecuteCommandResponse_Rejected struct { - Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` -} - -func (*ExecuteCommandResponse_Accepted) isExecuteCommandResponse_Outcome() {} - -func (*ExecuteCommandResponse_Rejected) isExecuteCommandResponse_Outcome() {} - -type UploadSessionFileRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - Filename string `protobuf:"bytes,3,opt,name=filename,proto3" json:"filename,omitempty"` - MediaType string `protobuf:"bytes,4,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` - Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` -} - -func (x *UploadSessionFileRequest) Reset() { - *x = UploadSessionFileRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *UploadSessionFileRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UploadSessionFileRequest) ProtoMessage() {} - -func (x *UploadSessionFileRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[16] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UploadSessionFileRequest.ProtoReflect.Descriptor instead. -func (*UploadSessionFileRequest) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{16} -} - -func (x *UploadSessionFileRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *UploadSessionFileRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *UploadSessionFileRequest) GetFilename() string { - if x != nil { - return x.Filename - } - return "" -} - -func (x *UploadSessionFileRequest) GetMediaType() string { - if x != nil { - return x.MediaType - } - return "" -} - -func (x *UploadSessionFileRequest) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -type UploadedFile struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` - Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` - Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` - MediaType string `protobuf:"bytes,4,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` -} - -func (x *UploadedFile) Reset() { - *x = UploadedFile{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *UploadedFile) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UploadedFile) ProtoMessage() {} - -func (x *UploadedFile) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[17] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UploadedFile.ProtoReflect.Descriptor instead. -func (*UploadedFile) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{17} -} - -func (x *UploadedFile) GetFilename() string { - if x != nil { - return x.Filename - } - return "" -} - -func (x *UploadedFile) GetPath() string { - if x != nil { - return x.Path - } - return "" -} - -func (x *UploadedFile) GetSize() int64 { - if x != nil { - return x.Size - } - return 0 -} - -func (x *UploadedFile) GetMediaType() string { - if x != nil { - return x.MediaType - } - return "" -} - -type UploadSessionFileResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - // Types that are assignable to Outcome: - // - // *UploadSessionFileResponse_Accepted - // *UploadSessionFileResponse_Rejected - Outcome isUploadSessionFileResponse_Outcome `protobuf_oneof:"outcome"` -} - -func (x *UploadSessionFileResponse) Reset() { - *x = UploadSessionFileResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_chat_session_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *UploadSessionFileResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UploadSessionFileResponse) ProtoMessage() {} - -func (x *UploadSessionFileResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_chat_session_proto_msgTypes[18] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UploadSessionFileResponse.ProtoReflect.Descriptor instead. -func (*UploadSessionFileResponse) Descriptor() ([]byte, []int) { - return file_aiscan_chat_session_proto_rawDescGZIP(), []int{18} -} - -func (x *UploadSessionFileResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (m *UploadSessionFileResponse) GetOutcome() isUploadSessionFileResponse_Outcome { - if m != nil { - return m.Outcome - } - return nil -} - -func (x *UploadSessionFileResponse) GetAccepted() *UploadedFile { - if x, ok := x.GetOutcome().(*UploadSessionFileResponse_Accepted); ok { - return x.Accepted - } - return nil -} - -func (x *UploadSessionFileResponse) GetRejected() *aop.Rejection { - if x, ok := x.GetOutcome().(*UploadSessionFileResponse_Rejected); ok { - return x.Rejected - } - return nil -} - -type isUploadSessionFileResponse_Outcome interface { - isUploadSessionFileResponse_Outcome() -} - -type UploadSessionFileResponse_Accepted struct { - Accepted *UploadedFile `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` -} - -type UploadSessionFileResponse_Rejected struct { - Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` -} - -func (*UploadSessionFileResponse_Accepted) isUploadSessionFileResponse_Outcome() {} - -func (*UploadSessionFileResponse_Rejected) isUploadSessionFileResponse_Outcome() {} - -var File_aiscan_chat_session_proto protoreflect.FileDescriptor - -var file_aiscan_chat_session_proto_rawDesc = []byte{ - 0x0a, 0x19, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2f, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, - 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe7, 0x01, 0x0a, 0x0d, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x26, 0x0a, 0x07, 0x73, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, - 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x39, 0x0a, - 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x64, 0x41, 0x74, 0x22, 0x75, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, - 0x74, 0x65, 0x72, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0b, 0x61, 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x14, 0x0a, - 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x6c, 0x69, - 0x6d, 0x69, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x63, - 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x69, 0x6e, 0x63, - 0x6c, 0x75, 0x64, 0x65, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x22, 0x6f, 0x0a, 0x14, 0x4c, 0x69, - 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x36, 0x0a, 0x08, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, - 0x61, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x52, 0x08, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, - 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0x32, 0x0a, 0x11, 0x47, - 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, - 0x4a, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x63, 0x68, 0x61, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8f, 0x01, 0x0a, 0x13, - 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, - 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x6e, 0x65, 0x77, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6e, 0x65, 0x77, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x22, 0x75, 0x0a, - 0x13, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, - 0x65, 0x69, 0x70, 0x74, 0x12, 0x28, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x12, 0x34, - 0x0a, 0x07, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x63, 0x75, 0x72, - 0x72, 0x65, 0x6e, 0x74, 0x22, 0xae, 0x01, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, - 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x3e, 0x0a, 0x08, - 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, - 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, - 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, - 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, - 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, - 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, - 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, - 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x54, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, - 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x9b, 0x01, 0x0a, 0x15, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, - 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, - 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x73, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, - 0x61, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x61, - 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, 0x20, 0x0a, 0x0b, - 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x34, - 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x4c, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x08, - 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, - 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x73, 0x22, 0x69, 0x0a, 0x15, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x22, 0x68, 0x0a, - 0x0e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x12, - 0x21, 0x0a, 0x0c, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, - 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0xab, 0x01, 0x0a, 0x16, 0x45, 0x78, 0x65, 0x63, - 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, - 0x64, 0x12, 0x39, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, - 0x74, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, - 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, - 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, - 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, - 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, - 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0xa7, 0x01, 0x0a, 0x18, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, - 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, - 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, - 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, - 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, - 0x71, 0x0a, 0x0c, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x12, - 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, - 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, - 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, - 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, - 0x70, 0x65, 0x22, 0xac, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, - 0x37, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, - 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x08, - 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, - 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, - 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, - 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, - 0x65, 0x32, 0xf5, 0x04, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, - 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x63, 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0a, 0x47, 0x65, 0x74, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, - 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, - 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, - 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x21, - 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, - 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x0e, 0x45, 0x78, - 0x65, 0x63, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x22, 0x2e, 0x61, - 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, - 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x23, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x45, - 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x11, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x25, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, - 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x46, 0x69, 0x6c, - 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, - 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, - 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x3b, 0x63, 0x68, 0x61, - 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_aiscan_chat_session_proto_rawDescOnce sync.Once - file_aiscan_chat_session_proto_rawDescData = file_aiscan_chat_session_proto_rawDesc -) - -func file_aiscan_chat_session_proto_rawDescGZIP() []byte { - file_aiscan_chat_session_proto_rawDescOnce.Do(func() { - file_aiscan_chat_session_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_chat_session_proto_rawDescData) - }) - return file_aiscan_chat_session_proto_rawDescData -} - -var file_aiscan_chat_session_proto_msgTypes = make([]protoimpl.MessageInfo, 19) -var file_aiscan_chat_session_proto_goTypes = []interface{}{ - (*SessionRecord)(nil), // 0: aiscan.chat.SessionRecord - (*ListSessionsRequest)(nil), // 1: aiscan.chat.ListSessionsRequest - (*ListSessionsResponse)(nil), // 2: aiscan.chat.ListSessionsResponse - (*GetSessionRequest)(nil), // 3: aiscan.chat.GetSessionRequest - (*GetSessionResponse)(nil), // 4: aiscan.chat.GetSessionResponse - (*ResetSessionRequest)(nil), // 5: aiscan.chat.ResetSessionRequest - (*ResetSessionReceipt)(nil), // 6: aiscan.chat.ResetSessionReceipt - (*ResetSessionResponse)(nil), // 7: aiscan.chat.ResetSessionResponse - (*DeleteSessionRequest)(nil), // 8: aiscan.chat.DeleteSessionRequest - (*DeleteSessionResponse)(nil), // 9: aiscan.chat.DeleteSessionResponse - (*CommandSpec)(nil), // 10: aiscan.chat.CommandSpec - (*ListCommandsRequest)(nil), // 11: aiscan.chat.ListCommandsRequest - (*ListCommandsResponse)(nil), // 12: aiscan.chat.ListCommandsResponse - (*ExecuteCommandRequest)(nil), // 13: aiscan.chat.ExecuteCommandRequest - (*CommandReceipt)(nil), // 14: aiscan.chat.CommandReceipt - (*ExecuteCommandResponse)(nil), // 15: aiscan.chat.ExecuteCommandResponse - (*UploadSessionFileRequest)(nil), // 16: aiscan.chat.UploadSessionFileRequest - (*UploadedFile)(nil), // 17: aiscan.chat.UploadedFile - (*UploadSessionFileResponse)(nil), // 18: aiscan.chat.UploadSessionFileResponse - (*aop.Session)(nil), // 19: aop.Session - (*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp - (*aop.Rejection)(nil), // 21: aop.Rejection -} -var file_aiscan_chat_session_proto_depIdxs = []int32{ - 19, // 0: aiscan.chat.SessionRecord.session:type_name -> aop.Session - 20, // 1: aiscan.chat.SessionRecord.created_at:type_name -> google.protobuf.Timestamp - 20, // 2: aiscan.chat.SessionRecord.updated_at:type_name -> google.protobuf.Timestamp - 0, // 3: aiscan.chat.ListSessionsResponse.sessions:type_name -> aiscan.chat.SessionRecord - 0, // 4: aiscan.chat.GetSessionResponse.session:type_name -> aiscan.chat.SessionRecord - 19, // 5: aiscan.chat.ResetSessionReceipt.previous:type_name -> aop.Session - 0, // 6: aiscan.chat.ResetSessionReceipt.current:type_name -> aiscan.chat.SessionRecord - 6, // 7: aiscan.chat.ResetSessionResponse.accepted:type_name -> aiscan.chat.ResetSessionReceipt - 21, // 8: aiscan.chat.ResetSessionResponse.rejected:type_name -> aop.Rejection - 19, // 9: aiscan.chat.DeleteSessionResponse.accepted:type_name -> aop.Session - 21, // 10: aiscan.chat.DeleteSessionResponse.rejected:type_name -> aop.Rejection - 10, // 11: aiscan.chat.ListCommandsResponse.commands:type_name -> aiscan.chat.CommandSpec - 14, // 12: aiscan.chat.ExecuteCommandResponse.accepted:type_name -> aiscan.chat.CommandReceipt - 21, // 13: aiscan.chat.ExecuteCommandResponse.rejected:type_name -> aop.Rejection - 17, // 14: aiscan.chat.UploadSessionFileResponse.accepted:type_name -> aiscan.chat.UploadedFile - 21, // 15: aiscan.chat.UploadSessionFileResponse.rejected:type_name -> aop.Rejection - 1, // 16: aiscan.chat.SessionService.ListSessions:input_type -> aiscan.chat.ListSessionsRequest - 3, // 17: aiscan.chat.SessionService.GetSession:input_type -> aiscan.chat.GetSessionRequest - 5, // 18: aiscan.chat.SessionService.ResetSession:input_type -> aiscan.chat.ResetSessionRequest - 8, // 19: aiscan.chat.SessionService.DeleteSession:input_type -> aiscan.chat.DeleteSessionRequest - 11, // 20: aiscan.chat.SessionService.ListCommands:input_type -> aiscan.chat.ListCommandsRequest - 13, // 21: aiscan.chat.SessionService.ExecuteCommand:input_type -> aiscan.chat.ExecuteCommandRequest - 16, // 22: aiscan.chat.SessionService.UploadSessionFile:input_type -> aiscan.chat.UploadSessionFileRequest - 2, // 23: aiscan.chat.SessionService.ListSessions:output_type -> aiscan.chat.ListSessionsResponse - 4, // 24: aiscan.chat.SessionService.GetSession:output_type -> aiscan.chat.GetSessionResponse - 7, // 25: aiscan.chat.SessionService.ResetSession:output_type -> aiscan.chat.ResetSessionResponse - 9, // 26: aiscan.chat.SessionService.DeleteSession:output_type -> aiscan.chat.DeleteSessionResponse - 12, // 27: aiscan.chat.SessionService.ListCommands:output_type -> aiscan.chat.ListCommandsResponse - 15, // 28: aiscan.chat.SessionService.ExecuteCommand:output_type -> aiscan.chat.ExecuteCommandResponse - 18, // 29: aiscan.chat.SessionService.UploadSessionFile:output_type -> aiscan.chat.UploadSessionFileResponse - 23, // [23:30] is the sub-list for method output_type - 16, // [16:23] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name -} - -func init() { file_aiscan_chat_session_proto_init() } -func file_aiscan_chat_session_proto_init() { - if File_aiscan_chat_session_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_aiscan_chat_session_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SessionRecord); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListSessionsRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListSessionsResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetSessionRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetSessionResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ResetSessionRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ResetSessionReceipt); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ResetSessionResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteSessionRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteSessionResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CommandSpec); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListCommandsRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListCommandsResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExecuteCommandRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CommandReceipt); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExecuteCommandResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UploadSessionFileRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UploadedFile); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_chat_session_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*UploadSessionFileResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - file_aiscan_chat_session_proto_msgTypes[7].OneofWrappers = []interface{}{ - (*ResetSessionResponse_Accepted)(nil), - (*ResetSessionResponse_Rejected)(nil), - } - file_aiscan_chat_session_proto_msgTypes[9].OneofWrappers = []interface{}{ - (*DeleteSessionResponse_Accepted)(nil), - (*DeleteSessionResponse_Rejected)(nil), - } - file_aiscan_chat_session_proto_msgTypes[15].OneofWrappers = []interface{}{ - (*ExecuteCommandResponse_Accepted)(nil), - (*ExecuteCommandResponse_Rejected)(nil), - } - file_aiscan_chat_session_proto_msgTypes[18].OneofWrappers = []interface{}{ - (*UploadSessionFileResponse_Accepted)(nil), - (*UploadSessionFileResponse_Rejected)(nil), - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_aiscan_chat_session_proto_rawDesc, - NumEnums: 0, - NumMessages: 19, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_aiscan_chat_session_proto_goTypes, - DependencyIndexes: file_aiscan_chat_session_proto_depIdxs, - MessageInfos: file_aiscan_chat_session_proto_msgTypes, - }.Build() - File_aiscan_chat_session_proto = out.File - file_aiscan_chat_session_proto_rawDesc = nil - file_aiscan_chat_session_proto_goTypes = nil - file_aiscan_chat_session_proto_depIdxs = nil -} diff --git a/aop/aiscan/chat/session_grpc.pb.go b/aop/aiscan/chat/session_grpc.pb.go deleted file mode 100644 index 90bc91fb..00000000 --- a/aop/aiscan/chat/session_grpc.pb.go +++ /dev/null @@ -1,331 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.3.0 -// - protoc v6.33.0 -// source: aiscan/chat/session.proto - -package chat - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 - -const ( - SessionService_ListSessions_FullMethodName = "/aiscan.chat.SessionService/ListSessions" - SessionService_GetSession_FullMethodName = "/aiscan.chat.SessionService/GetSession" - SessionService_ResetSession_FullMethodName = "/aiscan.chat.SessionService/ResetSession" - SessionService_DeleteSession_FullMethodName = "/aiscan.chat.SessionService/DeleteSession" - SessionService_ListCommands_FullMethodName = "/aiscan.chat.SessionService/ListCommands" - SessionService_ExecuteCommand_FullMethodName = "/aiscan.chat.SessionService/ExecuteCommand" - SessionService_UploadSessionFile_FullMethodName = "/aiscan.chat.SessionService/UploadSessionFile" -) - -// SessionServiceClient is the client API for SessionService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type SessionServiceClient interface { - ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) - GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) - ResetSession(ctx context.Context, in *ResetSessionRequest, opts ...grpc.CallOption) (*ResetSessionResponse, error) - DeleteSession(ctx context.Context, in *DeleteSessionRequest, opts ...grpc.CallOption) (*DeleteSessionResponse, error) - ListCommands(ctx context.Context, in *ListCommandsRequest, opts ...grpc.CallOption) (*ListCommandsResponse, error) - ExecuteCommand(ctx context.Context, in *ExecuteCommandRequest, opts ...grpc.CallOption) (*ExecuteCommandResponse, error) - UploadSessionFile(ctx context.Context, in *UploadSessionFileRequest, opts ...grpc.CallOption) (*UploadSessionFileResponse, error) -} - -type sessionServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewSessionServiceClient(cc grpc.ClientConnInterface) SessionServiceClient { - return &sessionServiceClient{cc} -} - -func (c *sessionServiceClient) ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) { - out := new(ListSessionsResponse) - err := c.cc.Invoke(ctx, SessionService_ListSessions_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *sessionServiceClient) GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) { - out := new(GetSessionResponse) - err := c.cc.Invoke(ctx, SessionService_GetSession_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *sessionServiceClient) ResetSession(ctx context.Context, in *ResetSessionRequest, opts ...grpc.CallOption) (*ResetSessionResponse, error) { - out := new(ResetSessionResponse) - err := c.cc.Invoke(ctx, SessionService_ResetSession_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *sessionServiceClient) DeleteSession(ctx context.Context, in *DeleteSessionRequest, opts ...grpc.CallOption) (*DeleteSessionResponse, error) { - out := new(DeleteSessionResponse) - err := c.cc.Invoke(ctx, SessionService_DeleteSession_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *sessionServiceClient) ListCommands(ctx context.Context, in *ListCommandsRequest, opts ...grpc.CallOption) (*ListCommandsResponse, error) { - out := new(ListCommandsResponse) - err := c.cc.Invoke(ctx, SessionService_ListCommands_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *sessionServiceClient) ExecuteCommand(ctx context.Context, in *ExecuteCommandRequest, opts ...grpc.CallOption) (*ExecuteCommandResponse, error) { - out := new(ExecuteCommandResponse) - err := c.cc.Invoke(ctx, SessionService_ExecuteCommand_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *sessionServiceClient) UploadSessionFile(ctx context.Context, in *UploadSessionFileRequest, opts ...grpc.CallOption) (*UploadSessionFileResponse, error) { - out := new(UploadSessionFileResponse) - err := c.cc.Invoke(ctx, SessionService_UploadSessionFile_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// SessionServiceServer is the server API for SessionService service. -// All implementations must embed UnimplementedSessionServiceServer -// for forward compatibility -type SessionServiceServer interface { - ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) - GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) - ResetSession(context.Context, *ResetSessionRequest) (*ResetSessionResponse, error) - DeleteSession(context.Context, *DeleteSessionRequest) (*DeleteSessionResponse, error) - ListCommands(context.Context, *ListCommandsRequest) (*ListCommandsResponse, error) - ExecuteCommand(context.Context, *ExecuteCommandRequest) (*ExecuteCommandResponse, error) - UploadSessionFile(context.Context, *UploadSessionFileRequest) (*UploadSessionFileResponse, error) - mustEmbedUnimplementedSessionServiceServer() -} - -// UnimplementedSessionServiceServer must be embedded to have forward compatible implementations. -type UnimplementedSessionServiceServer struct { -} - -func (UnimplementedSessionServiceServer) ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListSessions not implemented") -} -func (UnimplementedSessionServiceServer) GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetSession not implemented") -} -func (UnimplementedSessionServiceServer) ResetSession(context.Context, *ResetSessionRequest) (*ResetSessionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ResetSession not implemented") -} -func (UnimplementedSessionServiceServer) DeleteSession(context.Context, *DeleteSessionRequest) (*DeleteSessionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteSession not implemented") -} -func (UnimplementedSessionServiceServer) ListCommands(context.Context, *ListCommandsRequest) (*ListCommandsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListCommands not implemented") -} -func (UnimplementedSessionServiceServer) ExecuteCommand(context.Context, *ExecuteCommandRequest) (*ExecuteCommandResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ExecuteCommand not implemented") -} -func (UnimplementedSessionServiceServer) UploadSessionFile(context.Context, *UploadSessionFileRequest) (*UploadSessionFileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method UploadSessionFile not implemented") -} -func (UnimplementedSessionServiceServer) mustEmbedUnimplementedSessionServiceServer() {} - -// UnsafeSessionServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to SessionServiceServer will -// result in compilation errors. -type UnsafeSessionServiceServer interface { - mustEmbedUnimplementedSessionServiceServer() -} - -func RegisterSessionServiceServer(s grpc.ServiceRegistrar, srv SessionServiceServer) { - s.RegisterService(&SessionService_ServiceDesc, srv) -} - -func _SessionService_ListSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListSessionsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SessionServiceServer).ListSessions(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SessionService_ListSessions_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SessionServiceServer).ListSessions(ctx, req.(*ListSessionsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _SessionService_GetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetSessionRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SessionServiceServer).GetSession(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SessionService_GetSession_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SessionServiceServer).GetSession(ctx, req.(*GetSessionRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _SessionService_ResetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ResetSessionRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SessionServiceServer).ResetSession(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SessionService_ResetSession_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SessionServiceServer).ResetSession(ctx, req.(*ResetSessionRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _SessionService_DeleteSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteSessionRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SessionServiceServer).DeleteSession(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SessionService_DeleteSession_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SessionServiceServer).DeleteSession(ctx, req.(*DeleteSessionRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _SessionService_ListCommands_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListCommandsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SessionServiceServer).ListCommands(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SessionService_ListCommands_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SessionServiceServer).ListCommands(ctx, req.(*ListCommandsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _SessionService_ExecuteCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExecuteCommandRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SessionServiceServer).ExecuteCommand(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SessionService_ExecuteCommand_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SessionServiceServer).ExecuteCommand(ctx, req.(*ExecuteCommandRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _SessionService_UploadSessionFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(UploadSessionFileRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SessionServiceServer).UploadSessionFile(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SessionService_UploadSessionFile_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SessionServiceServer).UploadSessionFile(ctx, req.(*UploadSessionFileRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// SessionService_ServiceDesc is the grpc.ServiceDesc for SessionService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var SessionService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "aiscan.chat.SessionService", - HandlerType: (*SessionServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "ListSessions", - Handler: _SessionService_ListSessions_Handler, - }, - { - MethodName: "GetSession", - Handler: _SessionService_GetSession_Handler, - }, - { - MethodName: "ResetSession", - Handler: _SessionService_ResetSession_Handler, - }, - { - MethodName: "DeleteSession", - Handler: _SessionService_DeleteSession_Handler, - }, - { - MethodName: "ListCommands", - Handler: _SessionService_ListCommands_Handler, - }, - { - MethodName: "ExecuteCommand", - Handler: _SessionService_ExecuteCommand_Handler, - }, - { - MethodName: "UploadSessionFile", - Handler: _SessionService_UploadSessionFile_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "aiscan/chat/session.proto", -} diff --git a/aop/aiscan/client.go b/aop/aiscan/client.go deleted file mode 100644 index c486e6b6..00000000 --- a/aop/aiscan/client.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package aiscan provides stable client facades over AIScan's generated -// protobuf service groups. -package aiscan - -import ( - "connectrpc.com/connect" - aop "github.com/chainreactors/aiscan/aop" - chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" - "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" - "github.com/chainreactors/aiscan/aop/aiscan/scan/scanconnect" - "github.com/chainreactors/aiscan/aop/aopconnect" - "google.golang.org/grpc" -) - -// Client groups the public ConnectRPC APIs while reusing one HTTP transport, -// base URL and option set. -type Client struct { - Chat aopconnect.ChatServiceClient - Sessions chatconnect.SessionServiceClient - Scans scanconnect.ScanServiceClient -} - -func NewClient(httpClient connect.HTTPClient, baseURL string, options ...connect.ClientOption) *Client { - return &Client{ - Chat: aopconnect.NewChatServiceClient(httpClient, baseURL, options...), - Sessions: chatconnect.NewSessionServiceClient(httpClient, baseURL, options...), - Scans: scanconnect.NewScanServiceClient(httpClient, baseURL, options...), - } -} - -// GRPCClient groups the same public APIs over one native gRPC connection. -type GRPCClient struct { - Chat aop.ChatServiceClient - Sessions chatpb.SessionServiceClient - Scans scanpb.ScanServiceClient -} - -func NewGRPCClient(connection grpc.ClientConnInterface) *GRPCClient { - return &GRPCClient{ - Chat: aop.NewChatServiceClient(connection), - Sessions: chatpb.NewSessionServiceClient(connection), - Scans: scanpb.NewScanServiceClient(connection), - } -} diff --git a/aop/aiscan/client_test.go b/aop/aiscan/client_test.go deleted file mode 100644 index 741fb336..00000000 --- a/aop/aiscan/client_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package aiscan - -import ( - "net/http" - "testing" - - "connectrpc.com/connect" -) - -func TestNewClientInitializesAllPublicServiceGroups(t *testing.T) { - client := NewClient(http.DefaultClient, "http://127.0.0.1:8080", connect.WithProtoJSON()) - if client.Chat == nil || client.Sessions == nil || client.Scans == nil { - t.Fatalf("client groups = %+v", client) - } -} diff --git a/aop/aiscan/scan/scan_grpc.pb.go b/aop/aiscan/scan/scan_grpc.pb.go deleted file mode 100644 index 6778440d..00000000 --- a/aop/aiscan/scan/scan_grpc.pb.go +++ /dev/null @@ -1,322 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.3.0 -// - protoc v6.33.0 -// source: aiscan/scan/scan.proto - -package scan - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 - -const ( - ScanService_SubmitScan_FullMethodName = "/aiscan.scan.ScanService/SubmitScan" - ScanService_GetScan_FullMethodName = "/aiscan.scan.ScanService/GetScan" - ScanService_ListScans_FullMethodName = "/aiscan.scan.ScanService/ListScans" - ScanService_CancelScan_FullMethodName = "/aiscan.scan.ScanService/CancelScan" - ScanService_WatchScanEvents_FullMethodName = "/aiscan.scan.ScanService/WatchScanEvents" - ScanService_GetScanReport_FullMethodName = "/aiscan.scan.ScanService/GetScanReport" -) - -// ScanServiceClient is the client API for ScanService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type ScanServiceClient interface { - SubmitScan(ctx context.Context, in *SubmitScanRequest, opts ...grpc.CallOption) (*SubmitScanResponse, error) - GetScan(ctx context.Context, in *GetScanRequest, opts ...grpc.CallOption) (*GetScanResponse, error) - ListScans(ctx context.Context, in *ListScansRequest, opts ...grpc.CallOption) (*ListScansResponse, error) - CancelScan(ctx context.Context, in *CancelScanRequest, opts ...grpc.CallOption) (*CancelScanResponse, error) - WatchScanEvents(ctx context.Context, in *WatchScanEventsRequest, opts ...grpc.CallOption) (ScanService_WatchScanEventsClient, error) - GetScanReport(ctx context.Context, in *GetScanReportRequest, opts ...grpc.CallOption) (*GetScanReportResponse, error) -} - -type scanServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewScanServiceClient(cc grpc.ClientConnInterface) ScanServiceClient { - return &scanServiceClient{cc} -} - -func (c *scanServiceClient) SubmitScan(ctx context.Context, in *SubmitScanRequest, opts ...grpc.CallOption) (*SubmitScanResponse, error) { - out := new(SubmitScanResponse) - err := c.cc.Invoke(ctx, ScanService_SubmitScan_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *scanServiceClient) GetScan(ctx context.Context, in *GetScanRequest, opts ...grpc.CallOption) (*GetScanResponse, error) { - out := new(GetScanResponse) - err := c.cc.Invoke(ctx, ScanService_GetScan_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *scanServiceClient) ListScans(ctx context.Context, in *ListScansRequest, opts ...grpc.CallOption) (*ListScansResponse, error) { - out := new(ListScansResponse) - err := c.cc.Invoke(ctx, ScanService_ListScans_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *scanServiceClient) CancelScan(ctx context.Context, in *CancelScanRequest, opts ...grpc.CallOption) (*CancelScanResponse, error) { - out := new(CancelScanResponse) - err := c.cc.Invoke(ctx, ScanService_CancelScan_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *scanServiceClient) WatchScanEvents(ctx context.Context, in *WatchScanEventsRequest, opts ...grpc.CallOption) (ScanService_WatchScanEventsClient, error) { - stream, err := c.cc.NewStream(ctx, &ScanService_ServiceDesc.Streams[0], ScanService_WatchScanEvents_FullMethodName, opts...) - if err != nil { - return nil, err - } - x := &scanServiceWatchScanEventsClient{stream} - if err := x.ClientStream.SendMsg(in); err != nil { - return nil, err - } - if err := x.ClientStream.CloseSend(); err != nil { - return nil, err - } - return x, nil -} - -type ScanService_WatchScanEventsClient interface { - Recv() (*WatchScanEventsResponse, error) - grpc.ClientStream -} - -type scanServiceWatchScanEventsClient struct { - grpc.ClientStream -} - -func (x *scanServiceWatchScanEventsClient) Recv() (*WatchScanEventsResponse, error) { - m := new(WatchScanEventsResponse) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -func (c *scanServiceClient) GetScanReport(ctx context.Context, in *GetScanReportRequest, opts ...grpc.CallOption) (*GetScanReportResponse, error) { - out := new(GetScanReportResponse) - err := c.cc.Invoke(ctx, ScanService_GetScanReport_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// ScanServiceServer is the server API for ScanService service. -// All implementations must embed UnimplementedScanServiceServer -// for forward compatibility -type ScanServiceServer interface { - SubmitScan(context.Context, *SubmitScanRequest) (*SubmitScanResponse, error) - GetScan(context.Context, *GetScanRequest) (*GetScanResponse, error) - ListScans(context.Context, *ListScansRequest) (*ListScansResponse, error) - CancelScan(context.Context, *CancelScanRequest) (*CancelScanResponse, error) - WatchScanEvents(*WatchScanEventsRequest, ScanService_WatchScanEventsServer) error - GetScanReport(context.Context, *GetScanReportRequest) (*GetScanReportResponse, error) - mustEmbedUnimplementedScanServiceServer() -} - -// UnimplementedScanServiceServer must be embedded to have forward compatible implementations. -type UnimplementedScanServiceServer struct { -} - -func (UnimplementedScanServiceServer) SubmitScan(context.Context, *SubmitScanRequest) (*SubmitScanResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SubmitScan not implemented") -} -func (UnimplementedScanServiceServer) GetScan(context.Context, *GetScanRequest) (*GetScanResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetScan not implemented") -} -func (UnimplementedScanServiceServer) ListScans(context.Context, *ListScansRequest) (*ListScansResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListScans not implemented") -} -func (UnimplementedScanServiceServer) CancelScan(context.Context, *CancelScanRequest) (*CancelScanResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CancelScan not implemented") -} -func (UnimplementedScanServiceServer) WatchScanEvents(*WatchScanEventsRequest, ScanService_WatchScanEventsServer) error { - return status.Errorf(codes.Unimplemented, "method WatchScanEvents not implemented") -} -func (UnimplementedScanServiceServer) GetScanReport(context.Context, *GetScanReportRequest) (*GetScanReportResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetScanReport not implemented") -} -func (UnimplementedScanServiceServer) mustEmbedUnimplementedScanServiceServer() {} - -// UnsafeScanServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ScanServiceServer will -// result in compilation errors. -type UnsafeScanServiceServer interface { - mustEmbedUnimplementedScanServiceServer() -} - -func RegisterScanServiceServer(s grpc.ServiceRegistrar, srv ScanServiceServer) { - s.RegisterService(&ScanService_ServiceDesc, srv) -} - -func _ScanService_SubmitScan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SubmitScanRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ScanServiceServer).SubmitScan(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ScanService_SubmitScan_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ScanServiceServer).SubmitScan(ctx, req.(*SubmitScanRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ScanService_GetScan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetScanRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ScanServiceServer).GetScan(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ScanService_GetScan_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ScanServiceServer).GetScan(ctx, req.(*GetScanRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ScanService_ListScans_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListScansRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ScanServiceServer).ListScans(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ScanService_ListScans_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ScanServiceServer).ListScans(ctx, req.(*ListScansRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ScanService_CancelScan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CancelScanRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ScanServiceServer).CancelScan(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ScanService_CancelScan_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ScanServiceServer).CancelScan(ctx, req.(*CancelScanRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ScanService_WatchScanEvents_Handler(srv interface{}, stream grpc.ServerStream) error { - m := new(WatchScanEventsRequest) - if err := stream.RecvMsg(m); err != nil { - return err - } - return srv.(ScanServiceServer).WatchScanEvents(m, &scanServiceWatchScanEventsServer{stream}) -} - -type ScanService_WatchScanEventsServer interface { - Send(*WatchScanEventsResponse) error - grpc.ServerStream -} - -type scanServiceWatchScanEventsServer struct { - grpc.ServerStream -} - -func (x *scanServiceWatchScanEventsServer) Send(m *WatchScanEventsResponse) error { - return x.ServerStream.SendMsg(m) -} - -func _ScanService_GetScanReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetScanReportRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ScanServiceServer).GetScanReport(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ScanService_GetScanReport_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ScanServiceServer).GetScanReport(ctx, req.(*GetScanReportRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// ScanService_ServiceDesc is the grpc.ServiceDesc for ScanService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ScanService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "aiscan.scan.ScanService", - HandlerType: (*ScanServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "SubmitScan", - Handler: _ScanService_SubmitScan_Handler, - }, - { - MethodName: "GetScan", - Handler: _ScanService_GetScan_Handler, - }, - { - MethodName: "ListScans", - Handler: _ScanService_ListScans_Handler, - }, - { - MethodName: "CancelScan", - Handler: _ScanService_CancelScan_Handler, - }, - { - MethodName: "GetScanReport", - Handler: _ScanService_GetScanReport_Handler, - }, - }, - Streams: []grpc.StreamDesc{ - { - StreamName: "WatchScanEvents", - Handler: _ScanService_WatchScanEvents_Handler, - ServerStreams: true, - }, - }, - Metadata: "aiscan/scan/scan.proto", -} diff --git a/aop/aiscan/transport/agent.pb.go b/aop/aiscan/transport/agent.pb.go deleted file mode 100644 index 057487fd..00000000 --- a/aop/aiscan/transport/agent.pb.go +++ /dev/null @@ -1,1330 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.34.1 -// protoc v6.33.0 -// source: aiscan/transport/agent.proto - -package transport - -import ( - aop "github.com/chainreactors/aiscan/aop" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type AgentHello struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Authority string `protobuf:"bytes,3,opt,name=authority,proto3" json:"authority,omitempty"` - Commands []string `protobuf:"bytes,4,rep,name=commands,proto3" json:"commands,omitempty"` - CommandMenu []*CommandSpec `protobuf:"bytes,5,rep,name=command_menu,json=commandMenu,proto3" json:"command_menu,omitempty"` - Tools []*ToolDefinition `protobuf:"bytes,6,rep,name=tools,proto3" json:"tools,omitempty"` - Runtime *AgentRuntimeInfo `protobuf:"bytes,7,opt,name=runtime,proto3" json:"runtime,omitempty"` - Status *AgentStatus `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"` - Stats *AgentStats `protobuf:"bytes,9,opt,name=stats,proto3" json:"stats,omitempty"` -} - -func (x *AgentHello) Reset() { - *x = AgentHello{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_agent_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *AgentHello) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentHello) ProtoMessage() {} - -func (x *AgentHello) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_agent_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentHello.ProtoReflect.Descriptor instead. -func (*AgentHello) Descriptor() ([]byte, []int) { - return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{0} -} - -func (x *AgentHello) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -func (x *AgentHello) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *AgentHello) GetAuthority() string { - if x != nil { - return x.Authority - } - return "" -} - -func (x *AgentHello) GetCommands() []string { - if x != nil { - return x.Commands - } - return nil -} - -func (x *AgentHello) GetCommandMenu() []*CommandSpec { - if x != nil { - return x.CommandMenu - } - return nil -} - -func (x *AgentHello) GetTools() []*ToolDefinition { - if x != nil { - return x.Tools - } - return nil -} - -func (x *AgentHello) GetRuntime() *AgentRuntimeInfo { - if x != nil { - return x.Runtime - } - return nil -} - -func (x *AgentHello) GetStatus() *AgentStatus { - if x != nil { - return x.Status - } - return nil -} - -func (x *AgentHello) GetStats() *AgentStats { - if x != nil { - return x.Stats - } - return nil -} - -type ConnectionAccepted struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Capabilities []string `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty"` -} - -func (x *ConnectionAccepted) Reset() { - *x = ConnectionAccepted{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_agent_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ConnectionAccepted) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConnectionAccepted) ProtoMessage() {} - -func (x *ConnectionAccepted) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_agent_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ConnectionAccepted.ProtoReflect.Descriptor instead. -func (*ConnectionAccepted) Descriptor() ([]byte, []int) { - return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{1} -} - -func (x *ConnectionAccepted) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -func (x *ConnectionAccepted) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ConnectionAccepted) GetCapabilities() []string { - if x != nil { - return x.Capabilities - } - return nil -} - -type ToolCallRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` - Call *aop.ToolCall `protobuf:"bytes,4,opt,name=call,proto3" json:"call,omitempty"` -} - -func (x *ToolCallRequest) Reset() { - *x = ToolCallRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_agent_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ToolCallRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolCallRequest) ProtoMessage() {} - -func (x *ToolCallRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_agent_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ToolCallRequest.ProtoReflect.Descriptor instead. -func (*ToolCallRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{2} -} - -func (x *ToolCallRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *ToolCallRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *ToolCallRequest) GetTurnId() string { - if x != nil { - return x.TurnId - } - return "" -} - -func (x *ToolCallRequest) GetCall() *aop.ToolCall { - if x != nil { - return x.Call - } - return nil -} - -type AgentFrame struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - FrameId string `protobuf:"bytes,1,opt,name=frame_id,json=frameId,proto3" json:"frame_id,omitempty"` - CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` - // Types that are assignable to Payload: - // - // *AgentFrame_Hello - // *AgentFrame_OpenSession - // *AgentFrame_RunTurn - // *AgentFrame_CancelTurn - // *AgentFrame_CloseSession - // *AgentFrame_Event - // *AgentFrame_CommandResult - // *AgentFrame_FileResult - // *AgentFrame_ExecOutput - // *AgentFrame_ExecResult - // *AgentFrame_OperationError - // *AgentFrame_Status - // *AgentFrame_Stats - // *AgentFrame_ConfigReload - // *AgentFrame_Terminal - // *AgentFrame_ToolTelemetry - // *AgentFrame_ScoNodes - Payload isAgentFrame_Payload `protobuf_oneof:"payload"` -} - -func (x *AgentFrame) Reset() { - *x = AgentFrame{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_agent_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *AgentFrame) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentFrame) ProtoMessage() {} - -func (x *AgentFrame) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_agent_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentFrame.ProtoReflect.Descriptor instead. -func (*AgentFrame) Descriptor() ([]byte, []int) { - return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{3} -} - -func (x *AgentFrame) GetFrameId() string { - if x != nil { - return x.FrameId - } - return "" -} - -func (x *AgentFrame) GetCorrelationId() string { - if x != nil { - return x.CorrelationId - } - return "" -} - -func (m *AgentFrame) GetPayload() isAgentFrame_Payload { - if m != nil { - return m.Payload - } - return nil -} - -func (x *AgentFrame) GetHello() *AgentHello { - if x, ok := x.GetPayload().(*AgentFrame_Hello); ok { - return x.Hello - } - return nil -} - -func (x *AgentFrame) GetOpenSession() *aop.OpenSessionResponse { - if x, ok := x.GetPayload().(*AgentFrame_OpenSession); ok { - return x.OpenSession - } - return nil -} - -func (x *AgentFrame) GetRunTurn() *aop.RunTurnResponse { - if x, ok := x.GetPayload().(*AgentFrame_RunTurn); ok { - return x.RunTurn - } - return nil -} - -func (x *AgentFrame) GetCancelTurn() *aop.CancelTurnResponse { - if x, ok := x.GetPayload().(*AgentFrame_CancelTurn); ok { - return x.CancelTurn - } - return nil -} - -func (x *AgentFrame) GetCloseSession() *aop.CloseSessionResponse { - if x, ok := x.GetPayload().(*AgentFrame_CloseSession); ok { - return x.CloseSession - } - return nil -} - -func (x *AgentFrame) GetEvent() *aop.Event { - if x, ok := x.GetPayload().(*AgentFrame_Event); ok { - return x.Event - } - return nil -} - -func (x *AgentFrame) GetCommandResult() *CommandResult { - if x, ok := x.GetPayload().(*AgentFrame_CommandResult); ok { - return x.CommandResult - } - return nil -} - -func (x *AgentFrame) GetFileResult() *FileResult { - if x, ok := x.GetPayload().(*AgentFrame_FileResult); ok { - return x.FileResult - } - return nil -} - -func (x *AgentFrame) GetExecOutput() *ExecOutput { - if x, ok := x.GetPayload().(*AgentFrame_ExecOutput); ok { - return x.ExecOutput - } - return nil -} - -func (x *AgentFrame) GetExecResult() *ExecResult { - if x, ok := x.GetPayload().(*AgentFrame_ExecResult); ok { - return x.ExecResult - } - return nil -} - -func (x *AgentFrame) GetOperationError() *OperationError { - if x, ok := x.GetPayload().(*AgentFrame_OperationError); ok { - return x.OperationError - } - return nil -} - -func (x *AgentFrame) GetStatus() *AgentStatus { - if x, ok := x.GetPayload().(*AgentFrame_Status); ok { - return x.Status - } - return nil -} - -func (x *AgentFrame) GetStats() *AgentStats { - if x, ok := x.GetPayload().(*AgentFrame_Stats); ok { - return x.Stats - } - return nil -} - -func (x *AgentFrame) GetConfigReload() *ConfigReloadResult { - if x, ok := x.GetPayload().(*AgentFrame_ConfigReload); ok { - return x.ConfigReload - } - return nil -} - -func (x *AgentFrame) GetTerminal() *TerminalFrame { - if x, ok := x.GetPayload().(*AgentFrame_Terminal); ok { - return x.Terminal - } - return nil -} - -func (x *AgentFrame) GetToolTelemetry() *ToolTelemetry { - if x, ok := x.GetPayload().(*AgentFrame_ToolTelemetry); ok { - return x.ToolTelemetry - } - return nil -} - -func (x *AgentFrame) GetScoNodes() *ScoNodes { - if x, ok := x.GetPayload().(*AgentFrame_ScoNodes); ok { - return x.ScoNodes - } - return nil -} - -type isAgentFrame_Payload interface { - isAgentFrame_Payload() -} - -type AgentFrame_Hello struct { - Hello *AgentHello `protobuf:"bytes,10,opt,name=hello,proto3,oneof"` -} - -type AgentFrame_OpenSession struct { - OpenSession *aop.OpenSessionResponse `protobuf:"bytes,11,opt,name=open_session,json=openSession,proto3,oneof"` -} - -type AgentFrame_RunTurn struct { - RunTurn *aop.RunTurnResponse `protobuf:"bytes,12,opt,name=run_turn,json=runTurn,proto3,oneof"` -} - -type AgentFrame_CancelTurn struct { - CancelTurn *aop.CancelTurnResponse `protobuf:"bytes,13,opt,name=cancel_turn,json=cancelTurn,proto3,oneof"` -} - -type AgentFrame_CloseSession struct { - CloseSession *aop.CloseSessionResponse `protobuf:"bytes,14,opt,name=close_session,json=closeSession,proto3,oneof"` -} - -type AgentFrame_Event struct { - Event *aop.Event `protobuf:"bytes,15,opt,name=event,proto3,oneof"` -} - -type AgentFrame_CommandResult struct { - CommandResult *CommandResult `protobuf:"bytes,16,opt,name=command_result,json=commandResult,proto3,oneof"` -} - -type AgentFrame_FileResult struct { - FileResult *FileResult `protobuf:"bytes,17,opt,name=file_result,json=fileResult,proto3,oneof"` -} - -type AgentFrame_ExecOutput struct { - ExecOutput *ExecOutput `protobuf:"bytes,18,opt,name=exec_output,json=execOutput,proto3,oneof"` -} - -type AgentFrame_ExecResult struct { - ExecResult *ExecResult `protobuf:"bytes,19,opt,name=exec_result,json=execResult,proto3,oneof"` -} - -type AgentFrame_OperationError struct { - OperationError *OperationError `protobuf:"bytes,20,opt,name=operation_error,json=operationError,proto3,oneof"` -} - -type AgentFrame_Status struct { - Status *AgentStatus `protobuf:"bytes,21,opt,name=status,proto3,oneof"` -} - -type AgentFrame_Stats struct { - Stats *AgentStats `protobuf:"bytes,22,opt,name=stats,proto3,oneof"` -} - -type AgentFrame_ConfigReload struct { - ConfigReload *ConfigReloadResult `protobuf:"bytes,23,opt,name=config_reload,json=configReload,proto3,oneof"` -} - -type AgentFrame_Terminal struct { - Terminal *TerminalFrame `protobuf:"bytes,24,opt,name=terminal,proto3,oneof"` -} - -type AgentFrame_ToolTelemetry struct { - ToolTelemetry *ToolTelemetry `protobuf:"bytes,25,opt,name=tool_telemetry,json=toolTelemetry,proto3,oneof"` -} - -type AgentFrame_ScoNodes struct { - ScoNodes *ScoNodes `protobuf:"bytes,26,opt,name=sco_nodes,json=scoNodes,proto3,oneof"` -} - -func (*AgentFrame_Hello) isAgentFrame_Payload() {} - -func (*AgentFrame_OpenSession) isAgentFrame_Payload() {} - -func (*AgentFrame_RunTurn) isAgentFrame_Payload() {} - -func (*AgentFrame_CancelTurn) isAgentFrame_Payload() {} - -func (*AgentFrame_CloseSession) isAgentFrame_Payload() {} - -func (*AgentFrame_Event) isAgentFrame_Payload() {} - -func (*AgentFrame_CommandResult) isAgentFrame_Payload() {} - -func (*AgentFrame_FileResult) isAgentFrame_Payload() {} - -func (*AgentFrame_ExecOutput) isAgentFrame_Payload() {} - -func (*AgentFrame_ExecResult) isAgentFrame_Payload() {} - -func (*AgentFrame_OperationError) isAgentFrame_Payload() {} - -func (*AgentFrame_Status) isAgentFrame_Payload() {} - -func (*AgentFrame_Stats) isAgentFrame_Payload() {} - -func (*AgentFrame_ConfigReload) isAgentFrame_Payload() {} - -func (*AgentFrame_Terminal) isAgentFrame_Payload() {} - -func (*AgentFrame_ToolTelemetry) isAgentFrame_Payload() {} - -func (*AgentFrame_ScoNodes) isAgentFrame_Payload() {} - -type ServerFrame struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - FrameId string `protobuf:"bytes,1,opt,name=frame_id,json=frameId,proto3" json:"frame_id,omitempty"` - CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` - // Types that are assignable to Payload: - // - // *ServerFrame_Accepted - // *ServerFrame_OpenSession - // *ServerFrame_RunTurn - // *ServerFrame_CancelTurn - // *ServerFrame_CloseSession - // *ServerFrame_Command - // *ServerFrame_ToolCall - // *ServerFrame_FileRead - // *ServerFrame_FileWrite - // *ServerFrame_FileList - // *ServerFrame_FileMkdir - // *ServerFrame_FileUpload - // *ServerFrame_Exec - // *ServerFrame_CancelOperation - // *ServerFrame_ReloadConfig - // *ServerFrame_Terminal - // *ServerFrame_Extension - Payload isServerFrame_Payload `protobuf_oneof:"payload"` -} - -func (x *ServerFrame) Reset() { - *x = ServerFrame{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_agent_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ServerFrame) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServerFrame) ProtoMessage() {} - -func (x *ServerFrame) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_agent_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServerFrame.ProtoReflect.Descriptor instead. -func (*ServerFrame) Descriptor() ([]byte, []int) { - return file_aiscan_transport_agent_proto_rawDescGZIP(), []int{4} -} - -func (x *ServerFrame) GetFrameId() string { - if x != nil { - return x.FrameId - } - return "" -} - -func (x *ServerFrame) GetCorrelationId() string { - if x != nil { - return x.CorrelationId - } - return "" -} - -func (m *ServerFrame) GetPayload() isServerFrame_Payload { - if m != nil { - return m.Payload - } - return nil -} - -func (x *ServerFrame) GetAccepted() *ConnectionAccepted { - if x, ok := x.GetPayload().(*ServerFrame_Accepted); ok { - return x.Accepted - } - return nil -} - -func (x *ServerFrame) GetOpenSession() *aop.OpenSessionRequest { - if x, ok := x.GetPayload().(*ServerFrame_OpenSession); ok { - return x.OpenSession - } - return nil -} - -func (x *ServerFrame) GetRunTurn() *aop.RunTurnRequest { - if x, ok := x.GetPayload().(*ServerFrame_RunTurn); ok { - return x.RunTurn - } - return nil -} - -func (x *ServerFrame) GetCancelTurn() *aop.CancelTurnRequest { - if x, ok := x.GetPayload().(*ServerFrame_CancelTurn); ok { - return x.CancelTurn - } - return nil -} - -func (x *ServerFrame) GetCloseSession() *aop.CloseSessionRequest { - if x, ok := x.GetPayload().(*ServerFrame_CloseSession); ok { - return x.CloseSession - } - return nil -} - -func (x *ServerFrame) GetCommand() *CommandRequest { - if x, ok := x.GetPayload().(*ServerFrame_Command); ok { - return x.Command - } - return nil -} - -func (x *ServerFrame) GetToolCall() *ToolCallRequest { - if x, ok := x.GetPayload().(*ServerFrame_ToolCall); ok { - return x.ToolCall - } - return nil -} - -func (x *ServerFrame) GetFileRead() *FileReadRequest { - if x, ok := x.GetPayload().(*ServerFrame_FileRead); ok { - return x.FileRead - } - return nil -} - -func (x *ServerFrame) GetFileWrite() *FileWriteRequest { - if x, ok := x.GetPayload().(*ServerFrame_FileWrite); ok { - return x.FileWrite - } - return nil -} - -func (x *ServerFrame) GetFileList() *FileListRequest { - if x, ok := x.GetPayload().(*ServerFrame_FileList); ok { - return x.FileList - } - return nil -} - -func (x *ServerFrame) GetFileMkdir() *FileMkdirRequest { - if x, ok := x.GetPayload().(*ServerFrame_FileMkdir); ok { - return x.FileMkdir - } - return nil -} - -func (x *ServerFrame) GetFileUpload() *FileUploadRequest { - if x, ok := x.GetPayload().(*ServerFrame_FileUpload); ok { - return x.FileUpload - } - return nil -} - -func (x *ServerFrame) GetExec() *ExecRequest { - if x, ok := x.GetPayload().(*ServerFrame_Exec); ok { - return x.Exec - } - return nil -} - -func (x *ServerFrame) GetCancelOperation() *CancelOperation { - if x, ok := x.GetPayload().(*ServerFrame_CancelOperation); ok { - return x.CancelOperation - } - return nil -} - -func (x *ServerFrame) GetReloadConfig() *ReloadConfig { - if x, ok := x.GetPayload().(*ServerFrame_ReloadConfig); ok { - return x.ReloadConfig - } - return nil -} - -func (x *ServerFrame) GetTerminal() *TerminalFrame { - if x, ok := x.GetPayload().(*ServerFrame_Terminal); ok { - return x.Terminal - } - return nil -} - -func (x *ServerFrame) GetExtension() *aop.Extension { - if x, ok := x.GetPayload().(*ServerFrame_Extension); ok { - return x.Extension - } - return nil -} - -type isServerFrame_Payload interface { - isServerFrame_Payload() -} - -type ServerFrame_Accepted struct { - Accepted *ConnectionAccepted `protobuf:"bytes,10,opt,name=accepted,proto3,oneof"` -} - -type ServerFrame_OpenSession struct { - OpenSession *aop.OpenSessionRequest `protobuf:"bytes,11,opt,name=open_session,json=openSession,proto3,oneof"` -} - -type ServerFrame_RunTurn struct { - RunTurn *aop.RunTurnRequest `protobuf:"bytes,12,opt,name=run_turn,json=runTurn,proto3,oneof"` -} - -type ServerFrame_CancelTurn struct { - CancelTurn *aop.CancelTurnRequest `protobuf:"bytes,13,opt,name=cancel_turn,json=cancelTurn,proto3,oneof"` -} - -type ServerFrame_CloseSession struct { - CloseSession *aop.CloseSessionRequest `protobuf:"bytes,14,opt,name=close_session,json=closeSession,proto3,oneof"` -} - -type ServerFrame_Command struct { - Command *CommandRequest `protobuf:"bytes,15,opt,name=command,proto3,oneof"` -} - -type ServerFrame_ToolCall struct { - ToolCall *ToolCallRequest `protobuf:"bytes,16,opt,name=tool_call,json=toolCall,proto3,oneof"` -} - -type ServerFrame_FileRead struct { - FileRead *FileReadRequest `protobuf:"bytes,17,opt,name=file_read,json=fileRead,proto3,oneof"` -} - -type ServerFrame_FileWrite struct { - FileWrite *FileWriteRequest `protobuf:"bytes,18,opt,name=file_write,json=fileWrite,proto3,oneof"` -} - -type ServerFrame_FileList struct { - FileList *FileListRequest `protobuf:"bytes,19,opt,name=file_list,json=fileList,proto3,oneof"` -} - -type ServerFrame_FileMkdir struct { - FileMkdir *FileMkdirRequest `protobuf:"bytes,20,opt,name=file_mkdir,json=fileMkdir,proto3,oneof"` -} - -type ServerFrame_FileUpload struct { - FileUpload *FileUploadRequest `protobuf:"bytes,21,opt,name=file_upload,json=fileUpload,proto3,oneof"` -} - -type ServerFrame_Exec struct { - Exec *ExecRequest `protobuf:"bytes,22,opt,name=exec,proto3,oneof"` -} - -type ServerFrame_CancelOperation struct { - CancelOperation *CancelOperation `protobuf:"bytes,23,opt,name=cancel_operation,json=cancelOperation,proto3,oneof"` -} - -type ServerFrame_ReloadConfig struct { - ReloadConfig *ReloadConfig `protobuf:"bytes,24,opt,name=reload_config,json=reloadConfig,proto3,oneof"` -} - -type ServerFrame_Terminal struct { - Terminal *TerminalFrame `protobuf:"bytes,25,opt,name=terminal,proto3,oneof"` -} - -type ServerFrame_Extension struct { - Extension *aop.Extension `protobuf:"bytes,26,opt,name=extension,proto3,oneof"` -} - -func (*ServerFrame_Accepted) isServerFrame_Payload() {} - -func (*ServerFrame_OpenSession) isServerFrame_Payload() {} - -func (*ServerFrame_RunTurn) isServerFrame_Payload() {} - -func (*ServerFrame_CancelTurn) isServerFrame_Payload() {} - -func (*ServerFrame_CloseSession) isServerFrame_Payload() {} - -func (*ServerFrame_Command) isServerFrame_Payload() {} - -func (*ServerFrame_ToolCall) isServerFrame_Payload() {} - -func (*ServerFrame_FileRead) isServerFrame_Payload() {} - -func (*ServerFrame_FileWrite) isServerFrame_Payload() {} - -func (*ServerFrame_FileList) isServerFrame_Payload() {} - -func (*ServerFrame_FileMkdir) isServerFrame_Payload() {} - -func (*ServerFrame_FileUpload) isServerFrame_Payload() {} - -func (*ServerFrame_Exec) isServerFrame_Payload() {} - -func (*ServerFrame_CancelOperation) isServerFrame_Payload() {} - -func (*ServerFrame_ReloadConfig) isServerFrame_Payload() {} - -func (*ServerFrame_Terminal) isServerFrame_Payload() {} - -func (*ServerFrame_Extension) isServerFrame_Payload() {} - -var File_aiscan_transport_agent_proto protoreflect.FileDescriptor - -var file_aiscan_transport_agent_proto_rawDesc = []byte{ - 0x0a, 0x1c, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, - 0x1a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x1a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x98, 0x03, 0x0a, 0x0a, 0x41, - 0x67, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x73, 0x12, 0x40, 0x0a, 0x0c, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x6d, 0x65, - 0x6e, 0x75, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, - 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x4d, 0x65, 0x6e, 0x75, 0x12, 0x36, 0x0a, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x18, 0x06, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x66, 0x69, 0x6e, - 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x12, 0x3c, 0x0a, 0x07, - 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, - 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x49, 0x6e, 0x66, - 0x6f, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x35, 0x0a, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, - 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x12, 0x32, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, - 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x05, - 0x73, 0x74, 0x61, 0x74, 0x73, 0x22, 0x67, 0x0a, 0x12, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, - 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x85, - 0x01, 0x0a, 0x0f, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x75, - 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, - 0x6e, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, - 0x52, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x22, 0xfd, 0x08, 0x0a, 0x0a, 0x41, 0x67, 0x65, 0x6e, 0x74, - 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x49, 0x64, - 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x34, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, - 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x48, - 0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x3d, 0x0a, - 0x0c, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0b, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, - 0x0b, 0x6f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x0a, 0x08, - 0x72, 0x75, 0x6e, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, - 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x12, - 0x3a, 0x0a, 0x0b, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, - 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, - 0x0a, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x40, 0x0a, 0x0d, 0x63, - 0x6c, 0x6f, 0x73, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0e, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, - 0x0c, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, - 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, - 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, - 0x74, 0x12, 0x48, 0x0a, 0x0e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x72, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0d, 0x63, 0x6f, - 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x3f, 0x0a, 0x0b, 0x66, - 0x69, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, - 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, - 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x3f, 0x0a, 0x0b, - 0x65, 0x78, 0x65, 0x63, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x12, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x48, - 0x00, 0x52, 0x0a, 0x65, 0x78, 0x65, 0x63, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x3f, 0x0a, - 0x0b, 0x65, 0x78, 0x65, 0x63, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x13, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, - 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x48, 0x00, 0x52, 0x0a, 0x65, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x4b, - 0x0a, 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x4f, 0x70, 0x65, 0x72, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x48, 0x00, 0x52, 0x0e, 0x6f, 0x70, 0x65, - 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x37, 0x0a, 0x06, 0x73, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, 0x69, - 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, - 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x16, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, - 0x73, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x12, 0x4b, 0x0a, 0x0d, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x72, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x17, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x24, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x6c, 0x6f, 0x61, - 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x52, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x3d, 0x0a, 0x08, 0x74, 0x65, 0x72, 0x6d, 0x69, - 0x6e, 0x61, 0x6c, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x65, 0x72, - 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x48, 0x00, 0x52, 0x08, 0x74, 0x65, - 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x12, 0x48, 0x0a, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x74, - 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x18, 0x19, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, - 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, - 0x74, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x48, - 0x00, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, - 0x12, 0x39, 0x0a, 0x09, 0x73, 0x63, 0x6f, 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x1a, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x53, 0x63, 0x6f, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x48, - 0x00, 0x52, 0x08, 0x73, 0x63, 0x6f, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, - 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x9b, 0x09, 0x0a, 0x0b, 0x53, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x49, - 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6f, 0x72, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x42, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, - 0x70, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, - 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x3c, 0x0a, 0x0c, - 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x6f, - 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x72, 0x75, - 0x6e, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, - 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x48, 0x00, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x39, 0x0a, 0x0b, - 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, - 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6e, - 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x3f, 0x0a, 0x0d, 0x63, 0x6c, 0x6f, 0x73, 0x65, - 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, - 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0c, 0x63, 0x6c, 0x6f, 0x73, - 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x07, 0x63, - 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x40, 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, - 0x61, 0x6c, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x6f, 0x6f, - 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, - 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x40, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65, - 0x5f, 0x72, 0x65, 0x61, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x69, - 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, - 0x69, 0x6c, 0x65, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, - 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x61, 0x64, 0x12, 0x43, 0x0a, 0x0a, 0x66, 0x69, - 0x6c, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, - 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, - 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, - 0x40, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x13, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, - 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x4c, 0x69, 0x73, - 0x74, 0x12, 0x43, 0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x6b, 0x64, 0x69, 0x72, 0x18, - 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, - 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x4d, 0x6b, 0x64, - 0x69, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x09, 0x66, 0x69, 0x6c, - 0x65, 0x4d, 0x6b, 0x64, 0x69, 0x72, 0x12, 0x46, 0x0a, 0x0b, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x75, - 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x69, - 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, - 0x69, 0x6c, 0x65, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x48, 0x00, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x33, - 0x0a, 0x04, 0x65, 0x78, 0x65, 0x63, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x61, - 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, - 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x04, 0x65, - 0x78, 0x65, 0x63, 0x12, 0x4e, 0x0a, 0x10, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x6f, 0x70, - 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, - 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x48, 0x00, 0x52, 0x0f, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x45, 0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x52, 0x65, - 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x48, 0x00, 0x52, 0x0c, 0x72, 0x65, - 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x74, 0x65, - 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x18, 0x19, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, - 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, - 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x48, 0x00, 0x52, - 0x08, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x12, 0x2e, 0x0a, 0x09, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, - 0x6f, 0x70, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x09, - 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, - 0x6c, 0x6f, 0x61, 0x64, 0x32, 0x63, 0x0a, 0x15, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x54, 0x72, 0x61, - 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4a, 0x0a, - 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, - 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x41, 0x67, 0x65, 0x6e, - 0x74, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x1a, 0x1d, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x46, 0x72, 0x61, 0x6d, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, - 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, - 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, - 0x74, 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, -} - -var ( - file_aiscan_transport_agent_proto_rawDescOnce sync.Once - file_aiscan_transport_agent_proto_rawDescData = file_aiscan_transport_agent_proto_rawDesc -) - -func file_aiscan_transport_agent_proto_rawDescGZIP() []byte { - file_aiscan_transport_agent_proto_rawDescOnce.Do(func() { - file_aiscan_transport_agent_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_agent_proto_rawDescData) - }) - return file_aiscan_transport_agent_proto_rawDescData -} - -var file_aiscan_transport_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 5) -var file_aiscan_transport_agent_proto_goTypes = []interface{}{ - (*AgentHello)(nil), // 0: aiscan.transport.AgentHello - (*ConnectionAccepted)(nil), // 1: aiscan.transport.ConnectionAccepted - (*ToolCallRequest)(nil), // 2: aiscan.transport.ToolCallRequest - (*AgentFrame)(nil), // 3: aiscan.transport.AgentFrame - (*ServerFrame)(nil), // 4: aiscan.transport.ServerFrame - (*CommandSpec)(nil), // 5: aiscan.transport.CommandSpec - (*ToolDefinition)(nil), // 6: aiscan.transport.ToolDefinition - (*AgentRuntimeInfo)(nil), // 7: aiscan.transport.AgentRuntimeInfo - (*AgentStatus)(nil), // 8: aiscan.transport.AgentStatus - (*AgentStats)(nil), // 9: aiscan.transport.AgentStats - (*aop.ToolCall)(nil), // 10: aop.ToolCall - (*aop.OpenSessionResponse)(nil), // 11: aop.OpenSessionResponse - (*aop.RunTurnResponse)(nil), // 12: aop.RunTurnResponse - (*aop.CancelTurnResponse)(nil), // 13: aop.CancelTurnResponse - (*aop.CloseSessionResponse)(nil), // 14: aop.CloseSessionResponse - (*aop.Event)(nil), // 15: aop.Event - (*CommandResult)(nil), // 16: aiscan.transport.CommandResult - (*FileResult)(nil), // 17: aiscan.transport.FileResult - (*ExecOutput)(nil), // 18: aiscan.transport.ExecOutput - (*ExecResult)(nil), // 19: aiscan.transport.ExecResult - (*OperationError)(nil), // 20: aiscan.transport.OperationError - (*ConfigReloadResult)(nil), // 21: aiscan.transport.ConfigReloadResult - (*TerminalFrame)(nil), // 22: aiscan.transport.TerminalFrame - (*ToolTelemetry)(nil), // 23: aiscan.transport.ToolTelemetry - (*ScoNodes)(nil), // 24: aiscan.transport.ScoNodes - (*aop.OpenSessionRequest)(nil), // 25: aop.OpenSessionRequest - (*aop.RunTurnRequest)(nil), // 26: aop.RunTurnRequest - (*aop.CancelTurnRequest)(nil), // 27: aop.CancelTurnRequest - (*aop.CloseSessionRequest)(nil), // 28: aop.CloseSessionRequest - (*CommandRequest)(nil), // 29: aiscan.transport.CommandRequest - (*FileReadRequest)(nil), // 30: aiscan.transport.FileReadRequest - (*FileWriteRequest)(nil), // 31: aiscan.transport.FileWriteRequest - (*FileListRequest)(nil), // 32: aiscan.transport.FileListRequest - (*FileMkdirRequest)(nil), // 33: aiscan.transport.FileMkdirRequest - (*FileUploadRequest)(nil), // 34: aiscan.transport.FileUploadRequest - (*ExecRequest)(nil), // 35: aiscan.transport.ExecRequest - (*CancelOperation)(nil), // 36: aiscan.transport.CancelOperation - (*ReloadConfig)(nil), // 37: aiscan.transport.ReloadConfig - (*aop.Extension)(nil), // 38: aop.Extension -} -var file_aiscan_transport_agent_proto_depIdxs = []int32{ - 5, // 0: aiscan.transport.AgentHello.command_menu:type_name -> aiscan.transport.CommandSpec - 6, // 1: aiscan.transport.AgentHello.tools:type_name -> aiscan.transport.ToolDefinition - 7, // 2: aiscan.transport.AgentHello.runtime:type_name -> aiscan.transport.AgentRuntimeInfo - 8, // 3: aiscan.transport.AgentHello.status:type_name -> aiscan.transport.AgentStatus - 9, // 4: aiscan.transport.AgentHello.stats:type_name -> aiscan.transport.AgentStats - 10, // 5: aiscan.transport.ToolCallRequest.call:type_name -> aop.ToolCall - 0, // 6: aiscan.transport.AgentFrame.hello:type_name -> aiscan.transport.AgentHello - 11, // 7: aiscan.transport.AgentFrame.open_session:type_name -> aop.OpenSessionResponse - 12, // 8: aiscan.transport.AgentFrame.run_turn:type_name -> aop.RunTurnResponse - 13, // 9: aiscan.transport.AgentFrame.cancel_turn:type_name -> aop.CancelTurnResponse - 14, // 10: aiscan.transport.AgentFrame.close_session:type_name -> aop.CloseSessionResponse - 15, // 11: aiscan.transport.AgentFrame.event:type_name -> aop.Event - 16, // 12: aiscan.transport.AgentFrame.command_result:type_name -> aiscan.transport.CommandResult - 17, // 13: aiscan.transport.AgentFrame.file_result:type_name -> aiscan.transport.FileResult - 18, // 14: aiscan.transport.AgentFrame.exec_output:type_name -> aiscan.transport.ExecOutput - 19, // 15: aiscan.transport.AgentFrame.exec_result:type_name -> aiscan.transport.ExecResult - 20, // 16: aiscan.transport.AgentFrame.operation_error:type_name -> aiscan.transport.OperationError - 8, // 17: aiscan.transport.AgentFrame.status:type_name -> aiscan.transport.AgentStatus - 9, // 18: aiscan.transport.AgentFrame.stats:type_name -> aiscan.transport.AgentStats - 21, // 19: aiscan.transport.AgentFrame.config_reload:type_name -> aiscan.transport.ConfigReloadResult - 22, // 20: aiscan.transport.AgentFrame.terminal:type_name -> aiscan.transport.TerminalFrame - 23, // 21: aiscan.transport.AgentFrame.tool_telemetry:type_name -> aiscan.transport.ToolTelemetry - 24, // 22: aiscan.transport.AgentFrame.sco_nodes:type_name -> aiscan.transport.ScoNodes - 1, // 23: aiscan.transport.ServerFrame.accepted:type_name -> aiscan.transport.ConnectionAccepted - 25, // 24: aiscan.transport.ServerFrame.open_session:type_name -> aop.OpenSessionRequest - 26, // 25: aiscan.transport.ServerFrame.run_turn:type_name -> aop.RunTurnRequest - 27, // 26: aiscan.transport.ServerFrame.cancel_turn:type_name -> aop.CancelTurnRequest - 28, // 27: aiscan.transport.ServerFrame.close_session:type_name -> aop.CloseSessionRequest - 29, // 28: aiscan.transport.ServerFrame.command:type_name -> aiscan.transport.CommandRequest - 2, // 29: aiscan.transport.ServerFrame.tool_call:type_name -> aiscan.transport.ToolCallRequest - 30, // 30: aiscan.transport.ServerFrame.file_read:type_name -> aiscan.transport.FileReadRequest - 31, // 31: aiscan.transport.ServerFrame.file_write:type_name -> aiscan.transport.FileWriteRequest - 32, // 32: aiscan.transport.ServerFrame.file_list:type_name -> aiscan.transport.FileListRequest - 33, // 33: aiscan.transport.ServerFrame.file_mkdir:type_name -> aiscan.transport.FileMkdirRequest - 34, // 34: aiscan.transport.ServerFrame.file_upload:type_name -> aiscan.transport.FileUploadRequest - 35, // 35: aiscan.transport.ServerFrame.exec:type_name -> aiscan.transport.ExecRequest - 36, // 36: aiscan.transport.ServerFrame.cancel_operation:type_name -> aiscan.transport.CancelOperation - 37, // 37: aiscan.transport.ServerFrame.reload_config:type_name -> aiscan.transport.ReloadConfig - 22, // 38: aiscan.transport.ServerFrame.terminal:type_name -> aiscan.transport.TerminalFrame - 38, // 39: aiscan.transport.ServerFrame.extension:type_name -> aop.Extension - 3, // 40: aiscan.transport.AgentTransportService.Connect:input_type -> aiscan.transport.AgentFrame - 4, // 41: aiscan.transport.AgentTransportService.Connect:output_type -> aiscan.transport.ServerFrame - 41, // [41:42] is the sub-list for method output_type - 40, // [40:41] is the sub-list for method input_type - 40, // [40:40] is the sub-list for extension type_name - 40, // [40:40] is the sub-list for extension extendee - 0, // [0:40] is the sub-list for field type_name -} - -func init() { file_aiscan_transport_agent_proto_init() } -func file_aiscan_transport_agent_proto_init() { - if File_aiscan_transport_agent_proto != nil { - return - } - file_aiscan_transport_operation_proto_init() - file_aiscan_transport_telemetry_proto_init() - file_aiscan_transport_terminal_proto_init() - if !protoimpl.UnsafeEnabled { - file_aiscan_transport_agent_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AgentHello); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_agent_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ConnectionAccepted); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_agent_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ToolCallRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_agent_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AgentFrame); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_agent_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ServerFrame); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - file_aiscan_transport_agent_proto_msgTypes[3].OneofWrappers = []interface{}{ - (*AgentFrame_Hello)(nil), - (*AgentFrame_OpenSession)(nil), - (*AgentFrame_RunTurn)(nil), - (*AgentFrame_CancelTurn)(nil), - (*AgentFrame_CloseSession)(nil), - (*AgentFrame_Event)(nil), - (*AgentFrame_CommandResult)(nil), - (*AgentFrame_FileResult)(nil), - (*AgentFrame_ExecOutput)(nil), - (*AgentFrame_ExecResult)(nil), - (*AgentFrame_OperationError)(nil), - (*AgentFrame_Status)(nil), - (*AgentFrame_Stats)(nil), - (*AgentFrame_ConfigReload)(nil), - (*AgentFrame_Terminal)(nil), - (*AgentFrame_ToolTelemetry)(nil), - (*AgentFrame_ScoNodes)(nil), - } - file_aiscan_transport_agent_proto_msgTypes[4].OneofWrappers = []interface{}{ - (*ServerFrame_Accepted)(nil), - (*ServerFrame_OpenSession)(nil), - (*ServerFrame_RunTurn)(nil), - (*ServerFrame_CancelTurn)(nil), - (*ServerFrame_CloseSession)(nil), - (*ServerFrame_Command)(nil), - (*ServerFrame_ToolCall)(nil), - (*ServerFrame_FileRead)(nil), - (*ServerFrame_FileWrite)(nil), - (*ServerFrame_FileList)(nil), - (*ServerFrame_FileMkdir)(nil), - (*ServerFrame_FileUpload)(nil), - (*ServerFrame_Exec)(nil), - (*ServerFrame_CancelOperation)(nil), - (*ServerFrame_ReloadConfig)(nil), - (*ServerFrame_Terminal)(nil), - (*ServerFrame_Extension)(nil), - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_aiscan_transport_agent_proto_rawDesc, - NumEnums: 0, - NumMessages: 5, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_aiscan_transport_agent_proto_goTypes, - DependencyIndexes: file_aiscan_transport_agent_proto_depIdxs, - MessageInfos: file_aiscan_transport_agent_proto_msgTypes, - }.Build() - File_aiscan_transport_agent_proto = out.File - file_aiscan_transport_agent_proto_rawDesc = nil - file_aiscan_transport_agent_proto_goTypes = nil - file_aiscan_transport_agent_proto_depIdxs = nil -} diff --git a/aop/aiscan/transport/agent_grpc.pb.go b/aop/aiscan/transport/agent_grpc.pb.go deleted file mode 100644 index 1c5d8ffb..00000000 --- a/aop/aiscan/transport/agent_grpc.pb.go +++ /dev/null @@ -1,141 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.3.0 -// - protoc v6.33.0 -// source: aiscan/transport/agent.proto - -package transport - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 - -const ( - AgentTransportService_Connect_FullMethodName = "/aiscan.transport.AgentTransportService/Connect" -) - -// AgentTransportServiceClient is the client API for AgentTransportService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type AgentTransportServiceClient interface { - Connect(ctx context.Context, opts ...grpc.CallOption) (AgentTransportService_ConnectClient, error) -} - -type agentTransportServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewAgentTransportServiceClient(cc grpc.ClientConnInterface) AgentTransportServiceClient { - return &agentTransportServiceClient{cc} -} - -func (c *agentTransportServiceClient) Connect(ctx context.Context, opts ...grpc.CallOption) (AgentTransportService_ConnectClient, error) { - stream, err := c.cc.NewStream(ctx, &AgentTransportService_ServiceDesc.Streams[0], AgentTransportService_Connect_FullMethodName, opts...) - if err != nil { - return nil, err - } - x := &agentTransportServiceConnectClient{stream} - return x, nil -} - -type AgentTransportService_ConnectClient interface { - Send(*AgentFrame) error - Recv() (*ServerFrame, error) - grpc.ClientStream -} - -type agentTransportServiceConnectClient struct { - grpc.ClientStream -} - -func (x *agentTransportServiceConnectClient) Send(m *AgentFrame) error { - return x.ClientStream.SendMsg(m) -} - -func (x *agentTransportServiceConnectClient) Recv() (*ServerFrame, error) { - m := new(ServerFrame) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -// AgentTransportServiceServer is the server API for AgentTransportService service. -// All implementations must embed UnimplementedAgentTransportServiceServer -// for forward compatibility -type AgentTransportServiceServer interface { - Connect(AgentTransportService_ConnectServer) error - mustEmbedUnimplementedAgentTransportServiceServer() -} - -// UnimplementedAgentTransportServiceServer must be embedded to have forward compatible implementations. -type UnimplementedAgentTransportServiceServer struct { -} - -func (UnimplementedAgentTransportServiceServer) Connect(AgentTransportService_ConnectServer) error { - return status.Errorf(codes.Unimplemented, "method Connect not implemented") -} -func (UnimplementedAgentTransportServiceServer) mustEmbedUnimplementedAgentTransportServiceServer() {} - -// UnsafeAgentTransportServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to AgentTransportServiceServer will -// result in compilation errors. -type UnsafeAgentTransportServiceServer interface { - mustEmbedUnimplementedAgentTransportServiceServer() -} - -func RegisterAgentTransportServiceServer(s grpc.ServiceRegistrar, srv AgentTransportServiceServer) { - s.RegisterService(&AgentTransportService_ServiceDesc, srv) -} - -func _AgentTransportService_Connect_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(AgentTransportServiceServer).Connect(&agentTransportServiceConnectServer{stream}) -} - -type AgentTransportService_ConnectServer interface { - Send(*ServerFrame) error - Recv() (*AgentFrame, error) - grpc.ServerStream -} - -type agentTransportServiceConnectServer struct { - grpc.ServerStream -} - -func (x *agentTransportServiceConnectServer) Send(m *ServerFrame) error { - return x.ServerStream.SendMsg(m) -} - -func (x *agentTransportServiceConnectServer) Recv() (*AgentFrame, error) { - m := new(AgentFrame) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -// AgentTransportService_ServiceDesc is the grpc.ServiceDesc for AgentTransportService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var AgentTransportService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "aiscan.transport.AgentTransportService", - HandlerType: (*AgentTransportServiceServer)(nil), - Methods: []grpc.MethodDesc{}, - Streams: []grpc.StreamDesc{ - { - StreamName: "Connect", - Handler: _AgentTransportService_Connect_Handler, - ServerStreams: true, - ClientStreams: true, - }, - }, - Metadata: "aiscan/transport/agent.proto", -} diff --git a/aop/aiscan/transport/extensions.pb.go b/aop/aiscan/transport/extensions.pb.go deleted file mode 100644 index c4704c80..00000000 --- a/aop/aiscan/transport/extensions.pb.go +++ /dev/null @@ -1,790 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.34.1 -// protoc v6.33.0 -// source: aiscan/transport/extensions.proto - -package transport - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - structpb "google.golang.org/protobuf/types/known/structpb" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type CommandDetail struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Line string `protobuf:"bytes,1,opt,name=line,proto3" json:"line,omitempty"` - Presentation string `protobuf:"bytes,2,opt,name=presentation,proto3" json:"presentation,omitempty"` -} - -func (x *CommandDetail) Reset() { - *x = CommandDetail{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CommandDetail) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CommandDetail) ProtoMessage() {} - -func (x *CommandDetail) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CommandDetail.ProtoReflect.Descriptor instead. -func (*CommandDetail) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{0} -} - -func (x *CommandDetail) GetLine() string { - if x != nil { - return x.Line - } - return "" -} - -func (x *CommandDetail) GetPresentation() string { - if x != nil { - return x.Presentation - } - return "" -} - -type CompactDetail struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` - KeptMessages uint64 `protobuf:"varint,2,opt,name=kept_messages,json=keptMessages,proto3" json:"kept_messages,omitempty"` - TokensAfter uint64 `protobuf:"varint,3,opt,name=tokens_after,json=tokensAfter,proto3" json:"tokens_after,omitempty"` - TokensBefore uint64 `protobuf:"varint,4,opt,name=tokens_before,json=tokensBefore,proto3" json:"tokens_before,omitempty"` -} - -func (x *CompactDetail) Reset() { - *x = CompactDetail{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CompactDetail) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CompactDetail) ProtoMessage() {} - -func (x *CompactDetail) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CompactDetail.ProtoReflect.Descriptor instead. -func (*CompactDetail) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{1} -} - -func (x *CompactDetail) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -func (x *CompactDetail) GetKeptMessages() uint64 { - if x != nil { - return x.KeptMessages - } - return 0 -} - -func (x *CompactDetail) GetTokensAfter() uint64 { - if x != nil { - return x.TokensAfter - } - return 0 -} - -func (x *CompactDetail) GetTokensBefore() uint64 { - if x != nil { - return x.TokensBefore - } - return 0 -} - -type DelegationDetail struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - AgentName string `protobuf:"bytes,2,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - AgentType string `protobuf:"bytes,3,opt,name=agent_type,json=agentType,proto3" json:"agent_type,omitempty"` - ContextMode string `protobuf:"bytes,4,opt,name=context_mode,json=contextMode,proto3" json:"context_mode,omitempty"` - RunMode string `protobuf:"bytes,5,opt,name=run_mode,json=runMode,proto3" json:"run_mode,omitempty"` - Task string `protobuf:"bytes,6,opt,name=task,proto3" json:"task,omitempty"` -} - -func (x *DelegationDetail) Reset() { - *x = DelegationDetail{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *DelegationDetail) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DelegationDetail) ProtoMessage() {} - -func (x *DelegationDetail) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DelegationDetail.ProtoReflect.Descriptor instead. -func (*DelegationDetail) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{2} -} - -func (x *DelegationDetail) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -func (x *DelegationDetail) GetAgentName() string { - if x != nil { - return x.AgentName - } - return "" -} - -func (x *DelegationDetail) GetAgentType() string { - if x != nil { - return x.AgentType - } - return "" -} - -func (x *DelegationDetail) GetContextMode() string { - if x != nil { - return x.ContextMode - } - return "" -} - -func (x *DelegationDetail) GetRunMode() string { - if x != nil { - return x.RunMode - } - return "" -} - -func (x *DelegationDetail) GetTask() string { - if x != nil { - return x.Task - } - return "" -} - -type EvalControl struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Criteria string `protobuf:"bytes,1,opt,name=criteria,proto3" json:"criteria,omitempty"` - MaxRounds uint32 `protobuf:"varint,2,opt,name=max_rounds,json=maxRounds,proto3" json:"max_rounds,omitempty"` -} - -func (x *EvalControl) Reset() { - *x = EvalControl{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *EvalControl) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EvalControl) ProtoMessage() {} - -func (x *EvalControl) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EvalControl.ProtoReflect.Descriptor instead. -func (*EvalControl) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{3} -} - -func (x *EvalControl) GetCriteria() string { - if x != nil { - return x.Criteria - } - return "" -} - -func (x *EvalControl) GetMaxRounds() uint32 { - if x != nil { - return x.MaxRounds - } - return 0 -} - -type EvalDetail struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` - MaxRounds uint32 `protobuf:"varint,2,opt,name=max_rounds,json=maxRounds,proto3" json:"max_rounds,omitempty"` - Pass bool `protobuf:"varint,3,opt,name=pass,proto3" json:"pass,omitempty"` - Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` - Round uint32 `protobuf:"varint,5,opt,name=round,proto3" json:"round,omitempty"` -} - -func (x *EvalDetail) Reset() { - *x = EvalDetail{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *EvalDetail) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EvalDetail) ProtoMessage() {} - -func (x *EvalDetail) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EvalDetail.ProtoReflect.Descriptor instead. -func (*EvalDetail) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{4} -} - -func (x *EvalDetail) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -func (x *EvalDetail) GetMaxRounds() uint32 { - if x != nil { - return x.MaxRounds - } - return 0 -} - -func (x *EvalDetail) GetPass() bool { - if x != nil { - return x.Pass - } - return false -} - -func (x *EvalDetail) GetReason() string { - if x != nil { - return x.Reason - } - return "" -} - -func (x *EvalDetail) GetRound() uint32 { - if x != nil { - return x.Round - } - return 0 -} - -type BudgetWarning struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - ContextTokens uint64 `protobuf:"varint,1,opt,name=context_tokens,json=contextTokens,proto3" json:"context_tokens,omitempty"` - TokenBudget uint64 `protobuf:"varint,2,opt,name=token_budget,json=tokenBudget,proto3" json:"token_budget,omitempty"` -} - -func (x *BudgetWarning) Reset() { - *x = BudgetWarning{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *BudgetWarning) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BudgetWarning) ProtoMessage() {} - -func (x *BudgetWarning) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BudgetWarning.ProtoReflect.Descriptor instead. -func (*BudgetWarning) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{5} -} - -func (x *BudgetWarning) GetContextTokens() uint64 { - if x != nil { - return x.ContextTokens - } - return 0 -} - -func (x *BudgetWarning) GetTokenBudget() uint64 { - if x != nil { - return x.TokenBudget - } - return 0 -} - -type LLMRequestDetail struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` - Messages uint32 `protobuf:"varint,2,opt,name=messages,proto3" json:"messages,omitempty"` - MaxTokens uint32 `protobuf:"varint,3,opt,name=max_tokens,json=maxTokens,proto3" json:"max_tokens,omitempty"` - Stream bool `protobuf:"varint,4,opt,name=stream,proto3" json:"stream,omitempty"` -} - -func (x *LLMRequestDetail) Reset() { - *x = LLMRequestDetail{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *LLMRequestDetail) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LLMRequestDetail) ProtoMessage() {} - -func (x *LLMRequestDetail) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LLMRequestDetail.ProtoReflect.Descriptor instead. -func (*LLMRequestDetail) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{6} -} - -func (x *LLMRequestDetail) GetModel() string { - if x != nil { - return x.Model - } - return "" -} - -func (x *LLMRequestDetail) GetMessages() uint32 { - if x != nil { - return x.Messages - } - return 0 -} - -func (x *LLMRequestDetail) GetMaxTokens() uint32 { - if x != nil { - return x.MaxTokens - } - return 0 -} - -func (x *LLMRequestDetail) GetStream() bool { - if x != nil { - return x.Stream - } - return false -} - -type WebMessageExtension struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - Metadata []byte `protobuf:"bytes,2,opt,name=metadata,proto3" json:"metadata,omitempty"` - Params *structpb.Struct `protobuf:"bytes,3,opt,name=params,proto3" json:"params,omitempty"` -} - -func (x *WebMessageExtension) Reset() { - *x = WebMessageExtension{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_extensions_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *WebMessageExtension) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WebMessageExtension) ProtoMessage() {} - -func (x *WebMessageExtension) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_extensions_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WebMessageExtension.ProtoReflect.Descriptor instead. -func (*WebMessageExtension) Descriptor() ([]byte, []int) { - return file_aiscan_transport_extensions_proto_rawDescGZIP(), []int{7} -} - -func (x *WebMessageExtension) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -func (x *WebMessageExtension) GetMetadata() []byte { - if x != nil { - return x.Metadata - } - return nil -} - -func (x *WebMessageExtension) GetParams() *structpb.Struct { - if x != nil { - return x.Params - } - return nil -} - -var File_aiscan_transport_extensions_proto protoreflect.FileDescriptor - -var file_aiscan_transport_extensions_proto_rawDesc = []byte{ - 0x0a, 0x21, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, - 0x73, 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0x47, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x44, 0x65, - 0x74, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x70, 0x72, 0x65, 0x73, - 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x92, 0x01, 0x0a, - 0x0d, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, - 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x6b, 0x65, 0x70, 0x74, 0x5f, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x6b, 0x65, 0x70, - 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x73, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, - 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x0d, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x62, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x04, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x65, 0x66, 0x6f, 0x72, - 0x65, 0x22, 0xbd, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x67, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, - 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x61, 0x6d, 0x65, - 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, - 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x4d, 0x6f, - 0x64, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x75, 0x6e, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, - 0x04, 0x74, 0x61, 0x73, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x73, - 0x6b, 0x22, 0x48, 0x0a, 0x0b, 0x45, 0x76, 0x61, 0x6c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, - 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, 0x12, 0x1d, 0x0a, 0x0a, - 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x09, 0x6d, 0x61, 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x0a, - 0x45, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x12, - 0x12, 0x0a, 0x04, 0x70, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x70, - 0x61, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x72, - 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x72, 0x6f, 0x75, 0x6e, - 0x64, 0x22, 0x59, 0x0a, 0x0d, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x57, 0x61, 0x72, 0x6e, 0x69, - 0x6e, 0x67, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x74, - 0x65, 0x78, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x5f, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, - 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x22, 0x7b, 0x0a, 0x10, - 0x4c, 0x4c, 0x4d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, - 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x22, 0x7d, 0x0a, 0x13, 0x57, 0x65, 0x62, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, - 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x6d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2f, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, - 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, - 0x52, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, - 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, - 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, -} - -var ( - file_aiscan_transport_extensions_proto_rawDescOnce sync.Once - file_aiscan_transport_extensions_proto_rawDescData = file_aiscan_transport_extensions_proto_rawDesc -) - -func file_aiscan_transport_extensions_proto_rawDescGZIP() []byte { - file_aiscan_transport_extensions_proto_rawDescOnce.Do(func() { - file_aiscan_transport_extensions_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_extensions_proto_rawDescData) - }) - return file_aiscan_transport_extensions_proto_rawDescData -} - -var file_aiscan_transport_extensions_proto_msgTypes = make([]protoimpl.MessageInfo, 8) -var file_aiscan_transport_extensions_proto_goTypes = []interface{}{ - (*CommandDetail)(nil), // 0: aiscan.transport.CommandDetail - (*CompactDetail)(nil), // 1: aiscan.transport.CompactDetail - (*DelegationDetail)(nil), // 2: aiscan.transport.DelegationDetail - (*EvalControl)(nil), // 3: aiscan.transport.EvalControl - (*EvalDetail)(nil), // 4: aiscan.transport.EvalDetail - (*BudgetWarning)(nil), // 5: aiscan.transport.BudgetWarning - (*LLMRequestDetail)(nil), // 6: aiscan.transport.LLMRequestDetail - (*WebMessageExtension)(nil), // 7: aiscan.transport.WebMessageExtension - (*structpb.Struct)(nil), // 8: google.protobuf.Struct -} -var file_aiscan_transport_extensions_proto_depIdxs = []int32{ - 8, // 0: aiscan.transport.WebMessageExtension.params:type_name -> google.protobuf.Struct - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_aiscan_transport_extensions_proto_init() } -func file_aiscan_transport_extensions_proto_init() { - if File_aiscan_transport_extensions_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_aiscan_transport_extensions_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CommandDetail); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_extensions_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CompactDetail); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_extensions_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DelegationDetail); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_extensions_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*EvalControl); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_extensions_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*EvalDetail); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_extensions_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*BudgetWarning); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_extensions_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*LLMRequestDetail); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_extensions_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*WebMessageExtension); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_aiscan_transport_extensions_proto_rawDesc, - NumEnums: 0, - NumMessages: 8, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_aiscan_transport_extensions_proto_goTypes, - DependencyIndexes: file_aiscan_transport_extensions_proto_depIdxs, - MessageInfos: file_aiscan_transport_extensions_proto_msgTypes, - }.Build() - File_aiscan_transport_extensions_proto = out.File - file_aiscan_transport_extensions_proto_rawDesc = nil - file_aiscan_transport_extensions_proto_goTypes = nil - file_aiscan_transport_extensions_proto_depIdxs = nil -} diff --git a/aop/aiscan/transport/operation.pb.go b/aop/aiscan/transport/operation.pb.go deleted file mode 100644 index 69110f20..00000000 --- a/aop/aiscan/transport/operation.pb.go +++ /dev/null @@ -1,1556 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.34.1 -// protoc v6.33.0 -// source: aiscan/transport/operation.proto - -package transport - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type ExecStream int32 - -const ( - ExecStream_EXEC_STREAM_UNSPECIFIED ExecStream = 0 - ExecStream_EXEC_STREAM_STDOUT ExecStream = 1 - ExecStream_EXEC_STREAM_STDERR ExecStream = 2 -) - -// Enum value maps for ExecStream. -var ( - ExecStream_name = map[int32]string{ - 0: "EXEC_STREAM_UNSPECIFIED", - 1: "EXEC_STREAM_STDOUT", - 2: "EXEC_STREAM_STDERR", - } - ExecStream_value = map[string]int32{ - "EXEC_STREAM_UNSPECIFIED": 0, - "EXEC_STREAM_STDOUT": 1, - "EXEC_STREAM_STDERR": 2, - } -) - -func (x ExecStream) Enum() *ExecStream { - p := new(ExecStream) - *p = x - return p -} - -func (x ExecStream) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (ExecStream) Descriptor() protoreflect.EnumDescriptor { - return file_aiscan_transport_operation_proto_enumTypes[0].Descriptor() -} - -func (ExecStream) Type() protoreflect.EnumType { - return &file_aiscan_transport_operation_proto_enumTypes[0] -} - -func (x ExecStream) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use ExecStream.Descriptor instead. -func (ExecStream) EnumDescriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{0} -} - -type CommandRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - Line string `protobuf:"bytes,3,opt,name=line,proto3" json:"line,omitempty"` -} - -func (x *CommandRequest) Reset() { - *x = CommandRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CommandRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CommandRequest) ProtoMessage() {} - -func (x *CommandRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CommandRequest.ProtoReflect.Descriptor instead. -func (*CommandRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{0} -} - -func (x *CommandRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *CommandRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *CommandRequest) GetLine() string { - if x != nil { - return x.Line - } - return "" -} - -// RunOptions carries AIScan-only turn behavior in the -// io.chainreactors.aiscan.run AOP extension. -type RunOptions struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - EvalCriteria string `protobuf:"bytes,1,opt,name=eval_criteria,json=evalCriteria,proto3" json:"eval_criteria,omitempty"` - EvalMaxRounds uint32 `protobuf:"varint,2,opt,name=eval_max_rounds,json=evalMaxRounds,proto3" json:"eval_max_rounds,omitempty"` -} - -func (x *RunOptions) Reset() { - *x = RunOptions{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *RunOptions) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RunOptions) ProtoMessage() {} - -func (x *RunOptions) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RunOptions.ProtoReflect.Descriptor instead. -func (*RunOptions) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{1} -} - -func (x *RunOptions) GetEvalCriteria() string { - if x != nil { - return x.EvalCriteria - } - return "" -} - -func (x *RunOptions) GetEvalMaxRounds() uint32 { - if x != nil { - return x.EvalMaxRounds - } - return 0 -} - -type CommandResult struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Result []byte `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` - MediaType string `protobuf:"bytes,3,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` -} - -func (x *CommandResult) Reset() { - *x = CommandResult{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CommandResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CommandResult) ProtoMessage() {} - -func (x *CommandResult) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CommandResult.ProtoReflect.Descriptor instead. -func (*CommandResult) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{2} -} - -func (x *CommandResult) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *CommandResult) GetResult() []byte { - if x != nil { - return x.Result - } - return nil -} - -func (x *CommandResult) GetMediaType() string { - if x != nil { - return x.MediaType - } - return "" -} - -type FileReadRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` -} - -func (x *FileReadRequest) Reset() { - *x = FileReadRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *FileReadRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FileReadRequest) ProtoMessage() {} - -func (x *FileReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use FileReadRequest.ProtoReflect.Descriptor instead. -func (*FileReadRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{3} -} - -func (x *FileReadRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *FileReadRequest) GetPath() string { - if x != nil { - return x.Path - } - return "" -} - -type FileWriteRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` - Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` -} - -func (x *FileWriteRequest) Reset() { - *x = FileWriteRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *FileWriteRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FileWriteRequest) ProtoMessage() {} - -func (x *FileWriteRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use FileWriteRequest.ProtoReflect.Descriptor instead. -func (*FileWriteRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{4} -} - -func (x *FileWriteRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *FileWriteRequest) GetPath() string { - if x != nil { - return x.Path - } - return "" -} - -func (x *FileWriteRequest) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -type FileListRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` -} - -func (x *FileListRequest) Reset() { - *x = FileListRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *FileListRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FileListRequest) ProtoMessage() {} - -func (x *FileListRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use FileListRequest.ProtoReflect.Descriptor instead. -func (*FileListRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{5} -} - -func (x *FileListRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *FileListRequest) GetPath() string { - if x != nil { - return x.Path - } - return "" -} - -type FileMkdirRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` -} - -func (x *FileMkdirRequest) Reset() { - *x = FileMkdirRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *FileMkdirRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FileMkdirRequest) ProtoMessage() {} - -func (x *FileMkdirRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use FileMkdirRequest.ProtoReflect.Descriptor instead. -func (*FileMkdirRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{6} -} - -func (x *FileMkdirRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *FileMkdirRequest) GetPath() string { - if x != nil { - return x.Path - } - return "" -} - -type FileUploadRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - Filename string `protobuf:"bytes,3,opt,name=filename,proto3" json:"filename,omitempty"` - MediaType string `protobuf:"bytes,4,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` - Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` -} - -func (x *FileUploadRequest) Reset() { - *x = FileUploadRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *FileUploadRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FileUploadRequest) ProtoMessage() {} - -func (x *FileUploadRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use FileUploadRequest.ProtoReflect.Descriptor instead. -func (*FileUploadRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{7} -} - -func (x *FileUploadRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *FileUploadRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *FileUploadRequest) GetFilename() string { - if x != nil { - return x.Filename - } - return "" -} - -func (x *FileUploadRequest) GetMediaType() string { - if x != nil { - return x.MediaType - } - return "" -} - -func (x *FileUploadRequest) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -type FileEntry struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - IsDirectory bool `protobuf:"varint,2,opt,name=is_directory,json=isDirectory,proto3" json:"is_directory,omitempty"` - Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` -} - -func (x *FileEntry) Reset() { - *x = FileEntry{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *FileEntry) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FileEntry) ProtoMessage() {} - -func (x *FileEntry) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use FileEntry.ProtoReflect.Descriptor instead. -func (*FileEntry) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{8} -} - -func (x *FileEntry) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *FileEntry) GetIsDirectory() bool { - if x != nil { - return x.IsDirectory - } - return false -} - -func (x *FileEntry) GetSize() int64 { - if x != nil { - return x.Size - } - return 0 -} - -type FileResult struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` - Filename string `protobuf:"bytes,3,opt,name=filename,proto3" json:"filename,omitempty"` - Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` - Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` - Entries []*FileEntry `protobuf:"bytes,6,rep,name=entries,proto3" json:"entries,omitempty"` -} - -func (x *FileResult) Reset() { - *x = FileResult{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *FileResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*FileResult) ProtoMessage() {} - -func (x *FileResult) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use FileResult.ProtoReflect.Descriptor instead. -func (*FileResult) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{9} -} - -func (x *FileResult) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *FileResult) GetPath() string { - if x != nil { - return x.Path - } - return "" -} - -func (x *FileResult) GetFilename() string { - if x != nil { - return x.Filename - } - return "" -} - -func (x *FileResult) GetSize() int64 { - if x != nil { - return x.Size - } - return 0 -} - -func (x *FileResult) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -func (x *FileResult) GetEntries() []*FileEntry { - if x != nil { - return x.Entries - } - return nil -} - -type ExecRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` - Cwd string `protobuf:"bytes,3,opt,name=cwd,proto3" json:"cwd,omitempty"` - TimeoutSeconds uint32 `protobuf:"varint,4,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` - Env map[string]string `protobuf:"bytes,5,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` -} - -func (x *ExecRequest) Reset() { - *x = ExecRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ExecRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecRequest) ProtoMessage() {} - -func (x *ExecRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead. -func (*ExecRequest) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{10} -} - -func (x *ExecRequest) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *ExecRequest) GetCommand() string { - if x != nil { - return x.Command - } - return "" -} - -func (x *ExecRequest) GetCwd() string { - if x != nil { - return x.Cwd - } - return "" -} - -func (x *ExecRequest) GetTimeoutSeconds() uint32 { - if x != nil { - return x.TimeoutSeconds - } - return 0 -} - -func (x *ExecRequest) GetEnv() map[string]string { - if x != nil { - return x.Env - } - return nil -} - -type ExecOutput struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Stream ExecStream `protobuf:"varint,2,opt,name=stream,proto3,enum=aiscan.transport.ExecStream" json:"stream,omitempty"` - Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` -} - -func (x *ExecOutput) Reset() { - *x = ExecOutput{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ExecOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecOutput) ProtoMessage() {} - -func (x *ExecOutput) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[11] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecOutput.ProtoReflect.Descriptor instead. -func (*ExecOutput) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{11} -} - -func (x *ExecOutput) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *ExecOutput) GetStream() ExecStream { - if x != nil { - return x.Stream - } - return ExecStream_EXEC_STREAM_UNSPECIFIED -} - -func (x *ExecOutput) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -type ExecResult struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - ExitCode int32 `protobuf:"varint,2,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` - State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` - KillCause string `protobuf:"bytes,4,opt,name=kill_cause,json=killCause,proto3" json:"kill_cause,omitempty"` -} - -func (x *ExecResult) Reset() { - *x = ExecResult{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ExecResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecResult) ProtoMessage() {} - -func (x *ExecResult) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecResult.ProtoReflect.Descriptor instead. -func (*ExecResult) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{12} -} - -func (x *ExecResult) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *ExecResult) GetExitCode() int32 { - if x != nil { - return x.ExitCode - } - return 0 -} - -func (x *ExecResult) GetState() string { - if x != nil { - return x.State - } - return "" -} - -func (x *ExecResult) GetKillCause() string { - if x != nil { - return x.KillCause - } - return "" -} - -type CancelOperation struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` -} - -func (x *CancelOperation) Reset() { - *x = CancelOperation{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CancelOperation) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CancelOperation) ProtoMessage() {} - -func (x *CancelOperation) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CancelOperation.ProtoReflect.Descriptor instead. -func (*CancelOperation) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{13} -} - -func (x *CancelOperation) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -type OperationError struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` - Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` - Retryable bool `protobuf:"varint,4,opt,name=retryable,proto3" json:"retryable,omitempty"` -} - -func (x *OperationError) Reset() { - *x = OperationError{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *OperationError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OperationError) ProtoMessage() {} - -func (x *OperationError) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OperationError.ProtoReflect.Descriptor instead. -func (*OperationError) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{14} -} - -func (x *OperationError) GetTaskId() string { - if x != nil { - return x.TaskId - } - return "" -} - -func (x *OperationError) GetCode() string { - if x != nil { - return x.Code - } - return "" -} - -func (x *OperationError) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *OperationError) GetRetryable() bool { - if x != nil { - return x.Retryable - } - return false -} - -type ReloadConfig struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields -} - -func (x *ReloadConfig) Reset() { - *x = ReloadConfig{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ReloadConfig) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReloadConfig) ProtoMessage() {} - -func (x *ReloadConfig) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[15] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReloadConfig.ProtoReflect.Descriptor instead. -func (*ReloadConfig) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{15} -} - -type ConfigReloadResult struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` - Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` - Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"` - Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` -} - -func (x *ConfigReloadResult) Reset() { - *x = ConfigReloadResult{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_operation_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ConfigReloadResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConfigReloadResult) ProtoMessage() {} - -func (x *ConfigReloadResult) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_operation_proto_msgTypes[16] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ConfigReloadResult.ProtoReflect.Descriptor instead. -func (*ConfigReloadResult) Descriptor() ([]byte, []int) { - return file_aiscan_transport_operation_proto_rawDescGZIP(), []int{16} -} - -func (x *ConfigReloadResult) GetOk() bool { - if x != nil { - return x.Ok - } - return false -} - -func (x *ConfigReloadResult) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *ConfigReloadResult) GetModel() string { - if x != nil { - return x.Model - } - return "" -} - -func (x *ConfigReloadResult) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -var File_aiscan_transport_operation_proto protoreflect.FileDescriptor - -var file_aiscan_transport_operation_proto_rawDesc = []byte{ - 0x0a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x70, 0x6f, 0x72, 0x74, 0x22, 0x5c, 0x0a, 0x0e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, - 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x12, - 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, - 0x6e, 0x65, 0x22, 0x59, 0x0a, 0x0a, 0x52, 0x75, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x76, 0x61, 0x6c, 0x5f, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, - 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x76, 0x61, 0x6c, 0x43, 0x72, 0x69, - 0x74, 0x65, 0x72, 0x69, 0x61, 0x12, 0x26, 0x0a, 0x0f, 0x65, 0x76, 0x61, 0x6c, 0x5f, 0x6d, 0x61, - 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, - 0x65, 0x76, 0x61, 0x6c, 0x4d, 0x61, 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x22, 0x5f, 0x0a, - 0x0d, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x17, - 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, - 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x22, 0x3e, - 0x0a, 0x0f, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, - 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x53, - 0x0a, 0x10, 0x46, 0x69, 0x6c, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, - 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, - 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, - 0x61, 0x74, 0x61, 0x22, 0x3e, 0x0a, 0x0f, 0x46, 0x69, 0x6c, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, - 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, - 0x61, 0x74, 0x68, 0x22, 0x3f, 0x0a, 0x10, 0x46, 0x69, 0x6c, 0x65, 0x4d, 0x6b, 0x64, 0x69, 0x72, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, - 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x70, 0x61, 0x74, 0x68, 0x22, 0x9a, 0x01, 0x0a, 0x11, 0x46, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x6c, - 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, - 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, - 0x6b, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, - 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, - 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, - 0x61, 0x22, 0x56, 0x0a, 0x09, 0x46, 0x69, 0x6c, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x12, - 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x73, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, - 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x44, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x22, 0xb4, 0x01, 0x0a, 0x0a, 0x46, 0x69, - 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, - 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x35, 0x0a, 0x07, 0x65, 0x6e, 0x74, - 0x72, 0x69, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x46, 0x69, - 0x6c, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, - 0x22, 0xed, 0x01, 0x0a, 0x0b, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x77, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x63, 0x77, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, - 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, - 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x38, - 0x0a, 0x03, 0x65, 0x6e, 0x76, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x61, 0x69, - 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, - 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x1a, 0x36, 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, - 0x22, 0x6f, 0x0a, 0x0a, 0x45, 0x78, 0x65, 0x63, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x17, - 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, - 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x53, - 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x12, 0x0a, - 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, - 0x61, 0x22, 0x77, 0x0a, 0x0a, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, - 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, - 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x69, - 0x74, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, - 0x69, 0x6c, 0x6c, 0x5f, 0x63, 0x61, 0x75, 0x73, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x6b, 0x69, 0x6c, 0x6c, 0x43, 0x61, 0x75, 0x73, 0x65, 0x22, 0x2a, 0x0a, 0x0f, 0x43, 0x61, - 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x17, 0x0a, - 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x22, 0x75, 0x0a, 0x0e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, - 0x64, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, - 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x22, 0x0e, 0x0a, - 0x0c, 0x52, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x6c, 0x0a, - 0x12, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x02, 0x6f, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, - 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x2a, 0x59, 0x0a, 0x0a, 0x45, - 0x78, 0x65, 0x63, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x1b, 0x0a, 0x17, 0x45, 0x58, 0x45, - 0x43, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, - 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x45, 0x58, 0x45, 0x43, 0x5f, 0x53, - 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x53, 0x54, 0x44, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x12, 0x16, - 0x0a, 0x12, 0x45, 0x58, 0x45, 0x43, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x53, 0x54, - 0x44, 0x45, 0x52, 0x52, 0x10, 0x02, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, - 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, 0x61, 0x69, - 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x3b, 0x74, - 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_aiscan_transport_operation_proto_rawDescOnce sync.Once - file_aiscan_transport_operation_proto_rawDescData = file_aiscan_transport_operation_proto_rawDesc -) - -func file_aiscan_transport_operation_proto_rawDescGZIP() []byte { - file_aiscan_transport_operation_proto_rawDescOnce.Do(func() { - file_aiscan_transport_operation_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_operation_proto_rawDescData) - }) - return file_aiscan_transport_operation_proto_rawDescData -} - -var file_aiscan_transport_operation_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_aiscan_transport_operation_proto_msgTypes = make([]protoimpl.MessageInfo, 18) -var file_aiscan_transport_operation_proto_goTypes = []interface{}{ - (ExecStream)(0), // 0: aiscan.transport.ExecStream - (*CommandRequest)(nil), // 1: aiscan.transport.CommandRequest - (*RunOptions)(nil), // 2: aiscan.transport.RunOptions - (*CommandResult)(nil), // 3: aiscan.transport.CommandResult - (*FileReadRequest)(nil), // 4: aiscan.transport.FileReadRequest - (*FileWriteRequest)(nil), // 5: aiscan.transport.FileWriteRequest - (*FileListRequest)(nil), // 6: aiscan.transport.FileListRequest - (*FileMkdirRequest)(nil), // 7: aiscan.transport.FileMkdirRequest - (*FileUploadRequest)(nil), // 8: aiscan.transport.FileUploadRequest - (*FileEntry)(nil), // 9: aiscan.transport.FileEntry - (*FileResult)(nil), // 10: aiscan.transport.FileResult - (*ExecRequest)(nil), // 11: aiscan.transport.ExecRequest - (*ExecOutput)(nil), // 12: aiscan.transport.ExecOutput - (*ExecResult)(nil), // 13: aiscan.transport.ExecResult - (*CancelOperation)(nil), // 14: aiscan.transport.CancelOperation - (*OperationError)(nil), // 15: aiscan.transport.OperationError - (*ReloadConfig)(nil), // 16: aiscan.transport.ReloadConfig - (*ConfigReloadResult)(nil), // 17: aiscan.transport.ConfigReloadResult - nil, // 18: aiscan.transport.ExecRequest.EnvEntry -} -var file_aiscan_transport_operation_proto_depIdxs = []int32{ - 9, // 0: aiscan.transport.FileResult.entries:type_name -> aiscan.transport.FileEntry - 18, // 1: aiscan.transport.ExecRequest.env:type_name -> aiscan.transport.ExecRequest.EnvEntry - 0, // 2: aiscan.transport.ExecOutput.stream:type_name -> aiscan.transport.ExecStream - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name -} - -func init() { file_aiscan_transport_operation_proto_init() } -func file_aiscan_transport_operation_proto_init() { - if File_aiscan_transport_operation_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_aiscan_transport_operation_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CommandRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RunOptions); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CommandResult); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FileReadRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FileWriteRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FileListRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FileMkdirRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FileUploadRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FileEntry); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FileResult); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExecRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExecOutput); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExecResult); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CancelOperation); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*OperationError); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ReloadConfig); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_operation_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ConfigReloadResult); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_aiscan_transport_operation_proto_rawDesc, - NumEnums: 1, - NumMessages: 18, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_aiscan_transport_operation_proto_goTypes, - DependencyIndexes: file_aiscan_transport_operation_proto_depIdxs, - EnumInfos: file_aiscan_transport_operation_proto_enumTypes, - MessageInfos: file_aiscan_transport_operation_proto_msgTypes, - }.Build() - File_aiscan_transport_operation_proto = out.File - file_aiscan_transport_operation_proto_rawDesc = nil - file_aiscan_transport_operation_proto_goTypes = nil - file_aiscan_transport_operation_proto_depIdxs = nil -} diff --git a/aop/aiscan/transport/telemetry.pb.go b/aop/aiscan/transport/telemetry.pb.go deleted file mode 100644 index 3b4ede9a..00000000 --- a/aop/aiscan/transport/telemetry.pb.go +++ /dev/null @@ -1,861 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.34.1 -// protoc v6.33.0 -// source: aiscan/transport/telemetry.proto - -package transport - -import ( - aop "github.com/chainreactors/aiscan/aop" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type AgentRuntimeInfo struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` - Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` - WorkingDir string `protobuf:"bytes,3,opt,name=working_dir,json=workingDir,proto3" json:"working_dir,omitempty"` - Os string `protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"` - Arch string `protobuf:"bytes,5,opt,name=arch,proto3" json:"arch,omitempty"` - Pid int32 `protobuf:"varint,6,opt,name=pid,proto3" json:"pid,omitempty"` - Capabilities []string `protobuf:"bytes,7,rep,name=capabilities,proto3" json:"capabilities,omitempty"` - Metadata *aop.EncodedValue `protobuf:"bytes,8,opt,name=metadata,proto3" json:"metadata,omitempty"` -} - -func (x *AgentRuntimeInfo) Reset() { - *x = AgentRuntimeInfo{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *AgentRuntimeInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentRuntimeInfo) ProtoMessage() {} - -func (x *AgentRuntimeInfo) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentRuntimeInfo.ProtoReflect.Descriptor instead. -func (*AgentRuntimeInfo) Descriptor() ([]byte, []int) { - return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{0} -} - -func (x *AgentRuntimeInfo) GetHostname() string { - if x != nil { - return x.Hostname - } - return "" -} - -func (x *AgentRuntimeInfo) GetUsername() string { - if x != nil { - return x.Username - } - return "" -} - -func (x *AgentRuntimeInfo) GetWorkingDir() string { - if x != nil { - return x.WorkingDir - } - return "" -} - -func (x *AgentRuntimeInfo) GetOs() string { - if x != nil { - return x.Os - } - return "" -} - -func (x *AgentRuntimeInfo) GetArch() string { - if x != nil { - return x.Arch - } - return "" -} - -func (x *AgentRuntimeInfo) GetPid() int32 { - if x != nil { - return x.Pid - } - return 0 -} - -func (x *AgentRuntimeInfo) GetCapabilities() []string { - if x != nil { - return x.Capabilities - } - return nil -} - -func (x *AgentRuntimeInfo) GetMetadata() *aop.EncodedValue { - if x != nil { - return x.Metadata - } - return nil -} - -type AgentStatus struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` - Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` - Space string `protobuf:"bytes,3,opt,name=space,proto3" json:"space,omitempty"` - Bound bool `protobuf:"varint,4,opt,name=bound,proto3" json:"bound,omitempty"` - ConfigError string `protobuf:"bytes,5,opt,name=config_error,json=configError,proto3" json:"config_error,omitempty"` -} - -func (x *AgentStatus) Reset() { - *x = AgentStatus{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *AgentStatus) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentStatus) ProtoMessage() {} - -func (x *AgentStatus) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentStatus.ProtoReflect.Descriptor instead. -func (*AgentStatus) Descriptor() ([]byte, []int) { - return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{1} -} - -func (x *AgentStatus) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *AgentStatus) GetModel() string { - if x != nil { - return x.Model - } - return "" -} - -func (x *AgentStatus) GetSpace() string { - if x != nil { - return x.Space - } - return "" -} - -func (x *AgentStatus) GetBound() bool { - if x != nil { - return x.Bound - } - return false -} - -func (x *AgentStatus) GetConfigError() string { - if x != nil { - return x.ConfigError - } - return "" -} - -type AgentStats struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Turns uint64 `protobuf:"varint,1,opt,name=turns,proto3" json:"turns,omitempty"` - ToolCalls uint64 `protobuf:"varint,2,opt,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"` - RunningTools uint64 `protobuf:"varint,3,opt,name=running_tools,json=runningTools,proto3" json:"running_tools,omitempty"` - InputTokens uint64 `protobuf:"varint,4,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` - OutputTokens uint64 `protobuf:"varint,5,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` - TotalTokens uint64 `protobuf:"varint,6,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` - CacheReadTokens uint64 `protobuf:"varint,7,opt,name=cache_read_tokens,json=cacheReadTokens,proto3" json:"cache_read_tokens,omitempty"` - CacheWriteTokens uint64 `protobuf:"varint,8,opt,name=cache_write_tokens,json=cacheWriteTokens,proto3" json:"cache_write_tokens,omitempty"` - Assets uint64 `protobuf:"varint,9,opt,name=assets,proto3" json:"assets,omitempty"` - Loots uint64 `protobuf:"varint,10,opt,name=loots,proto3" json:"loots,omitempty"` - LastEvent string `protobuf:"bytes,11,opt,name=last_event,json=lastEvent,proto3" json:"last_event,omitempty"` -} - -func (x *AgentStats) Reset() { - *x = AgentStats{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *AgentStats) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentStats) ProtoMessage() {} - -func (x *AgentStats) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentStats.ProtoReflect.Descriptor instead. -func (*AgentStats) Descriptor() ([]byte, []int) { - return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{2} -} - -func (x *AgentStats) GetTurns() uint64 { - if x != nil { - return x.Turns - } - return 0 -} - -func (x *AgentStats) GetToolCalls() uint64 { - if x != nil { - return x.ToolCalls - } - return 0 -} - -func (x *AgentStats) GetRunningTools() uint64 { - if x != nil { - return x.RunningTools - } - return 0 -} - -func (x *AgentStats) GetInputTokens() uint64 { - if x != nil { - return x.InputTokens - } - return 0 -} - -func (x *AgentStats) GetOutputTokens() uint64 { - if x != nil { - return x.OutputTokens - } - return 0 -} - -func (x *AgentStats) GetTotalTokens() uint64 { - if x != nil { - return x.TotalTokens - } - return 0 -} - -func (x *AgentStats) GetCacheReadTokens() uint64 { - if x != nil { - return x.CacheReadTokens - } - return 0 -} - -func (x *AgentStats) GetCacheWriteTokens() uint64 { - if x != nil { - return x.CacheWriteTokens - } - return 0 -} - -func (x *AgentStats) GetAssets() uint64 { - if x != nil { - return x.Assets - } - return 0 -} - -func (x *AgentStats) GetLoots() uint64 { - if x != nil { - return x.Loots - } - return 0 -} - -func (x *AgentStats) GetLastEvent() string { - if x != nil { - return x.LastEvent - } - return "" -} - -type ToolDefinition struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` - InputSchema *aop.EncodedValue `protobuf:"bytes,4,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` -} - -func (x *ToolDefinition) Reset() { - *x = ToolDefinition{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ToolDefinition) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolDefinition) ProtoMessage() {} - -func (x *ToolDefinition) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ToolDefinition.ProtoReflect.Descriptor instead. -func (*ToolDefinition) Descriptor() ([]byte, []int) { - return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{3} -} - -func (x *ToolDefinition) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *ToolDefinition) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ToolDefinition) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *ToolDefinition) GetInputSchema() *aop.EncodedValue { - if x != nil { - return x.InputSchema - } - return nil -} - -type CommandSpec struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Aliases []string `protobuf:"bytes,2,rep,name=aliases,proto3" json:"aliases,omitempty"` - Usage string `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` - Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` -} - -func (x *CommandSpec) Reset() { - *x = CommandSpec{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *CommandSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CommandSpec) ProtoMessage() {} - -func (x *CommandSpec) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CommandSpec.ProtoReflect.Descriptor instead. -func (*CommandSpec) Descriptor() ([]byte, []int) { - return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{4} -} - -func (x *CommandSpec) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *CommandSpec) GetAliases() []string { - if x != nil { - return x.Aliases - } - return nil -} - -func (x *CommandSpec) GetUsage() string { - if x != nil { - return x.Usage - } - return "" -} - -func (x *CommandSpec) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -type ToolTelemetry struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Tool string `protobuf:"bytes,1,opt,name=tool,proto3" json:"tool,omitempty"` - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` - Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` - Data *aop.EncodedValue `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` - CallId string `protobuf:"bytes,5,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` - Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"` -} - -func (x *ToolTelemetry) Reset() { - *x = ToolTelemetry{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ToolTelemetry) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ToolTelemetry) ProtoMessage() {} - -func (x *ToolTelemetry) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ToolTelemetry.ProtoReflect.Descriptor instead. -func (*ToolTelemetry) Descriptor() ([]byte, []int) { - return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{5} -} - -func (x *ToolTelemetry) GetTool() string { - if x != nil { - return x.Tool - } - return "" -} - -func (x *ToolTelemetry) GetKind() string { - if x != nil { - return x.Kind - } - return "" -} - -func (x *ToolTelemetry) GetTarget() string { - if x != nil { - return x.Target - } - return "" -} - -func (x *ToolTelemetry) GetData() *aop.EncodedValue { - if x != nil { - return x.Data - } - return nil -} - -func (x *ToolTelemetry) GetCallId() string { - if x != nil { - return x.CallId - } - return "" -} - -func (x *ToolTelemetry) GetTimestamp() *timestamppb.Timestamp { - if x != nil { - return x.Timestamp - } - return nil -} - -type ScoNodes struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` - Nodes [][]byte `protobuf:"bytes,2,rep,name=nodes,proto3" json:"nodes,omitempty"` -} - -func (x *ScoNodes) Reset() { - *x = ScoNodes{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ScoNodes) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ScoNodes) ProtoMessage() {} - -func (x *ScoNodes) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_telemetry_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ScoNodes.ProtoReflect.Descriptor instead. -func (*ScoNodes) Descriptor() ([]byte, []int) { - return file_aiscan_transport_telemetry_proto_rawDescGZIP(), []int{6} -} - -func (x *ScoNodes) GetCallId() string { - if x != nil { - return x.CallId - } - return "" -} - -func (x *ScoNodes) GetNodes() [][]byte { - if x != nil { - return x.Nodes - } - return nil -} - -var File_aiscan_transport_telemetry_proto protoreflect.FileDescriptor - -var file_aiscan_transport_telemetry_proto_rawDesc = []byte{ - 0x0a, 0x20, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf4, 0x01, 0x0a, 0x10, 0x41, 0x67, 0x65, 0x6e, 0x74, - 0x52, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x68, - 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, - 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, - 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x64, - 0x69, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, - 0x67, 0x44, 0x69, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x6f, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x63, 0x68, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, - 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x2d, - 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x8e, 0x01, - 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1a, 0x0a, - 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, - 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, - 0x14, 0x0a, 0x05, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xf8, - 0x02, 0x0a, 0x0a, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x14, 0x0a, - 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x74, 0x75, - 0x72, 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, - 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, - 0x6c, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x6f, - 0x6f, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x75, 0x6e, 0x6e, 0x69, - 0x6e, 0x67, 0x54, 0x6f, 0x6f, 0x6c, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, - 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x69, - 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, - 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x04, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, - 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, - 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0f, 0x63, - 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x2c, - 0x0a, 0x12, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x04, 0x52, 0x10, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, - 0x61, 0x73, 0x73, 0x65, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x61, 0x73, - 0x73, 0x65, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x6f, 0x6f, 0x74, 0x73, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x04, 0x52, 0x05, 0x6c, 0x6f, 0x6f, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x61, - 0x73, 0x74, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x6c, 0x61, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x90, 0x01, 0x0a, 0x0e, 0x54, 0x6f, - 0x6f, 0x6c, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, - 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, - 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, - 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, - 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x22, 0x73, 0x0a, 0x0b, - 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x70, 0x65, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, - 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x07, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, - 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x22, 0xc9, 0x01, 0x0a, 0x0d, 0x54, 0x6f, 0x6f, 0x6c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, - 0x74, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x74, - 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, - 0x67, 0x65, 0x74, 0x12, 0x25, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, - 0x61, 0x6c, 0x75, 0x65, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, - 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, - 0x6c, 0x49, 0x64, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, - 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x39, 0x0a, - 0x08, 0x53, 0x63, 0x6f, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x6c, - 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, - 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, - 0x0c, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, - 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, - 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, -} - -var ( - file_aiscan_transport_telemetry_proto_rawDescOnce sync.Once - file_aiscan_transport_telemetry_proto_rawDescData = file_aiscan_transport_telemetry_proto_rawDesc -) - -func file_aiscan_transport_telemetry_proto_rawDescGZIP() []byte { - file_aiscan_transport_telemetry_proto_rawDescOnce.Do(func() { - file_aiscan_transport_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_telemetry_proto_rawDescData) - }) - return file_aiscan_transport_telemetry_proto_rawDescData -} - -var file_aiscan_transport_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 7) -var file_aiscan_transport_telemetry_proto_goTypes = []interface{}{ - (*AgentRuntimeInfo)(nil), // 0: aiscan.transport.AgentRuntimeInfo - (*AgentStatus)(nil), // 1: aiscan.transport.AgentStatus - (*AgentStats)(nil), // 2: aiscan.transport.AgentStats - (*ToolDefinition)(nil), // 3: aiscan.transport.ToolDefinition - (*CommandSpec)(nil), // 4: aiscan.transport.CommandSpec - (*ToolTelemetry)(nil), // 5: aiscan.transport.ToolTelemetry - (*ScoNodes)(nil), // 6: aiscan.transport.ScoNodes - (*aop.EncodedValue)(nil), // 7: aop.EncodedValue - (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp -} -var file_aiscan_transport_telemetry_proto_depIdxs = []int32{ - 7, // 0: aiscan.transport.AgentRuntimeInfo.metadata:type_name -> aop.EncodedValue - 7, // 1: aiscan.transport.ToolDefinition.input_schema:type_name -> aop.EncodedValue - 7, // 2: aiscan.transport.ToolTelemetry.data:type_name -> aop.EncodedValue - 8, // 3: aiscan.transport.ToolTelemetry.timestamp:type_name -> google.protobuf.Timestamp - 4, // [4:4] is the sub-list for method output_type - 4, // [4:4] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name -} - -func init() { file_aiscan_transport_telemetry_proto_init() } -func file_aiscan_transport_telemetry_proto_init() { - if File_aiscan_transport_telemetry_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_aiscan_transport_telemetry_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AgentRuntimeInfo); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_telemetry_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AgentStatus); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_telemetry_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*AgentStats); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_telemetry_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ToolDefinition); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_telemetry_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CommandSpec); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_telemetry_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ToolTelemetry); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_telemetry_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ScoNodes); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_aiscan_transport_telemetry_proto_rawDesc, - NumEnums: 0, - NumMessages: 7, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_aiscan_transport_telemetry_proto_goTypes, - DependencyIndexes: file_aiscan_transport_telemetry_proto_depIdxs, - MessageInfos: file_aiscan_transport_telemetry_proto_msgTypes, - }.Build() - File_aiscan_transport_telemetry_proto = out.File - file_aiscan_transport_telemetry_proto_rawDesc = nil - file_aiscan_transport_telemetry_proto_goTypes = nil - file_aiscan_transport_telemetry_proto_depIdxs = nil -} diff --git a/aop/aiscan/transport/terminal.pb.go b/aop/aiscan/transport/terminal.pb.go deleted file mode 100644 index c3b5e063..00000000 --- a/aop/aiscan/transport/terminal.pb.go +++ /dev/null @@ -1,506 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.34.1 -// protoc v6.33.0 -// source: aiscan/transport/terminal.proto - -package transport - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type TerminalInfo struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - Command string `protobuf:"bytes,4,opt,name=command,proto3" json:"command,omitempty"` - Pid int32 `protobuf:"varint,5,opt,name=pid,proto3" json:"pid,omitempty"` - StartedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` - LastActivityAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_activity_at,json=lastActivityAt,proto3" json:"last_activity_at,omitempty"` - EndedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` - ActivitySeq int64 `protobuf:"varint,9,opt,name=activity_seq,json=activitySeq,proto3" json:"activity_seq,omitempty"` - OutputBytes int64 `protobuf:"varint,10,opt,name=output_bytes,json=outputBytes,proto3" json:"output_bytes,omitempty"` - ExitCode int32 `protobuf:"varint,11,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` - State string `protobuf:"bytes,12,opt,name=state,proto3" json:"state,omitempty"` - KillCause string `protobuf:"bytes,13,opt,name=kill_cause,json=killCause,proto3" json:"kill_cause,omitempty"` -} - -func (x *TerminalInfo) Reset() { - *x = TerminalInfo{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_terminal_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *TerminalInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*TerminalInfo) ProtoMessage() {} - -func (x *TerminalInfo) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_terminal_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use TerminalInfo.ProtoReflect.Descriptor instead. -func (*TerminalInfo) Descriptor() ([]byte, []int) { - return file_aiscan_transport_terminal_proto_rawDescGZIP(), []int{0} -} - -func (x *TerminalInfo) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *TerminalInfo) GetKind() string { - if x != nil { - return x.Kind - } - return "" -} - -func (x *TerminalInfo) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *TerminalInfo) GetCommand() string { - if x != nil { - return x.Command - } - return "" -} - -func (x *TerminalInfo) GetPid() int32 { - if x != nil { - return x.Pid - } - return 0 -} - -func (x *TerminalInfo) GetStartedAt() *timestamppb.Timestamp { - if x != nil { - return x.StartedAt - } - return nil -} - -func (x *TerminalInfo) GetLastActivityAt() *timestamppb.Timestamp { - if x != nil { - return x.LastActivityAt - } - return nil -} - -func (x *TerminalInfo) GetEndedAt() *timestamppb.Timestamp { - if x != nil { - return x.EndedAt - } - return nil -} - -func (x *TerminalInfo) GetActivitySeq() int64 { - if x != nil { - return x.ActivitySeq - } - return 0 -} - -func (x *TerminalInfo) GetOutputBytes() int64 { - if x != nil { - return x.OutputBytes - } - return 0 -} - -func (x *TerminalInfo) GetExitCode() int32 { - if x != nil { - return x.ExitCode - } - return 0 -} - -func (x *TerminalInfo) GetState() string { - if x != nil { - return x.State - } - return "" -} - -func (x *TerminalInfo) GetKillCause() string { - if x != nil { - return x.KillCause - } - return "" -} - -type TerminalFrame struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - StreamId string `protobuf:"bytes,2,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` - SessionId string `protobuf:"bytes,3,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - Kind string `protobuf:"bytes,4,opt,name=kind,proto3" json:"kind,omitempty"` - Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` - Command string `protobuf:"bytes,6,opt,name=command,proto3" json:"command,omitempty"` - Args []string `protobuf:"bytes,7,rep,name=args,proto3" json:"args,omitempty"` - Data []byte `protobuf:"bytes,8,opt,name=data,proto3" json:"data,omitempty"` - Cols int32 `protobuf:"varint,9,opt,name=cols,proto3" json:"cols,omitempty"` - Rows int32 `protobuf:"varint,10,opt,name=rows,proto3" json:"rows,omitempty"` - Bytes int32 `protobuf:"varint,11,opt,name=bytes,proto3" json:"bytes,omitempty"` - Offset int64 `protobuf:"varint,12,opt,name=offset,proto3" json:"offset,omitempty"` - Singleton bool `protobuf:"varint,13,opt,name=singleton,proto3" json:"singleton,omitempty"` - Error string `protobuf:"bytes,14,opt,name=error,proto3" json:"error,omitempty"` - State string `protobuf:"bytes,15,opt,name=state,proto3" json:"state,omitempty"` - ExitCode int32 `protobuf:"varint,16,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` - Session *TerminalInfo `protobuf:"bytes,17,opt,name=session,proto3" json:"session,omitempty"` - Sessions []*TerminalInfo `protobuf:"bytes,18,rep,name=sessions,proto3" json:"sessions,omitempty"` -} - -func (x *TerminalFrame) Reset() { - *x = TerminalFrame{} - if protoimpl.UnsafeEnabled { - mi := &file_aiscan_transport_terminal_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *TerminalFrame) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*TerminalFrame) ProtoMessage() {} - -func (x *TerminalFrame) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_transport_terminal_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use TerminalFrame.ProtoReflect.Descriptor instead. -func (*TerminalFrame) Descriptor() ([]byte, []int) { - return file_aiscan_transport_terminal_proto_rawDescGZIP(), []int{1} -} - -func (x *TerminalFrame) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *TerminalFrame) GetStreamId() string { - if x != nil { - return x.StreamId - } - return "" -} - -func (x *TerminalFrame) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - -func (x *TerminalFrame) GetKind() string { - if x != nil { - return x.Kind - } - return "" -} - -func (x *TerminalFrame) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *TerminalFrame) GetCommand() string { - if x != nil { - return x.Command - } - return "" -} - -func (x *TerminalFrame) GetArgs() []string { - if x != nil { - return x.Args - } - return nil -} - -func (x *TerminalFrame) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -func (x *TerminalFrame) GetCols() int32 { - if x != nil { - return x.Cols - } - return 0 -} - -func (x *TerminalFrame) GetRows() int32 { - if x != nil { - return x.Rows - } - return 0 -} - -func (x *TerminalFrame) GetBytes() int32 { - if x != nil { - return x.Bytes - } - return 0 -} - -func (x *TerminalFrame) GetOffset() int64 { - if x != nil { - return x.Offset - } - return 0 -} - -func (x *TerminalFrame) GetSingleton() bool { - if x != nil { - return x.Singleton - } - return false -} - -func (x *TerminalFrame) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -func (x *TerminalFrame) GetState() string { - if x != nil { - return x.State - } - return "" -} - -func (x *TerminalFrame) GetExitCode() int32 { - if x != nil { - return x.ExitCode - } - return 0 -} - -func (x *TerminalFrame) GetSession() *TerminalInfo { - if x != nil { - return x.Session - } - return nil -} - -func (x *TerminalFrame) GetSessions() []*TerminalInfo { - if x != nil { - return x.Sessions - } - return nil -} - -var File_aiscan_transport_terminal_proto protoreflect.FileDescriptor - -var file_aiscan_transport_terminal_proto_rawDesc = []byte{ - 0x0a, 0x1f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, - 0x6f, 0x72, 0x74, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc2, 0x03, 0x0a, 0x0c, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, - 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, - 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x65, 0x64, 0x41, 0x74, 0x12, 0x44, 0x0a, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x61, 0x63, 0x74, - 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x61, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0e, 0x6c, 0x61, 0x73, 0x74, - 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, - 0x64, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, - 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x73, 0x65, - 0x71, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, - 0x79, 0x53, 0x65, 0x71, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x62, - 0x79, 0x74, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6f, 0x75, 0x74, 0x70, - 0x75, 0x74, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, - 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, - 0x43, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x0c, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, 0x69, - 0x6c, 0x6c, 0x5f, 0x63, 0x61, 0x75, 0x73, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x6b, 0x69, 0x6c, 0x6c, 0x43, 0x61, 0x75, 0x73, 0x65, 0x22, 0xfc, 0x03, 0x0a, 0x0d, 0x54, 0x65, - 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, - 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, - 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, - 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, - 0x73, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x77, - 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x12, 0x14, 0x0a, - 0x05, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x62, 0x79, - 0x74, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x0c, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, - 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x74, 0x6f, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, - 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x74, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, - 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, - 0x64, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, - 0x64, 0x65, 0x12, 0x38, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x11, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x49, - 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x08, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, - 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, - 0x74, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, - 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, - 0x3b, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x6f, 0x72, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, -} - -var ( - file_aiscan_transport_terminal_proto_rawDescOnce sync.Once - file_aiscan_transport_terminal_proto_rawDescData = file_aiscan_transport_terminal_proto_rawDesc -) - -func file_aiscan_transport_terminal_proto_rawDescGZIP() []byte { - file_aiscan_transport_terminal_proto_rawDescOnce.Do(func() { - file_aiscan_transport_terminal_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_transport_terminal_proto_rawDescData) - }) - return file_aiscan_transport_terminal_proto_rawDescData -} - -var file_aiscan_transport_terminal_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_aiscan_transport_terminal_proto_goTypes = []interface{}{ - (*TerminalInfo)(nil), // 0: aiscan.transport.TerminalInfo - (*TerminalFrame)(nil), // 1: aiscan.transport.TerminalFrame - (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp -} -var file_aiscan_transport_terminal_proto_depIdxs = []int32{ - 2, // 0: aiscan.transport.TerminalInfo.started_at:type_name -> google.protobuf.Timestamp - 2, // 1: aiscan.transport.TerminalInfo.last_activity_at:type_name -> google.protobuf.Timestamp - 2, // 2: aiscan.transport.TerminalInfo.ended_at:type_name -> google.protobuf.Timestamp - 0, // 3: aiscan.transport.TerminalFrame.session:type_name -> aiscan.transport.TerminalInfo - 0, // 4: aiscan.transport.TerminalFrame.sessions:type_name -> aiscan.transport.TerminalInfo - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name -} - -func init() { file_aiscan_transport_terminal_proto_init() } -func file_aiscan_transport_terminal_proto_init() { - if File_aiscan_transport_terminal_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_aiscan_transport_terminal_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*TerminalInfo); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aiscan_transport_terminal_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*TerminalFrame); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_aiscan_transport_terminal_proto_rawDesc, - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_aiscan_transport_terminal_proto_goTypes, - DependencyIndexes: file_aiscan_transport_terminal_proto_depIdxs, - MessageInfos: file_aiscan_transport_terminal_proto_msgTypes, - }.Build() - File_aiscan_transport_terminal_proto = out.File - file_aiscan_transport_terminal_proto_rawDesc = nil - file_aiscan_transport_terminal_proto_goTypes = nil - file_aiscan_transport_terminal_proto_depIdxs = nil -} diff --git a/aop/aopconnect/chat.connect.go b/aop/aopconnect/chat.connect.go deleted file mode 100644 index 2f974be8..00000000 --- a/aop/aopconnect/chat.connect.go +++ /dev/null @@ -1,249 +0,0 @@ -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: aop/chat.proto - -package aopconnect - -import ( - connect "connectrpc.com/connect" - context "context" - errors "errors" - aop "github.com/chainreactors/aiscan/aop" - http "net/http" - strings "strings" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // ChatServiceName is the fully-qualified name of the ChatService service. - ChatServiceName = "aop.ChatService" -) - -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. -// -// Note that these are different from the fully-qualified method names used by -// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to -// reflection-formatted method names, remove the leading slash and convert the remaining slash to a -// period. -const ( - // ChatServiceOpenSessionProcedure is the fully-qualified name of the ChatService's OpenSession RPC. - ChatServiceOpenSessionProcedure = "/aop.ChatService/OpenSession" - // ChatServiceRunTurnProcedure is the fully-qualified name of the ChatService's RunTurn RPC. - ChatServiceRunTurnProcedure = "/aop.ChatService/RunTurn" - // ChatServiceCancelTurnProcedure is the fully-qualified name of the ChatService's CancelTurn RPC. - ChatServiceCancelTurnProcedure = "/aop.ChatService/CancelTurn" - // ChatServiceCloseSessionProcedure is the fully-qualified name of the ChatService's CloseSession - // RPC. - ChatServiceCloseSessionProcedure = "/aop.ChatService/CloseSession" - // ChatServiceWatchEventsProcedure is the fully-qualified name of the ChatService's WatchEvents RPC. - ChatServiceWatchEventsProcedure = "/aop.ChatService/WatchEvents" - // ChatServiceListEventsProcedure is the fully-qualified name of the ChatService's ListEvents RPC. - ChatServiceListEventsProcedure = "/aop.ChatService/ListEvents" -) - -// ChatServiceClient is a client for the aop.ChatService service. -type ChatServiceClient interface { - OpenSession(context.Context, *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) - RunTurn(context.Context, *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) - CancelTurn(context.Context, *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) - CloseSession(context.Context, *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) - WatchEvents(context.Context, *connect.Request[aop.WatchEventsRequest]) (*connect.ServerStreamForClient[aop.WatchEventsResponse], error) - ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) -} - -// NewChatServiceClient constructs a client for the aop.ChatService service. By default, it uses the -// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends -// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or -// connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewChatServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ChatServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - chatServiceMethods := aop.File_aop_chat_proto.Services().ByName("ChatService").Methods() - return &chatServiceClient{ - openSession: connect.NewClient[aop.OpenSessionRequest, aop.OpenSessionResponse]( - httpClient, - baseURL+ChatServiceOpenSessionProcedure, - connect.WithSchema(chatServiceMethods.ByName("OpenSession")), - connect.WithClientOptions(opts...), - ), - runTurn: connect.NewClient[aop.RunTurnRequest, aop.RunTurnResponse]( - httpClient, - baseURL+ChatServiceRunTurnProcedure, - connect.WithSchema(chatServiceMethods.ByName("RunTurn")), - connect.WithClientOptions(opts...), - ), - cancelTurn: connect.NewClient[aop.CancelTurnRequest, aop.CancelTurnResponse]( - httpClient, - baseURL+ChatServiceCancelTurnProcedure, - connect.WithSchema(chatServiceMethods.ByName("CancelTurn")), - connect.WithClientOptions(opts...), - ), - closeSession: connect.NewClient[aop.CloseSessionRequest, aop.CloseSessionResponse]( - httpClient, - baseURL+ChatServiceCloseSessionProcedure, - connect.WithSchema(chatServiceMethods.ByName("CloseSession")), - connect.WithClientOptions(opts...), - ), - watchEvents: connect.NewClient[aop.WatchEventsRequest, aop.WatchEventsResponse]( - httpClient, - baseURL+ChatServiceWatchEventsProcedure, - connect.WithSchema(chatServiceMethods.ByName("WatchEvents")), - connect.WithClientOptions(opts...), - ), - listEvents: connect.NewClient[aop.ListEventsRequest, aop.ListEventsResponse]( - httpClient, - baseURL+ChatServiceListEventsProcedure, - connect.WithSchema(chatServiceMethods.ByName("ListEvents")), - connect.WithClientOptions(opts...), - ), - } -} - -// chatServiceClient implements ChatServiceClient. -type chatServiceClient struct { - openSession *connect.Client[aop.OpenSessionRequest, aop.OpenSessionResponse] - runTurn *connect.Client[aop.RunTurnRequest, aop.RunTurnResponse] - cancelTurn *connect.Client[aop.CancelTurnRequest, aop.CancelTurnResponse] - closeSession *connect.Client[aop.CloseSessionRequest, aop.CloseSessionResponse] - watchEvents *connect.Client[aop.WatchEventsRequest, aop.WatchEventsResponse] - listEvents *connect.Client[aop.ListEventsRequest, aop.ListEventsResponse] -} - -// OpenSession calls aop.ChatService.OpenSession. -func (c *chatServiceClient) OpenSession(ctx context.Context, req *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { - return c.openSession.CallUnary(ctx, req) -} - -// RunTurn calls aop.ChatService.RunTurn. -func (c *chatServiceClient) RunTurn(ctx context.Context, req *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { - return c.runTurn.CallUnary(ctx, req) -} - -// CancelTurn calls aop.ChatService.CancelTurn. -func (c *chatServiceClient) CancelTurn(ctx context.Context, req *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) { - return c.cancelTurn.CallUnary(ctx, req) -} - -// CloseSession calls aop.ChatService.CloseSession. -func (c *chatServiceClient) CloseSession(ctx context.Context, req *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) { - return c.closeSession.CallUnary(ctx, req) -} - -// WatchEvents calls aop.ChatService.WatchEvents. -func (c *chatServiceClient) WatchEvents(ctx context.Context, req *connect.Request[aop.WatchEventsRequest]) (*connect.ServerStreamForClient[aop.WatchEventsResponse], error) { - return c.watchEvents.CallServerStream(ctx, req) -} - -// ListEvents calls aop.ChatService.ListEvents. -func (c *chatServiceClient) ListEvents(ctx context.Context, req *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { - return c.listEvents.CallUnary(ctx, req) -} - -// ChatServiceHandler is an implementation of the aop.ChatService service. -type ChatServiceHandler interface { - OpenSession(context.Context, *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) - RunTurn(context.Context, *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) - CancelTurn(context.Context, *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) - CloseSession(context.Context, *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) - WatchEvents(context.Context, *connect.Request[aop.WatchEventsRequest], *connect.ServerStream[aop.WatchEventsResponse]) error - ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) -} - -// NewChatServiceHandler builds an HTTP handler from the service implementation. It returns the path -// on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewChatServiceHandler(svc ChatServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - chatServiceMethods := aop.File_aop_chat_proto.Services().ByName("ChatService").Methods() - chatServiceOpenSessionHandler := connect.NewUnaryHandler( - ChatServiceOpenSessionProcedure, - svc.OpenSession, - connect.WithSchema(chatServiceMethods.ByName("OpenSession")), - connect.WithHandlerOptions(opts...), - ) - chatServiceRunTurnHandler := connect.NewUnaryHandler( - ChatServiceRunTurnProcedure, - svc.RunTurn, - connect.WithSchema(chatServiceMethods.ByName("RunTurn")), - connect.WithHandlerOptions(opts...), - ) - chatServiceCancelTurnHandler := connect.NewUnaryHandler( - ChatServiceCancelTurnProcedure, - svc.CancelTurn, - connect.WithSchema(chatServiceMethods.ByName("CancelTurn")), - connect.WithHandlerOptions(opts...), - ) - chatServiceCloseSessionHandler := connect.NewUnaryHandler( - ChatServiceCloseSessionProcedure, - svc.CloseSession, - connect.WithSchema(chatServiceMethods.ByName("CloseSession")), - connect.WithHandlerOptions(opts...), - ) - chatServiceWatchEventsHandler := connect.NewServerStreamHandler( - ChatServiceWatchEventsProcedure, - svc.WatchEvents, - connect.WithSchema(chatServiceMethods.ByName("WatchEvents")), - connect.WithHandlerOptions(opts...), - ) - chatServiceListEventsHandler := connect.NewUnaryHandler( - ChatServiceListEventsProcedure, - svc.ListEvents, - connect.WithSchema(chatServiceMethods.ByName("ListEvents")), - connect.WithHandlerOptions(opts...), - ) - return "/aop.ChatService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case ChatServiceOpenSessionProcedure: - chatServiceOpenSessionHandler.ServeHTTP(w, r) - case ChatServiceRunTurnProcedure: - chatServiceRunTurnHandler.ServeHTTP(w, r) - case ChatServiceCancelTurnProcedure: - chatServiceCancelTurnHandler.ServeHTTP(w, r) - case ChatServiceCloseSessionProcedure: - chatServiceCloseSessionHandler.ServeHTTP(w, r) - case ChatServiceWatchEventsProcedure: - chatServiceWatchEventsHandler.ServeHTTP(w, r) - case ChatServiceListEventsProcedure: - chatServiceListEventsHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedChatServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedChatServiceHandler struct{} - -func (UnimplementedChatServiceHandler) OpenSession(context.Context, *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.OpenSession is not implemented")) -} - -func (UnimplementedChatServiceHandler) RunTurn(context.Context, *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.RunTurn is not implemented")) -} - -func (UnimplementedChatServiceHandler) CancelTurn(context.Context, *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.CancelTurn is not implemented")) -} - -func (UnimplementedChatServiceHandler) CloseSession(context.Context, *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.CloseSession is not implemented")) -} - -func (UnimplementedChatServiceHandler) WatchEvents(context.Context, *connect.Request[aop.WatchEventsRequest], *connect.ServerStream[aop.WatchEventsResponse]) error { - return connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.WatchEvents is not implemented")) -} - -func (UnimplementedChatServiceHandler) ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aop.ChatService.ListEvents is not implemented")) -} diff --git a/aop/chat.pb.go b/aop/chat.pb.go index 6ce2eef8..cb5a5d34 100644 --- a/aop/chat.pb.go +++ b/aop/chat.pb.go @@ -9,6 +9,7 @@ package aop import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + anypb "google.golang.org/protobuf/types/known/anypb" reflect "reflect" sync "sync" ) @@ -25,10 +26,9 @@ type Rejection struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` - Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` } func (x *Rejection) Reset() { @@ -84,22 +84,15 @@ func (x *Rejection) GetRetryable() bool { return false } -func (x *Rejection) GetDetail() *EncodedValue { - if x != nil { - return x.Detail - } - return nil -} - type Session struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` - Participant string `protobuf:"bytes,3,opt,name=participant,proto3" json:"participant,omitempty"` - Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + NodeUri string `protobuf:"bytes,3,opt,name=node_uri,json=nodeUri,proto3" json:"node_uri,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` } func (x *Session) Reset() { @@ -148,9 +141,9 @@ func (x *Session) GetState() string { return "" } -func (x *Session) GetParticipant() string { +func (x *Session) GetNodeUri() string { if x != nil { - return x.Participant + return x.NodeUri } return "" } @@ -167,13 +160,12 @@ type OpenSessionRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - Participant string `protobuf:"bytes,3,opt,name=participant,proto3" json:"participant,omitempty"` + NodeUri string `protobuf:"bytes,3,opt,name=node_uri,json=nodeUri,proto3" json:"node_uri,omitempty"` Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` ParentSessionId string `protobuf:"bytes,5,opt,name=parent_session_id,json=parentSessionId,proto3" json:"parent_session_id,omitempty"` ParentToolCallId string `protobuf:"bytes,6,opt,name=parent_tool_call_id,json=parentToolCallId,proto3" json:"parent_tool_call_id,omitempty"` - Extensions []*Extension `protobuf:"bytes,7,rep,name=extensions,proto3" json:"extensions,omitempty"` + Extensions []*anypb.Any `protobuf:"bytes,8,rep,name=extensions,proto3" json:"extensions,omitempty"` } func (x *OpenSessionRequest) Reset() { @@ -208,13 +200,6 @@ func (*OpenSessionRequest) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{2} } -func (x *OpenSessionRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (x *OpenSessionRequest) GetSessionId() string { if x != nil { return x.SessionId @@ -222,9 +207,9 @@ func (x *OpenSessionRequest) GetSessionId() string { return "" } -func (x *OpenSessionRequest) GetParticipant() string { +func (x *OpenSessionRequest) GetNodeUri() string { if x != nil { - return x.Participant + return x.NodeUri } return "" } @@ -250,7 +235,7 @@ func (x *OpenSessionRequest) GetParentToolCallId() string { return "" } -func (x *OpenSessionRequest) GetExtensions() []*Extension { +func (x *OpenSessionRequest) GetExtensions() []*anypb.Any { if x != nil { return x.Extensions } @@ -262,7 +247,6 @@ type OpenSessionResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // Types that are assignable to Outcome: // // *OpenSessionResponse_Accepted @@ -302,13 +286,6 @@ func (*OpenSessionResponse) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{3} } -func (x *OpenSessionResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (m *OpenSessionResponse) GetOutcome() isOpenSessionResponse_Outcome { if m != nil { return m.Outcome @@ -351,13 +328,12 @@ type RunTurnRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` Input *Message `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` ContinueSession bool `protobuf:"varint,5,opt,name=continue_session,json=continueSession,proto3" json:"continue_session,omitempty"` MaxTurns uint32 `protobuf:"varint,6,opt,name=max_turns,json=maxTurns,proto3" json:"max_turns,omitempty"` - Extensions []*Extension `protobuf:"bytes,7,rep,name=extensions,proto3" json:"extensions,omitempty"` + Extensions []*anypb.Any `protobuf:"bytes,8,rep,name=extensions,proto3" json:"extensions,omitempty"` } func (x *RunTurnRequest) Reset() { @@ -392,13 +368,6 @@ func (*RunTurnRequest) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{4} } -func (x *RunTurnRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (x *RunTurnRequest) GetSessionId() string { if x != nil { return x.SessionId @@ -434,7 +403,7 @@ func (x *RunTurnRequest) GetMaxTurns() uint32 { return 0 } -func (x *RunTurnRequest) GetExtensions() []*Extension { +func (x *RunTurnRequest) GetExtensions() []*anypb.Any { if x != nil { return x.Extensions } @@ -509,7 +478,6 @@ type RunTurnResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // Types that are assignable to Outcome: // // *RunTurnResponse_Accepted @@ -549,13 +517,6 @@ func (*RunTurnResponse) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{6} } -func (x *RunTurnResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (m *RunTurnResponse) GetOutcome() isRunTurnResponse_Outcome { if m != nil { return m.Outcome @@ -598,7 +559,6 @@ type CancelTurnRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` TurnId string `protobuf:"bytes,3,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` @@ -636,13 +596,6 @@ func (*CancelTurnRequest) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{7} } -func (x *CancelTurnRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (x *CancelTurnRequest) GetSessionId() string { if x != nil { return x.SessionId @@ -669,7 +622,6 @@ type CancelTurnResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // Types that are assignable to Outcome: // // *CancelTurnResponse_Accepted @@ -709,13 +661,6 @@ func (*CancelTurnResponse) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{8} } -func (x *CancelTurnResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (m *CancelTurnResponse) GetOutcome() isCancelTurnResponse_Outcome { if m != nil { return m.Outcome @@ -758,7 +703,6 @@ type CloseSessionRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` } @@ -795,13 +739,6 @@ func (*CloseSessionRequest) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{9} } -func (x *CloseSessionRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (x *CloseSessionRequest) GetSessionId() string { if x != nil { return x.SessionId @@ -821,7 +758,6 @@ type CloseSessionResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` // Types that are assignable to Outcome: // // *CloseSessionResponse_Accepted @@ -861,13 +797,6 @@ func (*CloseSessionResponse) Descriptor() ([]byte, []int) { return file_aop_chat_proto_rawDescGZIP(), []int{10} } -func (x *CloseSessionResponse) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - func (m *CloseSessionResponse) GetOutcome() isCloseSessionResponse_Outcome { if m != nil { return m.Outcome @@ -1015,53 +944,6 @@ func (x *EventDelivery) GetEvent() *Event { return nil } -type WatchEventsResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Delivery *EventDelivery `protobuf:"bytes,1,opt,name=delivery,proto3" json:"delivery,omitempty"` -} - -func (x *WatchEventsResponse) Reset() { - *x = WatchEventsResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_aop_chat_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *WatchEventsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WatchEventsResponse) ProtoMessage() {} - -func (x *WatchEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_aop_chat_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WatchEventsResponse.ProtoReflect.Descriptor instead. -func (*WatchEventsResponse) Descriptor() ([]byte, []int) { - return file_aop_chat_proto_rawDescGZIP(), []int{13} -} - -func (x *WatchEventsResponse) GetDelivery() *EventDelivery { - if x != nil { - return x.Delivery - } - return nil -} - type ListEventsRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1075,7 +957,7 @@ type ListEventsRequest struct { func (x *ListEventsRequest) Reset() { *x = ListEventsRequest{} if protoimpl.UnsafeEnabled { - mi := &file_aop_chat_proto_msgTypes[14] + mi := &file_aop_chat_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1088,7 +970,7 @@ func (x *ListEventsRequest) String() string { func (*ListEventsRequest) ProtoMessage() {} func (x *ListEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_aop_chat_proto_msgTypes[14] + mi := &file_aop_chat_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1101,7 +983,7 @@ func (x *ListEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListEventsRequest.ProtoReflect.Descriptor instead. func (*ListEventsRequest) Descriptor() ([]byte, []int) { - return file_aop_chat_proto_rawDescGZIP(), []int{14} + return file_aop_chat_proto_rawDescGZIP(), []int{13} } func (x *ListEventsRequest) GetSessionId() string { @@ -1137,7 +1019,7 @@ type ListEventsResponse struct { func (x *ListEventsResponse) Reset() { *x = ListEventsResponse{} if protoimpl.UnsafeEnabled { - mi := &file_aop_chat_proto_msgTypes[15] + mi := &file_aop_chat_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1150,7 +1032,7 @@ func (x *ListEventsResponse) String() string { func (*ListEventsResponse) ProtoMessage() {} func (x *ListEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_aop_chat_proto_msgTypes[15] + mi := &file_aop_chat_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1163,7 +1045,7 @@ func (x *ListEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListEventsResponse.ProtoReflect.Descriptor instead. func (*ListEventsResponse) Descriptor() ([]byte, []int) { - return file_aop_chat_proto_rawDescGZIP(), []int{15} + return file_aop_chat_proto_rawDescGZIP(), []int{14} } func (x *ListEventsResponse) GetEvents() []*EventDelivery { @@ -1186,170 +1068,129 @@ var file_aop_chat_proto_rawDesc = []byte{ 0x0a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x6f, 0x70, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x82, 0x01, 0x0a, 0x09, 0x52, - 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, - 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, - 0x61, 0x62, 0x6c, 0x65, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, - 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x22, - 0x67, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, - 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, - 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x22, 0x95, 0x02, 0x0a, 0x12, 0x4f, 0x70, 0x65, - 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, - 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x20, 0x0a, - 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x12, + 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x5d, 0x0a, 0x09, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x4a, 0x04, 0x08, + 0x04, 0x10, 0x05, 0x22, 0x60, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, + 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, + 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x75, 0x72, 0x69, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x6f, 0x64, 0x65, 0x55, 0x72, 0x69, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, - 0x64, 0x12, 0x2d, 0x0a, 0x13, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, - 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, - 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, - 0x12, 0x2e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x07, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x22, 0x99, 0x01, 0x0a, 0x13, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, - 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, - 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, - 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x83, 0x02, 0x0a, + 0x74, 0x69, 0x74, 0x6c, 0x65, 0x22, 0x81, 0x02, 0x0a, 0x12, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6e, + 0x6f, 0x64, 0x65, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, + 0x6f, 0x64, 0x65, 0x55, 0x72, 0x69, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x2a, 0x0a, 0x11, + 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x13, 0x70, 0x61, 0x72, 0x65, + 0x6e, 0x74, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x6f, + 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x12, 0x34, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, + 0x79, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x4a, 0x04, 0x08, + 0x01, 0x10, 0x02, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x22, 0x80, 0x01, 0x0a, 0x13, 0x4f, 0x70, + 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, + 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, + 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, + 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x22, 0xf6, 0x01, 0x0a, 0x0e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, - 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, - 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x6f, - 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x75, 0x72, - 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x54, 0x75, 0x72, - 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x78, 0x74, - 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, - 0x6e, 0x73, 0x22, 0x5b, 0x0a, 0x0b, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, - 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, - 0x12, 0x17, 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, - 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, - 0x99, 0x01, 0x0a, 0x0f, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x75, 0x72, 0x6e, 0x52, - 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, - 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, - 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x82, 0x01, 0x0a, 0x11, - 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, - 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, - 0x17, 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, - 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x22, 0x9c, 0x01, 0x0a, 0x12, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, + 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x63, + 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x75, + 0x72, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x54, 0x75, + 0x72, 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x0a, 0x65, + 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x4a, + 0x04, 0x08, 0x07, 0x10, 0x08, 0x22, 0x5b, 0x0a, 0x0b, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x63, + 0x65, 0x69, 0x70, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x22, 0x80, 0x01, 0x0a, 0x0f, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, - 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, - 0x6b, 0x0a, 0x13, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x9a, 0x01, 0x0a, - 0x14, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, - 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, - 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x56, 0x0a, 0x12, 0x57, 0x61, 0x74, - 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x21, - 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, - 0x72, 0x22, 0x49, 0x0a, 0x0d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, - 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x20, 0x0a, 0x05, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, 0x6f, 0x70, 0x2e, - 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x45, 0x0a, 0x13, - 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x08, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, 0x6e, - 0x74, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x52, 0x08, 0x64, 0x65, 0x6c, 0x69, 0x76, - 0x65, 0x72, 0x79, 0x22, 0x6b, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, - 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, - 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, - 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, - 0x22, 0x61, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, - 0x6e, 0x74, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e, - 0x74, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, - 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, - 0x73, 0x6f, 0x72, 0x32, 0x8c, 0x03, 0x0a, 0x0b, 0x43, 0x68, 0x61, 0x74, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x6f, - 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, - 0x12, 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, - 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x43, - 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, - 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, - 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x6c, - 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x2e, 0x61, 0x6f, 0x70, - 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x42, 0x0a, 0x0b, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x17, - 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x57, 0x61, - 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x30, 0x01, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x73, 0x12, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, - 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, - 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x4a, + 0x04, 0x08, 0x01, 0x10, 0x02, 0x22, 0x69, 0x0a, 0x11, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, + 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x75, 0x72, + 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, + 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, + 0x22, 0x83, 0x01, 0x0a, 0x12, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x48, 0x00, 0x52, 0x08, 0x61, + 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, + 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, + 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, + 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x22, 0x52, 0x0a, 0x13, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, + 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, + 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x22, 0x81, 0x01, 0x0a, 0x14, 0x43, + 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, + 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, + 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x22, 0x56, + 0x0a, 0x12, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x63, 0x75, 0x72, + 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x66, 0x74, 0x65, 0x72, + 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0x49, 0x0a, 0x0d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x44, + 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, + 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, + 0x20, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x22, 0x6b, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x63, + 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x66, 0x74, + 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x61, + 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, 0x73, 0x6f, + 0x72, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1364,7 +1205,7 @@ func file_aop_chat_proto_rawDescGZIP() []byte { return file_aop_chat_proto_rawDescData } -var file_aop_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_aop_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_aop_chat_proto_goTypes = []interface{}{ (*Rejection)(nil), // 0: aop.Rejection (*Session)(nil), // 1: aop.Session @@ -1379,47 +1220,31 @@ var file_aop_chat_proto_goTypes = []interface{}{ (*CloseSessionResponse)(nil), // 10: aop.CloseSessionResponse (*WatchEventsRequest)(nil), // 11: aop.WatchEventsRequest (*EventDelivery)(nil), // 12: aop.EventDelivery - (*WatchEventsResponse)(nil), // 13: aop.WatchEventsResponse - (*ListEventsRequest)(nil), // 14: aop.ListEventsRequest - (*ListEventsResponse)(nil), // 15: aop.ListEventsResponse - (*EncodedValue)(nil), // 16: aop.EncodedValue - (*Extension)(nil), // 17: aop.Extension - (*Message)(nil), // 18: aop.Message - (*Event)(nil), // 19: aop.Event + (*ListEventsRequest)(nil), // 13: aop.ListEventsRequest + (*ListEventsResponse)(nil), // 14: aop.ListEventsResponse + (*anypb.Any)(nil), // 15: google.protobuf.Any + (*Message)(nil), // 16: aop.Message + (*Event)(nil), // 17: aop.Event } var file_aop_chat_proto_depIdxs = []int32{ - 16, // 0: aop.Rejection.detail:type_name -> aop.EncodedValue - 17, // 1: aop.OpenSessionRequest.extensions:type_name -> aop.Extension - 1, // 2: aop.OpenSessionResponse.accepted:type_name -> aop.Session - 0, // 3: aop.OpenSessionResponse.rejected:type_name -> aop.Rejection - 18, // 4: aop.RunTurnRequest.input:type_name -> aop.Message - 17, // 5: aop.RunTurnRequest.extensions:type_name -> aop.Extension - 5, // 6: aop.RunTurnResponse.accepted:type_name -> aop.TurnReceipt - 0, // 7: aop.RunTurnResponse.rejected:type_name -> aop.Rejection - 5, // 8: aop.CancelTurnResponse.accepted:type_name -> aop.TurnReceipt - 0, // 9: aop.CancelTurnResponse.rejected:type_name -> aop.Rejection - 1, // 10: aop.CloseSessionResponse.accepted:type_name -> aop.Session - 0, // 11: aop.CloseSessionResponse.rejected:type_name -> aop.Rejection - 19, // 12: aop.EventDelivery.event:type_name -> aop.Event - 12, // 13: aop.WatchEventsResponse.delivery:type_name -> aop.EventDelivery - 12, // 14: aop.ListEventsResponse.events:type_name -> aop.EventDelivery - 2, // 15: aop.ChatService.OpenSession:input_type -> aop.OpenSessionRequest - 4, // 16: aop.ChatService.RunTurn:input_type -> aop.RunTurnRequest - 7, // 17: aop.ChatService.CancelTurn:input_type -> aop.CancelTurnRequest - 9, // 18: aop.ChatService.CloseSession:input_type -> aop.CloseSessionRequest - 11, // 19: aop.ChatService.WatchEvents:input_type -> aop.WatchEventsRequest - 14, // 20: aop.ChatService.ListEvents:input_type -> aop.ListEventsRequest - 3, // 21: aop.ChatService.OpenSession:output_type -> aop.OpenSessionResponse - 6, // 22: aop.ChatService.RunTurn:output_type -> aop.RunTurnResponse - 8, // 23: aop.ChatService.CancelTurn:output_type -> aop.CancelTurnResponse - 10, // 24: aop.ChatService.CloseSession:output_type -> aop.CloseSessionResponse - 13, // 25: aop.ChatService.WatchEvents:output_type -> aop.WatchEventsResponse - 15, // 26: aop.ChatService.ListEvents:output_type -> aop.ListEventsResponse - 21, // [21:27] is the sub-list for method output_type - 15, // [15:21] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 15, // 0: aop.OpenSessionRequest.extensions:type_name -> google.protobuf.Any + 1, // 1: aop.OpenSessionResponse.accepted:type_name -> aop.Session + 0, // 2: aop.OpenSessionResponse.rejected:type_name -> aop.Rejection + 16, // 3: aop.RunTurnRequest.input:type_name -> aop.Message + 15, // 4: aop.RunTurnRequest.extensions:type_name -> google.protobuf.Any + 5, // 5: aop.RunTurnResponse.accepted:type_name -> aop.TurnReceipt + 0, // 6: aop.RunTurnResponse.rejected:type_name -> aop.Rejection + 5, // 7: aop.CancelTurnResponse.accepted:type_name -> aop.TurnReceipt + 0, // 8: aop.CancelTurnResponse.rejected:type_name -> aop.Rejection + 1, // 9: aop.CloseSessionResponse.accepted:type_name -> aop.Session + 0, // 10: aop.CloseSessionResponse.rejected:type_name -> aop.Rejection + 17, // 11: aop.EventDelivery.event:type_name -> aop.Event + 12, // 12: aop.ListEventsResponse.events:type_name -> aop.EventDelivery + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_aop_chat_proto_init() } @@ -1429,7 +1254,6 @@ func file_aop_chat_proto_init() { } file_aop_content_proto_init() file_aop_event_proto_init() - file_aop_value_proto_init() if !protoimpl.UnsafeEnabled { file_aop_chat_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Rejection); i { @@ -1588,18 +1412,6 @@ func file_aop_chat_proto_init() { } } file_aop_chat_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*WatchEventsResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aop_chat_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ListEventsRequest); i { case 0: return &v.state @@ -1611,7 +1423,7 @@ func file_aop_chat_proto_init() { return nil } } - file_aop_chat_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + file_aop_chat_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ListEventsResponse); i { case 0: return &v.state @@ -1646,9 +1458,9 @@ func file_aop_chat_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_aop_chat_proto_rawDesc, NumEnums: 0, - NumMessages: 16, + NumMessages: 15, NumExtensions: 0, - NumServices: 1, + NumServices: 0, }, GoTypes: file_aop_chat_proto_goTypes, DependencyIndexes: file_aop_chat_proto_depIdxs, diff --git a/aop/chat_grpc.pb.go b/aop/chat_grpc.pb.go deleted file mode 100644 index c738e370..00000000 --- a/aop/chat_grpc.pb.go +++ /dev/null @@ -1,322 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.3.0 -// - protoc v6.33.0 -// source: aop/chat.proto - -package aop - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 - -const ( - ChatService_OpenSession_FullMethodName = "/aop.ChatService/OpenSession" - ChatService_RunTurn_FullMethodName = "/aop.ChatService/RunTurn" - ChatService_CancelTurn_FullMethodName = "/aop.ChatService/CancelTurn" - ChatService_CloseSession_FullMethodName = "/aop.ChatService/CloseSession" - ChatService_WatchEvents_FullMethodName = "/aop.ChatService/WatchEvents" - ChatService_ListEvents_FullMethodName = "/aop.ChatService/ListEvents" -) - -// ChatServiceClient is the client API for ChatService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type ChatServiceClient interface { - OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionResponse, error) - RunTurn(ctx context.Context, in *RunTurnRequest, opts ...grpc.CallOption) (*RunTurnResponse, error) - CancelTurn(ctx context.Context, in *CancelTurnRequest, opts ...grpc.CallOption) (*CancelTurnResponse, error) - CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error) - WatchEvents(ctx context.Context, in *WatchEventsRequest, opts ...grpc.CallOption) (ChatService_WatchEventsClient, error) - ListEvents(ctx context.Context, in *ListEventsRequest, opts ...grpc.CallOption) (*ListEventsResponse, error) -} - -type chatServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewChatServiceClient(cc grpc.ClientConnInterface) ChatServiceClient { - return &chatServiceClient{cc} -} - -func (c *chatServiceClient) OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionResponse, error) { - out := new(OpenSessionResponse) - err := c.cc.Invoke(ctx, ChatService_OpenSession_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *chatServiceClient) RunTurn(ctx context.Context, in *RunTurnRequest, opts ...grpc.CallOption) (*RunTurnResponse, error) { - out := new(RunTurnResponse) - err := c.cc.Invoke(ctx, ChatService_RunTurn_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *chatServiceClient) CancelTurn(ctx context.Context, in *CancelTurnRequest, opts ...grpc.CallOption) (*CancelTurnResponse, error) { - out := new(CancelTurnResponse) - err := c.cc.Invoke(ctx, ChatService_CancelTurn_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *chatServiceClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error) { - out := new(CloseSessionResponse) - err := c.cc.Invoke(ctx, ChatService_CloseSession_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *chatServiceClient) WatchEvents(ctx context.Context, in *WatchEventsRequest, opts ...grpc.CallOption) (ChatService_WatchEventsClient, error) { - stream, err := c.cc.NewStream(ctx, &ChatService_ServiceDesc.Streams[0], ChatService_WatchEvents_FullMethodName, opts...) - if err != nil { - return nil, err - } - x := &chatServiceWatchEventsClient{stream} - if err := x.ClientStream.SendMsg(in); err != nil { - return nil, err - } - if err := x.ClientStream.CloseSend(); err != nil { - return nil, err - } - return x, nil -} - -type ChatService_WatchEventsClient interface { - Recv() (*WatchEventsResponse, error) - grpc.ClientStream -} - -type chatServiceWatchEventsClient struct { - grpc.ClientStream -} - -func (x *chatServiceWatchEventsClient) Recv() (*WatchEventsResponse, error) { - m := new(WatchEventsResponse) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -func (c *chatServiceClient) ListEvents(ctx context.Context, in *ListEventsRequest, opts ...grpc.CallOption) (*ListEventsResponse, error) { - out := new(ListEventsResponse) - err := c.cc.Invoke(ctx, ChatService_ListEvents_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// ChatServiceServer is the server API for ChatService service. -// All implementations must embed UnimplementedChatServiceServer -// for forward compatibility -type ChatServiceServer interface { - OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionResponse, error) - RunTurn(context.Context, *RunTurnRequest) (*RunTurnResponse, error) - CancelTurn(context.Context, *CancelTurnRequest) (*CancelTurnResponse, error) - CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error) - WatchEvents(*WatchEventsRequest, ChatService_WatchEventsServer) error - ListEvents(context.Context, *ListEventsRequest) (*ListEventsResponse, error) - mustEmbedUnimplementedChatServiceServer() -} - -// UnimplementedChatServiceServer must be embedded to have forward compatible implementations. -type UnimplementedChatServiceServer struct { -} - -func (UnimplementedChatServiceServer) OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method OpenSession not implemented") -} -func (UnimplementedChatServiceServer) RunTurn(context.Context, *RunTurnRequest) (*RunTurnResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RunTurn not implemented") -} -func (UnimplementedChatServiceServer) CancelTurn(context.Context, *CancelTurnRequest) (*CancelTurnResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CancelTurn not implemented") -} -func (UnimplementedChatServiceServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CloseSession not implemented") -} -func (UnimplementedChatServiceServer) WatchEvents(*WatchEventsRequest, ChatService_WatchEventsServer) error { - return status.Errorf(codes.Unimplemented, "method WatchEvents not implemented") -} -func (UnimplementedChatServiceServer) ListEvents(context.Context, *ListEventsRequest) (*ListEventsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListEvents not implemented") -} -func (UnimplementedChatServiceServer) mustEmbedUnimplementedChatServiceServer() {} - -// UnsafeChatServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ChatServiceServer will -// result in compilation errors. -type UnsafeChatServiceServer interface { - mustEmbedUnimplementedChatServiceServer() -} - -func RegisterChatServiceServer(s grpc.ServiceRegistrar, srv ChatServiceServer) { - s.RegisterService(&ChatService_ServiceDesc, srv) -} - -func _ChatService_OpenSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OpenSessionRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ChatServiceServer).OpenSession(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ChatService_OpenSession_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ChatServiceServer).OpenSession(ctx, req.(*OpenSessionRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ChatService_RunTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RunTurnRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ChatServiceServer).RunTurn(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ChatService_RunTurn_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ChatServiceServer).RunTurn(ctx, req.(*RunTurnRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ChatService_CancelTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CancelTurnRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ChatServiceServer).CancelTurn(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ChatService_CancelTurn_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ChatServiceServer).CancelTurn(ctx, req.(*CancelTurnRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ChatService_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CloseSessionRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ChatServiceServer).CloseSession(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ChatService_CloseSession_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ChatServiceServer).CloseSession(ctx, req.(*CloseSessionRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ChatService_WatchEvents_Handler(srv interface{}, stream grpc.ServerStream) error { - m := new(WatchEventsRequest) - if err := stream.RecvMsg(m); err != nil { - return err - } - return srv.(ChatServiceServer).WatchEvents(m, &chatServiceWatchEventsServer{stream}) -} - -type ChatService_WatchEventsServer interface { - Send(*WatchEventsResponse) error - grpc.ServerStream -} - -type chatServiceWatchEventsServer struct { - grpc.ServerStream -} - -func (x *chatServiceWatchEventsServer) Send(m *WatchEventsResponse) error { - return x.ServerStream.SendMsg(m) -} - -func _ChatService_ListEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListEventsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ChatServiceServer).ListEvents(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ChatService_ListEvents_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ChatServiceServer).ListEvents(ctx, req.(*ListEventsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// ChatService_ServiceDesc is the grpc.ServiceDesc for ChatService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ChatService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "aop.ChatService", - HandlerType: (*ChatServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "OpenSession", - Handler: _ChatService_OpenSession_Handler, - }, - { - MethodName: "RunTurn", - Handler: _ChatService_RunTurn_Handler, - }, - { - MethodName: "CancelTurn", - Handler: _ChatService_CancelTurn_Handler, - }, - { - MethodName: "CloseSession", - Handler: _ChatService_CloseSession_Handler, - }, - { - MethodName: "ListEvents", - Handler: _ChatService_ListEvents_Handler, - }, - }, - Streams: []grpc.StreamDesc{ - { - StreamName: "WatchEvents", - Handler: _ChatService_WatchEvents_Handler, - ServerStreams: true, - }, - }, - Metadata: "aop/chat.proto", -} diff --git a/aop/content.pb.go b/aop/content.pb.go index 3d83172c..279590a8 100644 --- a/aop/content.pb.go +++ b/aop/content.pb.go @@ -122,12 +122,11 @@ type Annotation struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - Start uint64 `protobuf:"varint,2,opt,name=start,proto3" json:"start,omitempty"` - End uint64 `protobuf:"varint,3,opt,name=end,proto3" json:"end,omitempty"` - Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` - Uri string `protobuf:"bytes,5,opt,name=uri,proto3" json:"uri,omitempty"` - Detail *EncodedValue `protobuf:"bytes,6,opt,name=detail,proto3" json:"detail,omitempty"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Start uint64 `protobuf:"varint,2,opt,name=start,proto3" json:"start,omitempty"` + End uint64 `protobuf:"varint,3,opt,name=end,proto3" json:"end,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + Uri string `protobuf:"bytes,5,opt,name=uri,proto3" json:"uri,omitempty"` } func (x *Annotation) Reset() { @@ -197,13 +196,6 @@ func (x *Annotation) GetUri() string { return "" } -func (x *Annotation) GetDetail() *EncodedValue { - if x != nil { - return x.Detail - } - return nil -} - type TextContent struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -264,9 +256,7 @@ type ReasoningContent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` - Opaque []byte `protobuf:"bytes,2,opt,name=opaque,proto3" json:"opaque,omitempty"` - OpaqueType string `protobuf:"bytes,3,opt,name=opaque_type,json=opaqueType,proto3" json:"opaque_type,omitempty"` + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` } func (x *ReasoningContent) Reset() { @@ -308,29 +298,14 @@ func (x *ReasoningContent) GetText() string { return "" } -func (x *ReasoningContent) GetOpaque() []byte { - if x != nil { - return x.Opaque - } - return nil -} - -func (x *ReasoningContent) GetOpaqueType() string { - if x != nil { - return x.OpaqueType - } - return "" -} - type MediaContent struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` - Resource *Resource `protobuf:"bytes,2,opt,name=resource,proto3" json:"resource,omitempty"` - Transcript string `protobuf:"bytes,3,opt,name=transcript,proto3" json:"transcript,omitempty"` - Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + Resource *Resource `protobuf:"bytes,2,opt,name=resource,proto3" json:"resource,omitempty"` + Transcript string `protobuf:"bytes,3,opt,name=transcript,proto3" json:"transcript,omitempty"` } func (x *MediaContent) Reset() { @@ -386,13 +361,6 @@ func (x *MediaContent) GetTranscript() string { return "" } -func (x *MediaContent) GetDetail() *EncodedValue { - if x != nil { - return x.Detail - } - return nil -} - type ToolCall struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -477,13 +445,12 @@ type ToolResult struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` - Output []*Content `protobuf:"bytes,2,rep,name=output,proto3" json:"output,omitempty"` - IsError bool `protobuf:"varint,3,opt,name=is_error,json=isError,proto3" json:"is_error,omitempty"` - Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` - Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` - DurationMs uint64 `protobuf:"varint,6,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` - Terminate bool `protobuf:"varint,7,opt,name=terminate,proto3" json:"terminate,omitempty"` + CallId string `protobuf:"bytes,1,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + Output []*Content `protobuf:"bytes,2,rep,name=output,proto3" json:"output,omitempty"` + IsError bool `protobuf:"varint,3,opt,name=is_error,json=isError,proto3" json:"is_error,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + DurationMs uint64 `protobuf:"varint,6,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + Terminate bool `protobuf:"varint,7,opt,name=terminate,proto3" json:"terminate,omitempty"` } func (x *ToolResult) Reset() { @@ -539,13 +506,6 @@ func (x *ToolResult) GetIsError() bool { return false } -func (x *ToolResult) GetDetail() *EncodedValue { - if x != nil { - return x.Detail - } - return nil -} - func (x *ToolResult) GetName() string { if x != nil { return x.Name @@ -567,17 +527,21 @@ func (x *ToolResult) GetTerminate() bool { return false } -type OpaqueContent struct { +// ToolDefinition is the provider-neutral function/tool contract advertised by +// an Agent. Provider adapters translate this schema at their wire boundary. +type ToolDefinition struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - Value *EncodedValue `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + InputSchema *EncodedValue `protobuf:"bytes,4,opt,name=input_schema,json=inputSchema,proto3" json:"input_schema,omitempty"` } -func (x *OpaqueContent) Reset() { - *x = OpaqueContent{} +func (x *ToolDefinition) Reset() { + *x = ToolDefinition{} if protoimpl.UnsafeEnabled { mi := &file_aop_content_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -585,13 +549,13 @@ func (x *OpaqueContent) Reset() { } } -func (x *OpaqueContent) String() string { +func (x *ToolDefinition) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OpaqueContent) ProtoMessage() {} +func (*ToolDefinition) ProtoMessage() {} -func (x *OpaqueContent) ProtoReflect() protoreflect.Message { +func (x *ToolDefinition) ProtoReflect() protoreflect.Message { mi := &file_aop_content_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -603,21 +567,35 @@ func (x *OpaqueContent) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OpaqueContent.ProtoReflect.Descriptor instead. -func (*OpaqueContent) Descriptor() ([]byte, []int) { +// Deprecated: Use ToolDefinition.ProtoReflect.Descriptor instead. +func (*ToolDefinition) Descriptor() ([]byte, []int) { return file_aop_content_proto_rawDescGZIP(), []int{7} } -func (x *OpaqueContent) GetType() string { +func (x *ToolDefinition) GetType() string { if x != nil { return x.Type } return "" } -func (x *OpaqueContent) GetValue() *EncodedValue { +func (x *ToolDefinition) GetName() string { if x != nil { - return x.Value + return x.Name + } + return "" +} + +func (x *ToolDefinition) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ToolDefinition) GetInputSchema() *EncodedValue { + if x != nil { + return x.InputSchema } return nil } @@ -635,7 +613,6 @@ type Content struct { // *Content_Media // *Content_ToolCall // *Content_ToolResult - // *Content_Opaque Value isContent_Value `protobuf_oneof:"value"` } @@ -720,13 +697,6 @@ func (x *Content) GetToolResult() *ToolResult { return nil } -func (x *Content) GetOpaque() *OpaqueContent { - if x, ok := x.GetValue().(*Content_Opaque); ok { - return x.Opaque - } - return nil -} - type isContent_Value interface { isContent_Value() } @@ -755,10 +725,6 @@ type Content_ToolResult struct { ToolResult *ToolResult `protobuf:"bytes,6,opt,name=tool_result,json=toolResult,proto3,oneof"` } -type Content_Opaque struct { - Opaque *OpaqueContent `protobuf:"bytes,7,opt,name=opaque,proto3,oneof"` -} - func (*Content_Text) isContent_Value() {} func (*Content_Reasoning) isContent_Value() {} @@ -771,8 +737,6 @@ func (*Content_ToolCall) isContent_Value() {} func (*Content_ToolResult) isContent_Value() {} -func (*Content_Opaque) isContent_Value() {} - type Message struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -857,95 +821,89 @@ var file_aop_content_proto_rawDesc = []byte{ 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x22, 0x9b, 0x01, 0x0a, 0x0a, 0x41, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, + 0x75, 0x72, 0x63, 0x65, 0x22, 0x76, 0x0a, 0x0a, 0x41, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, + 0x65, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x14, + 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, + 0x69, 0x74, 0x6c, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x4a, 0x04, 0x08, 0x06, 0x10, 0x07, 0x22, 0x54, 0x0a, 0x0b, + 0x54, 0x65, 0x78, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, + 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, + 0x31, 0x0a, 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x6e, 0x6e, 0x6f, 0x74, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x22, 0x32, 0x0a, 0x10, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x43, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, + 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x22, 0x73, 0x0a, 0x0c, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x43, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x29, 0x0a, 0x08, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0xa0, 0x01, 0x0a, 0x08, + 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, + 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, + 0x12, 0x2f, 0x0a, 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, + 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, + 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, + 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x22, 0xbf, + 0x01, 0x0a, 0x0a, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x17, 0x0a, + 0x07, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x63, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6f, 0x6e, + 0x74, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, + 0x69, 0x73, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x69, 0x73, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, + 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x1c, 0x0a, 0x09, + 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x09, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, + 0x22, 0x90, 0x01, 0x0a, 0x0e, 0x54, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, - 0x03, 0x65, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, - 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, - 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, - 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, - 0x69, 0x6c, 0x22, 0x54, 0x0a, 0x0b, 0x54, 0x65, 0x78, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, - 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, 0x31, 0x0a, 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, - 0x2e, 0x41, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x61, 0x6e, 0x6e, - 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x5f, 0x0a, 0x10, 0x52, 0x65, 0x61, 0x73, - 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, - 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, - 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x70, 0x61, 0x71, - 0x75, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, - 0x70, 0x61, 0x71, 0x75, 0x65, 0x54, 0x79, 0x70, 0x65, 0x22, 0x98, 0x01, 0x0a, 0x0c, 0x4d, 0x65, - 0x64, 0x69, 0x61, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, - 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x29, - 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, - 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, - 0x72, 0x61, 0x6e, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, - 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, - 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, - 0x74, 0x61, 0x69, 0x6c, 0x22, 0xa0, 0x01, 0x0a, 0x08, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, - 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, - 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x2f, 0x0a, 0x09, 0x61, 0x72, 0x67, - 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, - 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, - 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x77, 0x6f, - 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x44, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x22, 0xe4, 0x01, 0x0a, 0x0a, 0x54, 0x6f, 0x6f, 0x6c, - 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x12, - 0x24, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x6f, - 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x45, 0x72, 0x72, 0x6f, 0x72, - 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, - 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, - 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x09, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x22, 0x4c, - 0x0a, 0x0d, 0x4f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, - 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xc8, 0x02, 0x0a, - 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x65, 0x78, - 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, - 0x12, 0x35, 0x0a, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x09, 0x72, 0x65, - 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x1a, 0x0a, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, - 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x66, 0x75, - 0x73, 0x61, 0x6c, 0x12, 0x29, 0x0a, 0x05, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x43, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x12, 0x2c, - 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, - 0x48, 0x00, 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x32, 0x0a, 0x0b, - 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x12, 0x2c, 0x0a, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x43, 0x6f, 0x6e, - 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x42, 0x07, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x69, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x07, 0x63, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, - 0x70, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, + 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, + 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, + 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x53, 0x63, 0x68, + 0x65, 0x6d, 0x61, 0x22, 0xa0, 0x02, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, + 0x26, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x65, 0x78, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, + 0x00, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, 0x35, 0x0a, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, + 0x74, 0x48, 0x00, 0x52, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x1a, + 0x0a, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x00, 0x52, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, 0x61, 0x6c, 0x12, 0x29, 0x0a, 0x05, 0x6d, 0x65, + 0x64, 0x69, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x4d, 0x65, 0x64, 0x69, 0x61, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, + 0x6d, 0x65, 0x64, 0x69, 0x61, 0x12, 0x2c, 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, + 0x6c, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, + 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, + 0x61, 0x6c, 0x6c, 0x12, 0x32, 0x0a, 0x0b, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x72, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, + 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x74, 0x6f, 0x6f, + 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x42, 0x07, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x22, 0x69, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x07, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, + 0x74, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -969,32 +927,28 @@ var file_aop_content_proto_goTypes = []interface{}{ (*MediaContent)(nil), // 4: aop.MediaContent (*ToolCall)(nil), // 5: aop.ToolCall (*ToolResult)(nil), // 6: aop.ToolResult - (*OpaqueContent)(nil), // 7: aop.OpaqueContent + (*ToolDefinition)(nil), // 7: aop.ToolDefinition (*Content)(nil), // 8: aop.Content (*Message)(nil), // 9: aop.Message (*EncodedValue)(nil), // 10: aop.EncodedValue } var file_aop_content_proto_depIdxs = []int32{ - 10, // 0: aop.Annotation.detail:type_name -> aop.EncodedValue - 1, // 1: aop.TextContent.annotations:type_name -> aop.Annotation - 0, // 2: aop.MediaContent.resource:type_name -> aop.Resource - 10, // 3: aop.MediaContent.detail:type_name -> aop.EncodedValue - 10, // 4: aop.ToolCall.arguments:type_name -> aop.EncodedValue - 8, // 5: aop.ToolResult.output:type_name -> aop.Content - 10, // 6: aop.ToolResult.detail:type_name -> aop.EncodedValue - 10, // 7: aop.OpaqueContent.value:type_name -> aop.EncodedValue - 2, // 8: aop.Content.text:type_name -> aop.TextContent - 3, // 9: aop.Content.reasoning:type_name -> aop.ReasoningContent - 4, // 10: aop.Content.media:type_name -> aop.MediaContent - 5, // 11: aop.Content.tool_call:type_name -> aop.ToolCall - 6, // 12: aop.Content.tool_result:type_name -> aop.ToolResult - 7, // 13: aop.Content.opaque:type_name -> aop.OpaqueContent - 8, // 14: aop.Message.content:type_name -> aop.Content - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 1, // 0: aop.TextContent.annotations:type_name -> aop.Annotation + 0, // 1: aop.MediaContent.resource:type_name -> aop.Resource + 10, // 2: aop.ToolCall.arguments:type_name -> aop.EncodedValue + 8, // 3: aop.ToolResult.output:type_name -> aop.Content + 10, // 4: aop.ToolDefinition.input_schema:type_name -> aop.EncodedValue + 2, // 5: aop.Content.text:type_name -> aop.TextContent + 3, // 6: aop.Content.reasoning:type_name -> aop.ReasoningContent + 4, // 7: aop.Content.media:type_name -> aop.MediaContent + 5, // 8: aop.Content.tool_call:type_name -> aop.ToolCall + 6, // 9: aop.Content.tool_result:type_name -> aop.ToolResult + 8, // 10: aop.Message.content:type_name -> aop.Content + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_aop_content_proto_init() } @@ -1089,7 +1043,7 @@ func file_aop_content_proto_init() { } } file_aop_content_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*OpaqueContent); i { + switch v := v.(*ToolDefinition); i { case 0: return &v.state case 1: @@ -1136,7 +1090,6 @@ func file_aop_content_proto_init() { (*Content_Media)(nil), (*Content_ToolCall)(nil), (*Content_ToolResult)(nil), - (*Content_Opaque)(nil), } type x struct{} out := protoimpl.TypeBuilder{ diff --git a/aop/envelope.pb.go b/aop/envelope.pb.go new file mode 100644 index 00000000..7bd6900b --- /dev/null +++ b/aop/envelope.pb.go @@ -0,0 +1,181 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/envelope.proto + +package aop + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + anypb "google.golang.org/protobuf/types/known/anypb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Envelope is the only AOP wire envelope. Business namespaces are carried by +// Any and never extend this message with a global oneof. +type Envelope struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + ReplyTo string `protobuf:"bytes,2,opt,name=reply_to,json=replyTo,proto3" json:"reply_to,omitempty"` + DeliveryCursor string `protobuf:"bytes,3,opt,name=delivery_cursor,json=deliveryCursor,proto3" json:"delivery_cursor,omitempty"` + Payload *anypb.Any `protobuf:"bytes,4,opt,name=payload,proto3" json:"payload,omitempty"` +} + +func (x *Envelope) Reset() { + *x = Envelope{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_envelope_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Envelope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Envelope) ProtoMessage() {} + +func (x *Envelope) ProtoReflect() protoreflect.Message { + mi := &file_aop_envelope_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Envelope.ProtoReflect.Descriptor instead. +func (*Envelope) Descriptor() ([]byte, []int) { + return file_aop_envelope_proto_rawDescGZIP(), []int{0} +} + +func (x *Envelope) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Envelope) GetReplyTo() string { + if x != nil { + return x.ReplyTo + } + return "" +} + +func (x *Envelope) GetDeliveryCursor() string { + if x != nil { + return x.DeliveryCursor + } + return "" +} + +func (x *Envelope) GetPayload() *anypb.Any { + if x != nil { + return x.Payload + } + return nil +} + +var File_aop_envelope_proto protoreflect.FileDescriptor + +var file_aop_envelope_proto_rawDesc = []byte{ + 0x0a, 0x12, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x6f, 0x70, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x8e, 0x01, 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, + 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x65, 0x70, 0x6c, 0x79, 0x5f, 0x74, 0x6f, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x65, 0x70, 0x6c, 0x79, 0x54, 0x6f, 0x12, 0x27, 0x0a, 0x0f, + 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x43, + 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x2e, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x70, 0x61, + 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, + 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_envelope_proto_rawDescOnce sync.Once + file_aop_envelope_proto_rawDescData = file_aop_envelope_proto_rawDesc +) + +func file_aop_envelope_proto_rawDescGZIP() []byte { + file_aop_envelope_proto_rawDescOnce.Do(func() { + file_aop_envelope_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_envelope_proto_rawDescData) + }) + return file_aop_envelope_proto_rawDescData +} + +var file_aop_envelope_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_aop_envelope_proto_goTypes = []interface{}{ + (*Envelope)(nil), // 0: aop.Envelope + (*anypb.Any)(nil), // 1: google.protobuf.Any +} +var file_aop_envelope_proto_depIdxs = []int32{ + 1, // 0: aop.Envelope.payload:type_name -> google.protobuf.Any + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_aop_envelope_proto_init() } +func file_aop_envelope_proto_init() { + if File_aop_envelope_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aop_envelope_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Envelope); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_envelope_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_envelope_proto_goTypes, + DependencyIndexes: file_aop_envelope_proto_depIdxs, + MessageInfos: file_aop_envelope_proto_msgTypes, + }.Build() + File_aop_envelope_proto = out.File + file_aop_envelope_proto_rawDesc = nil + file_aop_envelope_proto_goTypes = nil + file_aop_envelope_proto_depIdxs = nil +} diff --git a/aop/event.pb.go b/aop/event.pb.go index 259d5a12..03870f39 100644 --- a/aop/event.pb.go +++ b/aop/event.pb.go @@ -9,6 +9,7 @@ package aop import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + anypb "google.golang.org/protobuf/types/known/anypb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" @@ -278,10 +279,9 @@ type ProtocolError struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` - Detail *EncodedValue `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` } func (x *ProtocolError) Reset() { @@ -337,13 +337,6 @@ func (x *ProtocolError) GetRetryable() bool { return false } -func (x *ProtocolError) GetDetail() *EncodedValue { - if x != nil { - return x.Detail - } - return nil -} - type TokenUsage struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -731,8 +724,7 @@ type Status struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - State string `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` - Detail *EncodedValue `protobuf:"bytes,2,opt,name=detail,proto3" json:"detail,omitempty"` + State string `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` } func (x *Status) Reset() { @@ -774,68 +766,6 @@ func (x *Status) GetState() string { return "" } -func (x *Status) GetDetail() *EncodedValue { - if x != nil { - return x.Detail - } - return nil -} - -type ExtensionEvent struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - Value *EncodedValue `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *ExtensionEvent) Reset() { - *x = ExtensionEvent{} - if protoimpl.UnsafeEnabled { - mi := &file_aop_event_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ExtensionEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExtensionEvent) ProtoMessage() {} - -func (x *ExtensionEvent) ProtoReflect() protoreflect.Message { - mi := &file_aop_event_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExtensionEvent.ProtoReflect.Descriptor instead. -func (*ExtensionEvent) Descriptor() ([]byte, []int) { - return file_aop_event_proto_rawDescGZIP(), []int{9} -} - -func (x *ExtensionEvent) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *ExtensionEvent) GetValue() *EncodedValue { - if x != nil { - return x.Value - } - return nil -} - type ProviderMetadata struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -848,7 +778,7 @@ type ProviderMetadata struct { func (x *ProviderMetadata) Reset() { *x = ProviderMetadata{} if protoimpl.UnsafeEnabled { - mi := &file_aop_event_proto_msgTypes[10] + mi := &file_aop_event_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -861,7 +791,7 @@ func (x *ProviderMetadata) String() string { func (*ProviderMetadata) ProtoMessage() {} func (x *ProviderMetadata) ProtoReflect() protoreflect.Message { - mi := &file_aop_event_proto_msgTypes[10] + mi := &file_aop_event_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -874,7 +804,7 @@ func (x *ProviderMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderMetadata.ProtoReflect.Descriptor instead. func (*ProviderMetadata) Descriptor() ([]byte, []int) { - return file_aop_event_proto_rawDescGZIP(), []int{10} + return file_aop_event_proto_rawDescGZIP(), []int{9} } func (x *ProviderMetadata) GetName() string { @@ -910,7 +840,7 @@ type ProviderFrame struct { func (x *ProviderFrame) Reset() { *x = ProviderFrame{} if protoimpl.UnsafeEnabled { - mi := &file_aop_event_proto_msgTypes[11] + mi := &file_aop_event_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -923,7 +853,7 @@ func (x *ProviderFrame) String() string { func (*ProviderFrame) ProtoMessage() {} func (x *ProviderFrame) ProtoReflect() protoreflect.Message { - mi := &file_aop_event_proto_msgTypes[11] + mi := &file_aop_event_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -936,7 +866,7 @@ func (x *ProviderFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderFrame.ProtoReflect.Descriptor instead. func (*ProviderFrame) Descriptor() ([]byte, []int) { - return file_aop_event_proto_rawDescGZIP(), []int{11} + return file_aop_event_proto_rawDescGZIP(), []int{10} } func (x *ProviderFrame) GetProvider() string { @@ -1006,7 +936,7 @@ type Event struct { TurnId string `protobuf:"bytes,4,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` Emitter string `protobuf:"bytes,5,opt,name=emitter,proto3" json:"emitter,omitempty"` Seq uint64 `protobuf:"varint,6,opt,name=seq,proto3" json:"seq,omitempty"` - Extensions []*Extension `protobuf:"bytes,7,rep,name=extensions,proto3" json:"extensions,omitempty"` + Extensions []*anypb.Any `protobuf:"bytes,8,rep,name=extensions,proto3" json:"extensions,omitempty"` // Types that are assignable to Payload: // // *Event_SessionStarted @@ -1021,15 +951,15 @@ type Event struct { // *Event_Usage // *Event_Error // *Event_Status - // *Event_Extension // *Event_ProviderFrame + // *Event_Extension Payload isEvent_Payload `protobuf_oneof:"payload"` } func (x *Event) Reset() { *x = Event{} if protoimpl.UnsafeEnabled { - mi := &file_aop_event_proto_msgTypes[12] + mi := &file_aop_event_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1042,7 +972,7 @@ func (x *Event) String() string { func (*Event) ProtoMessage() {} func (x *Event) ProtoReflect() protoreflect.Message { - mi := &file_aop_event_proto_msgTypes[12] + mi := &file_aop_event_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1055,7 +985,7 @@ func (x *Event) ProtoReflect() protoreflect.Message { // Deprecated: Use Event.ProtoReflect.Descriptor instead. func (*Event) Descriptor() ([]byte, []int) { - return file_aop_event_proto_rawDescGZIP(), []int{12} + return file_aop_event_proto_rawDescGZIP(), []int{11} } func (x *Event) GetId() string { @@ -1100,7 +1030,7 @@ func (x *Event) GetSeq() uint64 { return 0 } -func (x *Event) GetExtensions() []*Extension { +func (x *Event) GetExtensions() []*anypb.Any { if x != nil { return x.Extensions } @@ -1198,16 +1128,16 @@ func (x *Event) GetStatus() *Status { return nil } -func (x *Event) GetExtension() *ExtensionEvent { - if x, ok := x.GetPayload().(*Event_Extension); ok { - return x.Extension +func (x *Event) GetProviderFrame() *ProviderFrame { + if x, ok := x.GetPayload().(*Event_ProviderFrame); ok { + return x.ProviderFrame } return nil } -func (x *Event) GetProviderFrame() *ProviderFrame { - if x, ok := x.GetPayload().(*Event_ProviderFrame); ok { - return x.ProviderFrame +func (x *Event) GetExtension() *anypb.Any { + if x, ok := x.GetPayload().(*Event_Extension); ok { + return x.Extension } return nil } @@ -1264,14 +1194,14 @@ type Event_Status struct { Status *Status `protobuf:"bytes,21,opt,name=status,proto3,oneof"` } -type Event_Extension struct { - Extension *ExtensionEvent `protobuf:"bytes,22,opt,name=extension,proto3,oneof"` -} - type Event_ProviderFrame struct { ProviderFrame *ProviderFrame `protobuf:"bytes,23,opt,name=provider_frame,json=providerFrame,proto3,oneof"` } +type Event_Extension struct { + Extension *anypb.Any `protobuf:"bytes,24,opt,name=extension,proto3,oneof"` +} + func (*Event_SessionStarted) isEvent_Payload() {} func (*Event_SessionEnded) isEvent_Payload() {} @@ -1296,103 +1226,94 @@ func (*Event_Error) isEvent_Payload() {} func (*Event_Status) isEvent_Payload() {} -func (*Event_Extension) isEvent_Payload() {} - func (*Event_ProviderFrame) isEvent_Payload() {} +func (*Event_Extension) isEvent_Payload() {} + var File_aop_event_proto protoreflect.FileDescriptor var file_aop_event_proto_rawDesc = []byte{ 0x0a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x6f, 0x70, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, - 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x81, 0x01, 0x0a, 0x0e, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x14, - 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, - 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x2a, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, - 0x12, 0x2d, 0x0a, 0x13, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, - 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, - 0x61, 0x72, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x22, - 0x26, 0x0a, 0x0c, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, - 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x0d, 0x0a, 0x0b, 0x54, 0x75, 0x72, 0x6e, 0x53, - 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x22, 0x86, 0x01, 0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, - 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, - 0x61, 0x62, 0x6c, 0x65, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, - 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x22, - 0xfd, 0x01, 0x0a, 0x0a, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, - 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x6f, - 0x74, 0x61, 0x6c, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, - 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, - 0x33, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x1b, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x2e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x64, 0x65, - 0x74, 0x61, 0x69, 0x6c, 0x1a, 0x39, 0x0a, 0x0b, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, - 0xa4, 0x01, 0x0a, 0x09, 0x54, 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x1f, 0x0a, - 0x0b, 0x73, 0x74, 0x6f, 0x70, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0a, 0x73, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x28, - 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, - 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, - 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x25, 0x0a, 0x05, 0x75, 0x73, 0x61, 0x67, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, - 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0xc9, 0x02, 0x0a, 0x0c, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, - 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x63, - 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x31, 0x0a, 0x09, 0x6f, - 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, - 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, - 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, - 0x74, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, - 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x72, 0x65, 0x61, 0x73, 0x6f, - 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x1a, 0x0a, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, 0x61, 0x6c, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x66, 0x75, 0x73, 0x61, 0x6c, - 0x12, 0x14, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, - 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x27, 0x0a, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, - 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, - 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, - 0x28, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x48, 0x00, - 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x42, 0x07, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x22, 0x70, 0x0a, 0x0d, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x44, 0x65, - 0x6c, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, - 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x69, 0x6e, 0x64, - 0x65, 0x78, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, - 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x72, 0x67, 0x75, 0x6d, - 0x65, 0x6e, 0x74, 0x73, 0x22, 0x49, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x14, - 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, - 0x74, 0x61, 0x74, 0x65, 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, - 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x22, - 0x4d, 0x0a, 0x0e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x76, 0x65, 0x6e, - 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, - 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x3c, + 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x81, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x2a, + 0x0a, 0x11, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x65, 0x6e, + 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x13, 0x70, 0x61, + 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, + 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x54, + 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x22, 0x26, 0x0a, 0x0c, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, + 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x22, 0x0d, 0x0a, 0x0b, 0x54, 0x75, 0x72, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, + 0x22, 0x61, 0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x74, 0x72, 0x79, 0x61, 0x62, 0x6c, 0x65, 0x4a, 0x04, 0x08, + 0x04, 0x10, 0x05, 0x22, 0xfd, 0x01, 0x0a, 0x0a, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x6f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, + 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x14, 0x0a, + 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x12, 0x33, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x18, 0x05, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x2e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x1a, 0x39, 0x0a, 0x0b, 0x44, 0x65, 0x74, 0x61, + 0x69, 0x6c, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x22, 0xa4, 0x01, 0x0a, 0x09, 0x54, 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, + 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x6f, 0x70, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x25, 0x0a, 0x05, + 0x75, 0x73, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, + 0x70, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x05, 0x75, 0x73, + 0x61, 0x67, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0xc9, 0x02, 0x0a, 0x0c, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6f, + 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, + 0x31, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x4f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x48, 0x00, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, 0x09, 0x72, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x72, + 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x1a, 0x0a, 0x07, 0x72, 0x65, 0x66, 0x75, + 0x73, 0x61, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x66, + 0x75, 0x73, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x0c, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x27, 0x0a, 0x0e, 0x74, 0x6f, + 0x6f, 0x6c, 0x5f, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x00, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x72, 0x67, 0x75, 0x6d, 0x65, + 0x6e, 0x74, 0x73, 0x12, 0x28, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x42, 0x07, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x70, 0x0a, 0x0d, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, + 0x6c, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x6c, 0x6c, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x49, 0x64, + 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x72, + 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, + 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x24, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x22, 0x3c, 0x0a, 0x10, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, @@ -1414,7 +1335,7 @@ var file_aop_event_proto_rawDesc = []byte{ 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xc5, 0x07, + 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xd8, 0x07, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, @@ -1426,72 +1347,75 @@ var file_aop_event_proto_rawDesc = []byte{ 0x28, 0x09, 0x52, 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x65, 0x71, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x04, 0x52, 0x03, 0x73, 0x65, 0x71, 0x12, 0x2e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, - 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3e, 0x0a, 0x0f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, - 0x72, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, - 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x38, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, - 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, - 0x48, 0x00, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, - 0x12, 0x35, 0x0a, 0x0c, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, - 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x75, 0x72, - 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0b, 0x74, 0x75, 0x72, 0x6e, - 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x0a, 0x74, 0x75, 0x72, 0x6e, 0x5f, - 0x65, 0x6e, 0x64, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, - 0x70, 0x2e, 0x54, 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x48, 0x00, 0x52, 0x09, 0x74, - 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x38, 0x0a, 0x0d, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x64, 0x65, - 0x6c, 0x74, 0x61, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x0c, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x2c, 0x0a, 0x09, - 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x48, 0x00, - 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x3c, 0x0a, 0x0f, 0x74, 0x6f, - 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x11, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, - 0x6c, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x43, - 0x61, 0x6c, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x0b, 0x74, 0x6f, 0x6f, 0x6c, - 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, - 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, - 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x27, 0x0a, 0x05, - 0x75, 0x73, 0x61, 0x67, 0x65, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, - 0x70, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, - 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2a, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x14, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x15, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, - 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x6f, - 0x70, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x48, 0x00, 0x52, 0x09, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3b, 0x0a, - 0x0e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, - 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x76, - 0x69, 0x64, 0x65, 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x48, 0x00, 0x52, 0x0d, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, - 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2a, 0x9e, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x4f, - 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x1b, 0x44, 0x45, 0x4c, 0x54, - 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, - 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x44, 0x45, 0x4c, - 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, - 0x52, 0x54, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, - 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x50, 0x50, 0x45, 0x4e, 0x44, 0x10, 0x02, - 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, - 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x10, 0x03, 0x12, 0x17, 0x0a, - 0x13, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x45, 0x4e, 0x44, 0x10, 0x04, 0x2a, 0x55, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x15, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x15, - 0x0a, 0x11, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, - 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, - 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x04, 0x52, 0x03, 0x73, 0x65, 0x71, 0x12, 0x34, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, + 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3e, 0x0a, 0x0f, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0e, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x38, 0x0a, 0x0d, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x0c, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x73, + 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x54, 0x75, 0x72, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x48, 0x00, + 0x52, 0x0b, 0x74, 0x75, 0x72, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x12, 0x2f, 0x0a, + 0x0a, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, + 0x64, 0x48, 0x00, 0x52, 0x09, 0x74, 0x75, 0x72, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x28, + 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x38, 0x0a, 0x0d, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, + 0x74, 0x61, 0x48, 0x00, 0x52, 0x0c, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x65, 0x6c, + 0x74, 0x61, 0x12, 0x2c, 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x18, + 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, + 0x43, 0x61, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, + 0x12, 0x3c, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x65, + 0x6c, 0x74, 0x61, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, + 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x32, + 0x0a, 0x0b, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x12, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, + 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x12, 0x27, 0x0a, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x18, 0x13, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2a, 0x0a, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x48, 0x00, + 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3b, + 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, + 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x48, 0x00, 0x52, 0x0d, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x34, 0x0a, 0x09, 0x65, + 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x41, 0x6e, 0x79, 0x48, 0x00, 0x52, 0x09, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, + 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x4a, 0x04, 0x08, 0x07, + 0x10, 0x08, 0x4a, 0x04, 0x08, 0x16, 0x10, 0x17, 0x2a, 0x9e, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x6c, + 0x74, 0x61, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x1b, 0x44, + 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, + 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, + 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, + 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x44, 0x45, 0x4c, 0x54, 0x41, + 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x50, 0x50, 0x45, 0x4e, + 0x44, 0x10, 0x02, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, + 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x10, 0x03, + 0x12, 0x17, 0x0a, 0x13, 0x44, 0x45, 0x4c, 0x54, 0x41, 0x5f, 0x4f, 0x50, 0x45, 0x52, 0x41, 0x54, + 0x49, 0x4f, 0x4e, 0x5f, 0x45, 0x4e, 0x44, 0x10, 0x04, 0x2a, 0x55, 0x0a, 0x09, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x15, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, + 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, + 0x00, 0x12, 0x15, 0x0a, 0x11, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, + 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x49, 0x52, 0x45, + 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, + 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, + 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1507,7 +1431,7 @@ func file_aop_event_proto_rawDescGZIP() []byte { } var file_aop_event_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_aop_event_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_aop_event_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_aop_event_proto_goTypes = []interface{}{ (DeltaOperation)(0), // 0: aop.DeltaOperation (Direction)(0), // 1: aop.Direction @@ -1520,51 +1444,46 @@ var file_aop_event_proto_goTypes = []interface{}{ (*MessageDelta)(nil), // 8: aop.MessageDelta (*ToolCallDelta)(nil), // 9: aop.ToolCallDelta (*Status)(nil), // 10: aop.Status - (*ExtensionEvent)(nil), // 11: aop.ExtensionEvent - (*ProviderMetadata)(nil), // 12: aop.ProviderMetadata - (*ProviderFrame)(nil), // 13: aop.ProviderFrame - (*Event)(nil), // 14: aop.Event - nil, // 15: aop.TokenUsage.DetailEntry - (*EncodedValue)(nil), // 16: aop.EncodedValue - (*Content)(nil), // 17: aop.Content - (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp - (*Extension)(nil), // 19: aop.Extension - (*Message)(nil), // 20: aop.Message - (*ToolCall)(nil), // 21: aop.ToolCall - (*ToolResult)(nil), // 22: aop.ToolResult + (*ProviderMetadata)(nil), // 11: aop.ProviderMetadata + (*ProviderFrame)(nil), // 12: aop.ProviderFrame + (*Event)(nil), // 13: aop.Event + nil, // 14: aop.TokenUsage.DetailEntry + (*Content)(nil), // 15: aop.Content + (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp + (*anypb.Any)(nil), // 17: google.protobuf.Any + (*Message)(nil), // 18: aop.Message + (*ToolCall)(nil), // 19: aop.ToolCall + (*ToolResult)(nil), // 20: aop.ToolResult } var file_aop_event_proto_depIdxs = []int32{ - 16, // 0: aop.ProtocolError.detail:type_name -> aop.EncodedValue - 15, // 1: aop.TokenUsage.detail:type_name -> aop.TokenUsage.DetailEntry - 5, // 2: aop.TurnEnded.error:type_name -> aop.ProtocolError - 6, // 3: aop.TurnEnded.usage:type_name -> aop.TokenUsage - 0, // 4: aop.MessageDelta.operation:type_name -> aop.DeltaOperation - 17, // 5: aop.MessageDelta.content:type_name -> aop.Content - 16, // 6: aop.Status.detail:type_name -> aop.EncodedValue - 16, // 7: aop.ExtensionEvent.value:type_name -> aop.EncodedValue - 1, // 8: aop.ProviderFrame.direction:type_name -> aop.Direction - 12, // 9: aop.ProviderFrame.metadata:type_name -> aop.ProviderMetadata - 18, // 10: aop.Event.emitted_at:type_name -> google.protobuf.Timestamp - 19, // 11: aop.Event.extensions:type_name -> aop.Extension - 2, // 12: aop.Event.session_started:type_name -> aop.SessionStarted - 3, // 13: aop.Event.session_ended:type_name -> aop.SessionEnded - 4, // 14: aop.Event.turn_started:type_name -> aop.TurnStarted - 7, // 15: aop.Event.turn_ended:type_name -> aop.TurnEnded - 20, // 16: aop.Event.message:type_name -> aop.Message - 8, // 17: aop.Event.message_delta:type_name -> aop.MessageDelta - 21, // 18: aop.Event.tool_call:type_name -> aop.ToolCall - 9, // 19: aop.Event.tool_call_delta:type_name -> aop.ToolCallDelta - 22, // 20: aop.Event.tool_result:type_name -> aop.ToolResult - 6, // 21: aop.Event.usage:type_name -> aop.TokenUsage - 5, // 22: aop.Event.error:type_name -> aop.ProtocolError - 10, // 23: aop.Event.status:type_name -> aop.Status - 11, // 24: aop.Event.extension:type_name -> aop.ExtensionEvent - 13, // 25: aop.Event.provider_frame:type_name -> aop.ProviderFrame - 26, // [26:26] is the sub-list for method output_type - 26, // [26:26] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 14, // 0: aop.TokenUsage.detail:type_name -> aop.TokenUsage.DetailEntry + 5, // 1: aop.TurnEnded.error:type_name -> aop.ProtocolError + 6, // 2: aop.TurnEnded.usage:type_name -> aop.TokenUsage + 0, // 3: aop.MessageDelta.operation:type_name -> aop.DeltaOperation + 15, // 4: aop.MessageDelta.content:type_name -> aop.Content + 1, // 5: aop.ProviderFrame.direction:type_name -> aop.Direction + 11, // 6: aop.ProviderFrame.metadata:type_name -> aop.ProviderMetadata + 16, // 7: aop.Event.emitted_at:type_name -> google.protobuf.Timestamp + 17, // 8: aop.Event.extensions:type_name -> google.protobuf.Any + 2, // 9: aop.Event.session_started:type_name -> aop.SessionStarted + 3, // 10: aop.Event.session_ended:type_name -> aop.SessionEnded + 4, // 11: aop.Event.turn_started:type_name -> aop.TurnStarted + 7, // 12: aop.Event.turn_ended:type_name -> aop.TurnEnded + 18, // 13: aop.Event.message:type_name -> aop.Message + 8, // 14: aop.Event.message_delta:type_name -> aop.MessageDelta + 19, // 15: aop.Event.tool_call:type_name -> aop.ToolCall + 9, // 16: aop.Event.tool_call_delta:type_name -> aop.ToolCallDelta + 20, // 17: aop.Event.tool_result:type_name -> aop.ToolResult + 6, // 18: aop.Event.usage:type_name -> aop.TokenUsage + 5, // 19: aop.Event.error:type_name -> aop.ProtocolError + 10, // 20: aop.Event.status:type_name -> aop.Status + 12, // 21: aop.Event.provider_frame:type_name -> aop.ProviderFrame + 17, // 22: aop.Event.extension:type_name -> google.protobuf.Any + 23, // [23:23] is the sub-list for method output_type + 23, // [23:23] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_aop_event_proto_init() } @@ -1573,7 +1492,6 @@ func file_aop_event_proto_init() { return } file_aop_content_proto_init() - file_aop_value_proto_init() if !protoimpl.UnsafeEnabled { file_aop_event_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SessionStarted); i { @@ -1684,18 +1602,6 @@ func file_aop_event_proto_init() { } } file_aop_event_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExtensionEvent); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_aop_event_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ProviderMetadata); i { case 0: return &v.state @@ -1707,7 +1613,7 @@ func file_aop_event_proto_init() { return nil } } - file_aop_event_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + file_aop_event_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ProviderFrame); i { case 0: return &v.state @@ -1719,7 +1625,7 @@ func file_aop_event_proto_init() { return nil } } - file_aop_event_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + file_aop_event_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Event); i { case 0: return &v.state @@ -1740,7 +1646,7 @@ func file_aop_event_proto_init() { (*MessageDelta_ToolArguments)(nil), (*MessageDelta_Content)(nil), } - file_aop_event_proto_msgTypes[12].OneofWrappers = []interface{}{ + file_aop_event_proto_msgTypes[11].OneofWrappers = []interface{}{ (*Event_SessionStarted)(nil), (*Event_SessionEnded)(nil), (*Event_TurnStarted)(nil), @@ -1753,8 +1659,8 @@ func file_aop_event_proto_init() { (*Event_Usage)(nil), (*Event_Error)(nil), (*Event_Status)(nil), - (*Event_Extension)(nil), (*Event_ProviderFrame)(nil), + (*Event_Extension)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -1762,7 +1668,7 @@ func file_aop_event_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_aop_event_proto_rawDesc, NumEnums: 2, - NumMessages: 14, + NumMessages: 13, NumExtensions: 0, NumServices: 0, }, diff --git a/aop/exec/protocol.pb.go b/aop/exec/protocol.pb.go new file mode 100644 index 00000000..e2084dff --- /dev/null +++ b/aop/exec/protocol.pb.go @@ -0,0 +1,519 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/exec/protocol.proto + +package exec + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Stream int32 + +const ( + Stream_STREAM_UNSPECIFIED Stream = 0 + Stream_STREAM_STDOUT Stream = 1 + Stream_STREAM_STDERR Stream = 2 +) + +// Enum value maps for Stream. +var ( + Stream_name = map[int32]string{ + 0: "STREAM_UNSPECIFIED", + 1: "STREAM_STDOUT", + 2: "STREAM_STDERR", + } + Stream_value = map[string]int32{ + "STREAM_UNSPECIFIED": 0, + "STREAM_STDOUT": 1, + "STREAM_STDERR": 2, + } +) + +func (x Stream) Enum() *Stream { + p := new(Stream) + *p = x + return p +} + +func (x Stream) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Stream) Descriptor() protoreflect.EnumDescriptor { + return file_aop_exec_protocol_proto_enumTypes[0].Descriptor() +} + +func (Stream) Type() protoreflect.EnumType { + return &file_aop_exec_protocol_proto_enumTypes[0] +} + +func (x Stream) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Stream.Descriptor instead. +func (Stream) EnumDescriptor() ([]byte, []int) { + return file_aop_exec_protocol_proto_rawDescGZIP(), []int{0} +} + +type Request struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` + Cwd string `protobuf:"bytes,2,opt,name=cwd,proto3" json:"cwd,omitempty"` + TimeoutSeconds uint32 `protobuf:"varint,3,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + Env map[string]string `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *Request) Reset() { + *x = Request{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_exec_protocol_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Request) ProtoMessage() {} + +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_aop_exec_protocol_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_aop_exec_protocol_proto_rawDescGZIP(), []int{0} +} + +func (x *Request) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *Request) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +func (x *Request) GetTimeoutSeconds() uint32 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + +func (x *Request) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + +type Output struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Stream Stream `protobuf:"varint,1,opt,name=stream,proto3,enum=aop.exec.Stream" json:"stream,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *Output) Reset() { + *x = Output{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_exec_protocol_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Output) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Output) ProtoMessage() {} + +func (x *Output) ProtoReflect() protoreflect.Message { + mi := &file_aop_exec_protocol_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Output.ProtoReflect.Descriptor instead. +func (*Output) Descriptor() ([]byte, []int) { + return file_aop_exec_protocol_proto_rawDescGZIP(), []int{1} +} + +func (x *Output) GetStream() Stream { + if x != nil { + return x.Stream + } + return Stream_STREAM_UNSPECIFIED +} + +func (x *Output) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type Result struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + KillCause string `protobuf:"bytes,3,opt,name=kill_cause,json=killCause,proto3" json:"kill_cause,omitempty"` +} + +func (x *Result) Reset() { + *x = Result{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_exec_protocol_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Result) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Result) ProtoMessage() {} + +func (x *Result) ProtoReflect() protoreflect.Message { + mi := &file_aop_exec_protocol_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Result.ProtoReflect.Descriptor instead. +func (*Result) Descriptor() ([]byte, []int) { + return file_aop_exec_protocol_proto_rawDescGZIP(), []int{2} +} + +func (x *Result) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *Result) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Result) GetKillCause() string { + if x != nil { + return x.KillCause + } + return "" +} + +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_Request + // *ProtocolMessage_Output + // *ProtocolMessage_Result + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_exec_protocol_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aop_exec_protocol_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aop_exec_protocol_proto_rawDescGZIP(), []int{3} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetRequest() *Request { + if x, ok := x.GetMessage().(*ProtocolMessage_Request); ok { + return x.Request + } + return nil +} + +func (x *ProtocolMessage) GetOutput() *Output { + if x, ok := x.GetMessage().(*ProtocolMessage_Output); ok { + return x.Output + } + return nil +} + +func (x *ProtocolMessage) GetResult() *Result { + if x, ok := x.GetMessage().(*ProtocolMessage_Result); ok { + return x.Result + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_Request struct { + Request *Request `protobuf:"bytes,10,opt,name=request,proto3,oneof"` +} + +type ProtocolMessage_Output struct { + Output *Output `protobuf:"bytes,11,opt,name=output,proto3,oneof"` +} + +type ProtocolMessage_Result struct { + Result *Result `protobuf:"bytes,12,opt,name=result,proto3,oneof"` +} + +func (*ProtocolMessage_Request) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Output) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Result) isProtocolMessage_Message() {} + +var File_aop_exec_protocol_proto protoreflect.FileDescriptor + +var file_aop_exec_protocol_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x78, 0x65, 0x63, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x61, 0x6f, 0x70, 0x2e, 0x65, + 0x78, 0x65, 0x63, 0x22, 0xc4, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x77, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x63, 0x77, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x74, + 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, + 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x2c, 0x0a, 0x03, 0x65, 0x6e, 0x76, 0x18, 0x04, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x65, 0x78, 0x65, 0x63, 0x2e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x03, 0x65, + 0x6e, 0x76, 0x1a, 0x36, 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x46, 0x0a, 0x06, 0x4f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x12, 0x28, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x65, 0x78, 0x65, 0x63, 0x2e, + 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x12, + 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, + 0x74, 0x61, 0x22, 0x5a, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1b, 0x0a, 0x09, + 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, + 0x1d, 0x0a, 0x0a, 0x6b, 0x69, 0x6c, 0x6c, 0x5f, 0x63, 0x61, 0x75, 0x73, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x6b, 0x69, 0x6c, 0x6c, 0x43, 0x61, 0x75, 0x73, 0x65, 0x22, 0xa3, + 0x01, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x2d, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x65, 0x78, 0x65, 0x63, 0x2e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x2a, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x65, 0x78, 0x65, 0x63, 0x2e, 0x4f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x2a, 0x0a, + 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x65, 0x78, 0x65, 0x63, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, + 0x00, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x2a, 0x46, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x16, + 0x0a, 0x12, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, + 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, + 0x5f, 0x53, 0x54, 0x44, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x52, + 0x45, 0x41, 0x4d, 0x5f, 0x53, 0x54, 0x44, 0x45, 0x52, 0x52, 0x10, 0x02, 0x42, 0x2f, 0x5a, 0x2d, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, + 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, + 0x61, 0x6f, 0x70, 0x2f, 0x65, 0x78, 0x65, 0x63, 0x3b, 0x65, 0x78, 0x65, 0x63, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_exec_protocol_proto_rawDescOnce sync.Once + file_aop_exec_protocol_proto_rawDescData = file_aop_exec_protocol_proto_rawDesc +) + +func file_aop_exec_protocol_proto_rawDescGZIP() []byte { + file_aop_exec_protocol_proto_rawDescOnce.Do(func() { + file_aop_exec_protocol_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_exec_protocol_proto_rawDescData) + }) + return file_aop_exec_protocol_proto_rawDescData +} + +var file_aop_exec_protocol_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_aop_exec_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_aop_exec_protocol_proto_goTypes = []interface{}{ + (Stream)(0), // 0: aop.exec.Stream + (*Request)(nil), // 1: aop.exec.Request + (*Output)(nil), // 2: aop.exec.Output + (*Result)(nil), // 3: aop.exec.Result + (*ProtocolMessage)(nil), // 4: aop.exec.ProtocolMessage + nil, // 5: aop.exec.Request.EnvEntry +} +var file_aop_exec_protocol_proto_depIdxs = []int32{ + 5, // 0: aop.exec.Request.env:type_name -> aop.exec.Request.EnvEntry + 0, // 1: aop.exec.Output.stream:type_name -> aop.exec.Stream + 1, // 2: aop.exec.ProtocolMessage.request:type_name -> aop.exec.Request + 2, // 3: aop.exec.ProtocolMessage.output:type_name -> aop.exec.Output + 3, // 4: aop.exec.ProtocolMessage.result:type_name -> aop.exec.Result + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_aop_exec_protocol_proto_init() } +func file_aop_exec_protocol_proto_init() { + if File_aop_exec_protocol_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aop_exec_protocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Request); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_exec_protocol_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Output); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_exec_protocol_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Result); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_exec_protocol_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_exec_protocol_proto_msgTypes[3].OneofWrappers = []interface{}{ + (*ProtocolMessage_Request)(nil), + (*ProtocolMessage_Output)(nil), + (*ProtocolMessage_Result)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_exec_protocol_proto_rawDesc, + NumEnums: 1, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_exec_protocol_proto_goTypes, + DependencyIndexes: file_aop_exec_protocol_proto_depIdxs, + EnumInfos: file_aop_exec_protocol_proto_enumTypes, + MessageInfos: file_aop_exec_protocol_proto_msgTypes, + }.Build() + File_aop_exec_protocol_proto = out.File + file_aop_exec_protocol_proto_rawDesc = nil + file_aop_exec_protocol_proto_goTypes = nil + file_aop_exec_protocol_proto_depIdxs = nil +} diff --git a/aop/file/protocol.pb.go b/aop/file/protocol.pb.go new file mode 100644 index 00000000..0f718f6a --- /dev/null +++ b/aop/file/protocol.pb.go @@ -0,0 +1,814 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/file/protocol.proto + +package file + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ReadRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *ReadRequest) Reset() { + *x = ReadRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadRequest) ProtoMessage() {} + +func (x *ReadRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. +func (*ReadRequest) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{0} +} + +func (x *ReadRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type WriteRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *WriteRequest) Reset() { + *x = WriteRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WriteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteRequest) ProtoMessage() {} + +func (x *WriteRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteRequest.ProtoReflect.Descriptor instead. +func (*WriteRequest) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{1} +} + +func (x *WriteRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WriteRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type ListRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{2} +} + +func (x *ListRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type MkdirRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *MkdirRequest) Reset() { + *x = MkdirRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MkdirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MkdirRequest) ProtoMessage() {} + +func (x *MkdirRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MkdirRequest.ProtoReflect.Descriptor instead. +func (*MkdirRequest) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{3} +} + +func (x *MkdirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type UploadRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Filename string `protobuf:"bytes,2,opt,name=filename,proto3" json:"filename,omitempty"` + MediaType string `protobuf:"bytes,3,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` + Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *UploadRequest) Reset() { + *x = UploadRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UploadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadRequest) ProtoMessage() {} + +func (x *UploadRequest) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadRequest.ProtoReflect.Descriptor instead. +func (*UploadRequest) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{4} +} + +func (x *UploadRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *UploadRequest) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *UploadRequest) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +func (x *UploadRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type Entry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + IsDirectory bool `protobuf:"varint,2,opt,name=is_directory,json=isDirectory,proto3" json:"is_directory,omitempty"` + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` +} + +func (x *Entry) Reset() { + *x = Entry{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Entry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Entry) ProtoMessage() {} + +func (x *Entry) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Entry.ProtoReflect.Descriptor instead. +func (*Entry) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{5} +} + +func (x *Entry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Entry) GetIsDirectory() bool { + if x != nil { + return x.IsDirectory + } + return false +} + +func (x *Entry) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +type Result struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Filename string `protobuf:"bytes,2,opt,name=filename,proto3" json:"filename,omitempty"` + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` + Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` + Entries []*Entry `protobuf:"bytes,5,rep,name=entries,proto3" json:"entries,omitempty"` + MediaType string `protobuf:"bytes,6,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *Result) Reset() { + *x = Result{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Result) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Result) ProtoMessage() {} + +func (x *Result) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Result.ProtoReflect.Descriptor instead. +func (*Result) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{6} +} + +func (x *Result) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *Result) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *Result) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *Result) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *Result) GetEntries() []*Entry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *Result) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_ReadRequest + // *ProtocolMessage_WriteRequest + // *ProtocolMessage_ListRequest + // *ProtocolMessage_MkdirRequest + // *ProtocolMessage_UploadRequest + // *ProtocolMessage_Result + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_file_protocol_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aop_file_protocol_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aop_file_protocol_proto_rawDescGZIP(), []int{7} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetReadRequest() *ReadRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_ReadRequest); ok { + return x.ReadRequest + } + return nil +} + +func (x *ProtocolMessage) GetWriteRequest() *WriteRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_WriteRequest); ok { + return x.WriteRequest + } + return nil +} + +func (x *ProtocolMessage) GetListRequest() *ListRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_ListRequest); ok { + return x.ListRequest + } + return nil +} + +func (x *ProtocolMessage) GetMkdirRequest() *MkdirRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_MkdirRequest); ok { + return x.MkdirRequest + } + return nil +} + +func (x *ProtocolMessage) GetUploadRequest() *UploadRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_UploadRequest); ok { + return x.UploadRequest + } + return nil +} + +func (x *ProtocolMessage) GetResult() *Result { + if x, ok := x.GetMessage().(*ProtocolMessage_Result); ok { + return x.Result + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_ReadRequest struct { + ReadRequest *ReadRequest `protobuf:"bytes,10,opt,name=read_request,json=readRequest,proto3,oneof"` +} + +type ProtocolMessage_WriteRequest struct { + WriteRequest *WriteRequest `protobuf:"bytes,11,opt,name=write_request,json=writeRequest,proto3,oneof"` +} + +type ProtocolMessage_ListRequest struct { + ListRequest *ListRequest `protobuf:"bytes,12,opt,name=list_request,json=listRequest,proto3,oneof"` +} + +type ProtocolMessage_MkdirRequest struct { + MkdirRequest *MkdirRequest `protobuf:"bytes,13,opt,name=mkdir_request,json=mkdirRequest,proto3,oneof"` +} + +type ProtocolMessage_UploadRequest struct { + UploadRequest *UploadRequest `protobuf:"bytes,14,opt,name=upload_request,json=uploadRequest,proto3,oneof"` +} + +type ProtocolMessage_Result struct { + Result *Result `protobuf:"bytes,20,opt,name=result,proto3,oneof"` +} + +func (*ProtocolMessage_ReadRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_WriteRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_ListRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_MkdirRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_UploadRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Result) isProtocolMessage_Message() {} + +var File_aop_file_protocol_proto protoreflect.FileDescriptor + +var file_aop_file_protocol_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x6f, 0x70, 0x2f, 0x66, 0x69, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x61, 0x6f, 0x70, 0x2e, 0x66, + 0x69, 0x6c, 0x65, 0x22, 0x21, 0x0a, 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x36, 0x0a, 0x0c, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, + 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x21, + 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, + 0x68, 0x22, 0x22, 0x0a, 0x0c, 0x4d, 0x6b, 0x64, 0x69, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x7d, 0x0a, 0x0d, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, + 0x64, 0x61, 0x74, 0x61, 0x22, 0x52, 0x0a, 0x05, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x12, 0x0a, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x73, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x44, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x22, 0xaa, 0x01, 0x0a, 0x06, 0x52, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x29, 0x0a, 0x07, 0x65, + 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, + 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, + 0x61, 0x54, 0x79, 0x70, 0x65, 0x22, 0x80, 0x03, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x3a, 0x0a, 0x0c, 0x72, 0x65, 0x61, + 0x64, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x72, 0x65, 0x61, 0x64, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3d, 0x0a, 0x0d, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x3a, 0x0a, 0x0c, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x48, 0x00, 0x52, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x3d, 0x0a, 0x0d, 0x6d, 0x6b, 0x64, 0x69, 0x72, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x66, 0x69, + 0x6c, 0x65, 0x2e, 0x4d, 0x6b, 0x64, 0x69, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, + 0x00, 0x52, 0x0c, 0x6d, 0x6b, 0x64, 0x69, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x40, 0x0a, 0x0e, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x66, 0x69, + 0x6c, 0x65, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x48, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x2a, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x14, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x42, 0x09, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x2f, 0x5a, 0x2d, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, + 0x66, 0x69, 0x6c, 0x65, 0x3b, 0x66, 0x69, 0x6c, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var ( + file_aop_file_protocol_proto_rawDescOnce sync.Once + file_aop_file_protocol_proto_rawDescData = file_aop_file_protocol_proto_rawDesc +) + +func file_aop_file_protocol_proto_rawDescGZIP() []byte { + file_aop_file_protocol_proto_rawDescOnce.Do(func() { + file_aop_file_protocol_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_file_protocol_proto_rawDescData) + }) + return file_aop_file_protocol_proto_rawDescData +} + +var file_aop_file_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_aop_file_protocol_proto_goTypes = []interface{}{ + (*ReadRequest)(nil), // 0: aop.file.ReadRequest + (*WriteRequest)(nil), // 1: aop.file.WriteRequest + (*ListRequest)(nil), // 2: aop.file.ListRequest + (*MkdirRequest)(nil), // 3: aop.file.MkdirRequest + (*UploadRequest)(nil), // 4: aop.file.UploadRequest + (*Entry)(nil), // 5: aop.file.Entry + (*Result)(nil), // 6: aop.file.Result + (*ProtocolMessage)(nil), // 7: aop.file.ProtocolMessage +} +var file_aop_file_protocol_proto_depIdxs = []int32{ + 5, // 0: aop.file.Result.entries:type_name -> aop.file.Entry + 0, // 1: aop.file.ProtocolMessage.read_request:type_name -> aop.file.ReadRequest + 1, // 2: aop.file.ProtocolMessage.write_request:type_name -> aop.file.WriteRequest + 2, // 3: aop.file.ProtocolMessage.list_request:type_name -> aop.file.ListRequest + 3, // 4: aop.file.ProtocolMessage.mkdir_request:type_name -> aop.file.MkdirRequest + 4, // 5: aop.file.ProtocolMessage.upload_request:type_name -> aop.file.UploadRequest + 6, // 6: aop.file.ProtocolMessage.result:type_name -> aop.file.Result + 7, // [7:7] is the sub-list for method output_type + 7, // [7:7] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name +} + +func init() { file_aop_file_protocol_proto_init() } +func file_aop_file_protocol_proto_init() { + if File_aop_file_protocol_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aop_file_protocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReadRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_file_protocol_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WriteRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_file_protocol_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_file_protocol_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MkdirRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_file_protocol_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UploadRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_file_protocol_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Entry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_file_protocol_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Result); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_file_protocol_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_file_protocol_proto_msgTypes[7].OneofWrappers = []interface{}{ + (*ProtocolMessage_ReadRequest)(nil), + (*ProtocolMessage_WriteRequest)(nil), + (*ProtocolMessage_ListRequest)(nil), + (*ProtocolMessage_MkdirRequest)(nil), + (*ProtocolMessage_UploadRequest)(nil), + (*ProtocolMessage_Result)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_file_protocol_proto_rawDesc, + NumEnums: 0, + NumMessages: 8, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_file_protocol_proto_goTypes, + DependencyIndexes: file_aop_file_protocol_proto_depIdxs, + MessageInfos: file_aop_file_protocol_proto_msgTypes, + }.Build() + File_aop_file_protocol_proto = out.File + file_aop_file_protocol_proto_rawDesc = nil + file_aop_file_protocol_proto_goTypes = nil + file_aop_file_protocol_proto_depIdxs = nil +} diff --git a/aop/helpers.go b/aop/helpers.go index 02684246..72620cfe 100644 --- a/aop/helpers.go +++ b/aop/helpers.go @@ -4,12 +4,11 @@ import ( "encoding/json" "fmt" - "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" ) const JSONMediaType = "application/json" -const ProtoJSONMediaType = "application/protobuf+json" func JSONValue(value any) (*EncodedValue, error) { data, err := json.Marshal(value) @@ -30,86 +29,44 @@ func DecodeJSON[T any](value *EncodedValue) (T, error) { return decoded, nil } -func ProtoJSONValue(value proto.Message) (*EncodedValue, error) { - if value == nil { - return nil, fmt.Errorf("protobuf value is required") - } - data, err := protojson.Marshal(value) - if err != nil { - return nil, err - } - return &EncodedValue{Data: data, MediaType: ProtoJSONMediaType}, nil -} - -func DecodeProtoJSON(value *EncodedValue, target proto.Message) error { - if value == nil || target == nil { - return fmt.Errorf("encoded value and target are required") - } - return protojson.Unmarshal(value.Data, target) -} - -func SetProtoExtension(event *Event, namespace string, value proto.Message) error { +// SetTypedExtension packs value into Event.extensions and replaces an existing +// extension with the same protobuf full name. +func SetTypedExtension(event *Event, value proto.Message) error { if event == nil { return fmt.Errorf("event is required") } - encoded, err := ProtoJSONValue(value) + encoded, err := anypb.New(value) if err != nil { return err } for _, extension := range event.Extensions { - if extension.Namespace == namespace { - extension.Value = encoded + if extension != nil && extension.MessageName() == encoded.MessageName() { + extension.TypeUrl = encoded.TypeUrl + extension.Value = encoded.Value return nil } } - event.Extensions = append(event.Extensions, &Extension{Namespace: namespace, Value: encoded}) + event.Extensions = append(event.Extensions, encoded) return nil } -func ProtoExtension(event *Event, namespace string, target proto.Message) (bool, error) { +// FindTypedExtension unmarshals the extension matching target's protobuf full +// name. Unknown extensions are ignored and remain preserved on the Event. +func FindTypedExtension(event *Event, target proto.Message) (bool, error) { + if target == nil { + return false, fmt.Errorf("extension target is required") + } if event == nil { return false, nil } for _, extension := range event.Extensions { - if extension.Namespace == namespace { - return true, DecodeProtoJSON(extension.Value, target) + if extension != nil && extension.MessageIs(target) { + return true, extension.UnmarshalTo(target) } } return false, nil } -func SetJSONExtension(event *Event, namespace string, value any) error { - if event == nil { - return fmt.Errorf("event is required") - } - encoded, err := JSONValue(value) - if err != nil { - return err - } - for _, extension := range event.Extensions { - if extension.Namespace == namespace { - extension.Value = encoded - return nil - } - } - event.Extensions = append(event.Extensions, &Extension{Namespace: namespace, Value: encoded}) - return nil -} - -func GetJSONExtension[T any](event *Event, namespace string) (T, bool, error) { - var zero T - if event == nil { - return zero, false, nil - } - for _, extension := range event.Extensions { - if extension.Namespace == namespace { - value, err := DecodeJSON[T](extension.Value) - return value, true, err - } - } - return zero, false, nil -} - func Text(text string) *Content { return &Content{Value: &Content_Text{Text: &TextContent{Text: text}}} } @@ -158,7 +115,10 @@ func Kind(event *Event) string { case *Event_Status: return "status" case *Event_Extension: - return event.GetExtension().GetType() + if extension := event.GetExtension(); extension != nil { + return string(extension.MessageName()) + } + return "extension" case *Event_ProviderFrame: return "provider.frame" default: diff --git a/aop/interop_fixture_test.go b/aop/interop_fixture_test.go index b8794d7a..d6a5a6a1 100644 --- a/aop/interop_fixture_test.go +++ b/aop/interop_fixture_test.go @@ -1,4 +1,4 @@ -package aop +package aop_test import ( "encoding/base64" @@ -7,12 +7,14 @@ import ( "path/filepath" "testing" + aop "github.com/chainreactors/aiscan/aop" + toolpb "github.com/chainreactors/aiscan/aop/tool" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) type interopFixture struct { - Event json.RawMessage `json:"event"` + Envelope json.RawMessage `json:"envelope"` BinaryBase64 string `json:"binaryBase64"` ProviderPayloads struct { OpenAIBase64 string `json:"openaiBase64"` @@ -30,11 +32,32 @@ func TestInteropFixtureMatchesProtoBinaryAndProtoJSON(t *testing.T) { if err := json.Unmarshal(raw, &fixture); err != nil { t.Fatal(err) } - event := new(Event) - if err := protojson.Unmarshal(fixture.Event, event); err != nil { + envelope := new(aop.Envelope) + if err := protojson.Unmarshal(fixture.Envelope, envelope); err != nil { t.Fatal(err) } - binary, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) + if got := envelope.GetPayload().GetTypeUrl(); got != "type.googleapis.com/aop.ProtocolMessage" { + t.Fatalf("payload type URL = %q", got) + } + core := new(aop.ProtocolMessage) + if err := envelope.GetPayload().UnmarshalTo(core); err != nil { + t.Fatal(err) + } + event := core.GetEvent() + if event == nil { + t.Fatal("fixture payload does not contain an event") + } + if len(event.Extensions) != 1 { + t.Fatalf("event extensions = %d", len(event.Extensions)) + } + progress := new(toolpb.Progress) + if err := event.Extensions[0].UnmarshalTo(progress); err != nil { + t.Fatal(err) + } + if progress.Tool != "fixture-tool" || progress.Text != "fixture progress" { + t.Fatalf("typed extension = %#v", progress) + } + binary, err := proto.MarshalOptions{Deterministic: true}.Marshal(envelope) if err != nil { t.Fatal(err) } @@ -49,12 +72,12 @@ func TestInteropFixtureMatchesProtoBinaryAndProtoJSON(t *testing.T) { if _, err := base64.StdEncoding.DecodeString(fixture.ProviderPayloads.AnthropicBase64); err != nil { t.Fatalf("Anthropic payload: %v", err) } - jsonRoundTrip, err := protojson.Marshal(event) + jsonRoundTrip, err := protojson.Marshal(envelope) if err != nil { t.Fatal(err) } - fromJSON := new(Event) - if err := protojson.Unmarshal(jsonRoundTrip, fromJSON); err != nil || !proto.Equal(event, fromJSON) { + fromJSON := new(aop.Envelope) + if err := protojson.Unmarshal(jsonRoundTrip, fromJSON); err != nil || !proto.Equal(envelope, fromJSON) { t.Fatalf("protobuf JSON round trip failed: %v", err) } } diff --git a/aop/mux.go b/aop/mux.go new file mode 100644 index 00000000..5f0749ec --- /dev/null +++ b/aop/mux.go @@ -0,0 +1,81 @@ +package aop + +import ( + "context" + "fmt" + "strings" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// SendFunc writes one Envelope on the connection owned by the caller. +type SendFunc func(*Envelope) error + +// NamespaceHandler processes one registered top-level namespace message. +// The handler owns business semantics; NamespaceMux only decodes and routes. +type NamespaceHandler func(context.Context, *Envelope, proto.Message, SendFunc) error + +type namespaceEntry struct { + messageType protoreflect.MessageType + handler NamespaceHandler +} + +// NamespaceMux routes Envelope.payload by protobuf full name. Mux instances +// are application-owned; there is no global registry or lifecycle ownership. +type NamespaceMux struct { + handlers map[protoreflect.FullName]namespaceEntry +} + +func NewNamespaceMux() *NamespaceMux { + return &NamespaceMux{handlers: make(map[protoreflect.FullName]namespaceEntry)} +} + +// Register adds one .ProtocolMessage handler. Applications register +// namespaces during construction, before Dispatch is used concurrently. +func (m *NamespaceMux) Register(prototype proto.Message, handler NamespaceHandler) error { + if m == nil { + return fmt.Errorf("namespace mux is required") + } + if prototype == nil || handler == nil { + return fmt.Errorf("namespace prototype and handler are required") + } + descriptor := prototype.ProtoReflect().Descriptor() + name := descriptor.FullName() + if !strings.HasSuffix(string(name), ".ProtocolMessage") && name != "aop.ProtocolMessage" { + return fmt.Errorf("namespace message %q must be named ProtocolMessage", name) + } + if m.handlers == nil { + m.handlers = make(map[protoreflect.FullName]namespaceEntry) + } + if _, exists := m.handlers[name]; exists { + return fmt.Errorf("namespace %q is already registered", name) + } + m.handlers[name] = namespaceEntry{messageType: prototype.ProtoReflect().Type(), handler: handler} + return nil +} + +// Dispatch decodes and handles one registered namespace. Unknown namespaces +// return handled=false so the connection owner can emit its protocol error. +func (m *NamespaceMux) Dispatch(ctx context.Context, envelope *Envelope, send SendFunc) (handled bool, err error) { + if m == nil || envelope == nil || envelope.Payload == nil { + return false, fmt.Errorf("AOP envelope payload is required") + } + name := envelope.Payload.MessageName() + entry, ok := m.handlers[name] + if !ok { + return false, nil + } + canonical := "type.googleapis.com/" + string(name) + if envelope.Payload.TypeUrl != canonical { + return true, fmt.Errorf("non-canonical type URL %q, want %q", envelope.Payload.TypeUrl, canonical) + } + message := entry.messageType.New().Interface() + if err := envelope.Payload.UnmarshalTo(message); err != nil { + return true, fmt.Errorf("decode %s: %w", name, err) + } + if err := entry.handler(ctx, envelope, message, send); err != nil { + return true, err + } + return true, nil +} diff --git a/aop/mux_test.go b/aop/mux_test.go new file mode 100644 index 00000000..574cdc43 --- /dev/null +++ b/aop/mux_test.go @@ -0,0 +1,36 @@ +package aop + +import ( + "context" + "testing" + + filepb "github.com/chainreactors/aiscan/aop/file" + "google.golang.org/protobuf/proto" +) + +func TestNamespaceMuxRegistersAndDispatches(t *testing.T) { + mux := NewNamespaceMux() + called := false + if err := mux.Register(&filepb.ProtocolMessage{}, func(_ context.Context, _ *Envelope, message proto.Message, _ SendFunc) error { + called = message.(*filepb.ProtocolMessage).GetReadRequest().GetPath() == "/tmp/x" + return nil + }); err != nil { + t.Fatal(err) + } + envelope := MustWrap("id", "", &filepb.ProtocolMessage{Message: &filepb.ProtocolMessage_ReadRequest{ReadRequest: &filepb.ReadRequest{Path: "/tmp/x"}}}) + handled, err := mux.Dispatch(context.Background(), envelope, nil) + if err != nil || !handled || !called { + t.Fatalf("handled=%v called=%v err=%v", handled, called, err) + } +} + +func TestNamespaceMuxRejectsDuplicate(t *testing.T) { + mux := NewNamespaceMux() + handler := func(context.Context, *Envelope, proto.Message, SendFunc) error { return nil } + if err := mux.Register(&filepb.ProtocolMessage{}, handler); err != nil { + t.Fatal(err) + } + if err := mux.Register(&filepb.ProtocolMessage{}, handler); err == nil { + t.Fatal("duplicate namespace registration succeeded") + } +} diff --git a/aop/protocol.pb.go b/aop/protocol.pb.go new file mode 100644 index 00000000..98d8414a --- /dev/null +++ b/aop/protocol.pb.go @@ -0,0 +1,1172 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/protocol.proto + +package aop + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AgentRuntimeInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + WorkingDir string `protobuf:"bytes,3,opt,name=working_dir,json=workingDir,proto3" json:"working_dir,omitempty"` + Os string `protobuf:"bytes,4,opt,name=os,proto3" json:"os,omitempty"` + Arch string `protobuf:"bytes,5,opt,name=arch,proto3" json:"arch,omitempty"` + Pid int32 `protobuf:"varint,6,opt,name=pid,proto3" json:"pid,omitempty"` + Metadata *structpb.Struct `protobuf:"bytes,7,opt,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *AgentRuntimeInfo) Reset() { + *x = AgentRuntimeInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_protocol_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentRuntimeInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentRuntimeInfo) ProtoMessage() {} + +func (x *AgentRuntimeInfo) ProtoReflect() protoreflect.Message { + mi := &file_aop_protocol_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentRuntimeInfo.ProtoReflect.Descriptor instead. +func (*AgentRuntimeInfo) Descriptor() ([]byte, []int) { + return file_aop_protocol_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentRuntimeInfo) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *AgentRuntimeInfo) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *AgentRuntimeInfo) GetWorkingDir() string { + if x != nil { + return x.WorkingDir + } + return "" +} + +func (x *AgentRuntimeInfo) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *AgentRuntimeInfo) GetArch() string { + if x != nil { + return x.Arch + } + return "" +} + +func (x *AgentRuntimeInfo) GetPid() int32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *AgentRuntimeInfo) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +type AgentHello struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Authority string `protobuf:"bytes,3,opt,name=authority,proto3" json:"authority,omitempty"` + Capabilities []string `protobuf:"bytes,4,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + Tools []*ToolDefinition `protobuf:"bytes,6,rep,name=tools,proto3" json:"tools,omitempty"` + Runtime *AgentRuntimeInfo `protobuf:"bytes,7,opt,name=runtime,proto3" json:"runtime,omitempty"` +} + +func (x *AgentHello) Reset() { + *x = AgentHello{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_protocol_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentHello) ProtoMessage() {} + +func (x *AgentHello) ProtoReflect() protoreflect.Message { + mi := &file_aop_protocol_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentHello.ProtoReflect.Descriptor instead. +func (*AgentHello) Descriptor() ([]byte, []int) { + return file_aop_protocol_proto_rawDescGZIP(), []int{1} +} + +func (x *AgentHello) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentHello) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AgentHello) GetAuthority() string { + if x != nil { + return x.Authority + } + return "" +} + +func (x *AgentHello) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *AgentHello) GetTools() []*ToolDefinition { + if x != nil { + return x.Tools + } + return nil +} + +func (x *AgentHello) GetRuntime() *AgentRuntimeInfo { + if x != nil { + return x.Runtime + } + return nil +} + +type AgentAccepted struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Capabilities []string `protobuf:"bytes,2,rep,name=capabilities,proto3" json:"capabilities,omitempty"` +} + +func (x *AgentAccepted) Reset() { + *x = AgentAccepted{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_protocol_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentAccepted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentAccepted) ProtoMessage() {} + +func (x *AgentAccepted) ProtoReflect() protoreflect.Message { + mi := &file_aop_protocol_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentAccepted.ProtoReflect.Descriptor instead. +func (*AgentAccepted) Descriptor() ([]byte, []int) { + return file_aop_protocol_proto_rawDescGZIP(), []int{2} +} + +func (x *AgentAccepted) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentAccepted) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +type AgentStatus struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + Model string `protobuf:"bytes,2,opt,name=model,proto3" json:"model,omitempty"` + Space string `protobuf:"bytes,3,opt,name=space,proto3" json:"space,omitempty"` + Bound bool `protobuf:"varint,4,opt,name=bound,proto3" json:"bound,omitempty"` + ConfigError string `protobuf:"bytes,6,opt,name=config_error,json=configError,proto3" json:"config_error,omitempty"` +} + +func (x *AgentStatus) Reset() { + *x = AgentStatus{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_protocol_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentStatus) ProtoMessage() {} + +func (x *AgentStatus) ProtoReflect() protoreflect.Message { + mi := &file_aop_protocol_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentStatus.ProtoReflect.Descriptor instead. +func (*AgentStatus) Descriptor() ([]byte, []int) { + return file_aop_protocol_proto_rawDescGZIP(), []int{3} +} + +func (x *AgentStatus) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *AgentStatus) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *AgentStatus) GetSpace() string { + if x != nil { + return x.Space + } + return "" +} + +func (x *AgentStatus) GetBound() bool { + if x != nil { + return x.Bound + } + return false +} + +func (x *AgentStatus) GetConfigError() string { + if x != nil { + return x.ConfigError + } + return "" +} + +type AgentStats struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Turns uint64 `protobuf:"varint,1,opt,name=turns,proto3" json:"turns,omitempty"` + ToolCalls uint64 `protobuf:"varint,2,opt,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"` + RunningTools uint64 `protobuf:"varint,3,opt,name=running_tools,json=runningTools,proto3" json:"running_tools,omitempty"` + InputTokens uint64 `protobuf:"varint,4,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + OutputTokens uint64 `protobuf:"varint,5,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + TotalTokens uint64 `protobuf:"varint,6,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + CacheReadTokens uint64 `protobuf:"varint,7,opt,name=cache_read_tokens,json=cacheReadTokens,proto3" json:"cache_read_tokens,omitempty"` + CacheWriteTokens uint64 `protobuf:"varint,8,opt,name=cache_write_tokens,json=cacheWriteTokens,proto3" json:"cache_write_tokens,omitempty"` + LastEvent string `protobuf:"bytes,11,opt,name=last_event,json=lastEvent,proto3" json:"last_event,omitempty"` +} + +func (x *AgentStats) Reset() { + *x = AgentStats{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_protocol_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentStats) ProtoMessage() {} + +func (x *AgentStats) ProtoReflect() protoreflect.Message { + mi := &file_aop_protocol_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentStats.ProtoReflect.Descriptor instead. +func (*AgentStats) Descriptor() ([]byte, []int) { + return file_aop_protocol_proto_rawDescGZIP(), []int{4} +} + +func (x *AgentStats) GetTurns() uint64 { + if x != nil { + return x.Turns + } + return 0 +} + +func (x *AgentStats) GetToolCalls() uint64 { + if x != nil { + return x.ToolCalls + } + return 0 +} + +func (x *AgentStats) GetRunningTools() uint64 { + if x != nil { + return x.RunningTools + } + return 0 +} + +func (x *AgentStats) GetInputTokens() uint64 { + if x != nil { + return x.InputTokens + } + return 0 +} + +func (x *AgentStats) GetOutputTokens() uint64 { + if x != nil { + return x.OutputTokens + } + return 0 +} + +func (x *AgentStats) GetTotalTokens() uint64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *AgentStats) GetCacheReadTokens() uint64 { + if x != nil { + return x.CacheReadTokens + } + return 0 +} + +func (x *AgentStats) GetCacheWriteTokens() uint64 { + if x != nil { + return x.CacheWriteTokens + } + return 0 +} + +func (x *AgentStats) GetLastEvent() string { + if x != nil { + return x.LastEvent + } + return "" +} + +type CancelOperation struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TargetId string `protobuf:"bytes,1,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *CancelOperation) Reset() { + *x = CancelOperation{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_protocol_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelOperation) ProtoMessage() {} + +func (x *CancelOperation) ProtoReflect() protoreflect.Message { + mi := &file_aop_protocol_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelOperation.ProtoReflect.Descriptor instead. +func (*CancelOperation) Descriptor() ([]byte, []int) { + return file_aop_protocol_proto_rawDescGZIP(), []int{5} +} + +func (x *CancelOperation) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *CancelOperation) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// ProtocolMessage is the typed union for the AOP core namespace. Extension +// packages define their own ProtocolMessage and do not modify this one. +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_AgentHello + // *ProtocolMessage_AgentAccepted + // *ProtocolMessage_AgentStatus + // *ProtocolMessage_AgentStats + // *ProtocolMessage_OpenSessionRequest + // *ProtocolMessage_OpenSessionResponse + // *ProtocolMessage_RunTurnRequest + // *ProtocolMessage_RunTurnResponse + // *ProtocolMessage_CancelTurnRequest + // *ProtocolMessage_CancelTurnResponse + // *ProtocolMessage_CloseSessionRequest + // *ProtocolMessage_CloseSessionResponse + // *ProtocolMessage_WatchEventsRequest + // *ProtocolMessage_ListEventsRequest + // *ProtocolMessage_ListEventsResponse + // *ProtocolMessage_Event + // *ProtocolMessage_CancelOperation + // *ProtocolMessage_ProtocolError + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_protocol_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aop_protocol_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aop_protocol_proto_rawDescGZIP(), []int{6} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetAgentHello() *AgentHello { + if x, ok := x.GetMessage().(*ProtocolMessage_AgentHello); ok { + return x.AgentHello + } + return nil +} + +func (x *ProtocolMessage) GetAgentAccepted() *AgentAccepted { + if x, ok := x.GetMessage().(*ProtocolMessage_AgentAccepted); ok { + return x.AgentAccepted + } + return nil +} + +func (x *ProtocolMessage) GetAgentStatus() *AgentStatus { + if x, ok := x.GetMessage().(*ProtocolMessage_AgentStatus); ok { + return x.AgentStatus + } + return nil +} + +func (x *ProtocolMessage) GetAgentStats() *AgentStats { + if x, ok := x.GetMessage().(*ProtocolMessage_AgentStats); ok { + return x.AgentStats + } + return nil +} + +func (x *ProtocolMessage) GetOpenSessionRequest() *OpenSessionRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_OpenSessionRequest); ok { + return x.OpenSessionRequest + } + return nil +} + +func (x *ProtocolMessage) GetOpenSessionResponse() *OpenSessionResponse { + if x, ok := x.GetMessage().(*ProtocolMessage_OpenSessionResponse); ok { + return x.OpenSessionResponse + } + return nil +} + +func (x *ProtocolMessage) GetRunTurnRequest() *RunTurnRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_RunTurnRequest); ok { + return x.RunTurnRequest + } + return nil +} + +func (x *ProtocolMessage) GetRunTurnResponse() *RunTurnResponse { + if x, ok := x.GetMessage().(*ProtocolMessage_RunTurnResponse); ok { + return x.RunTurnResponse + } + return nil +} + +func (x *ProtocolMessage) GetCancelTurnRequest() *CancelTurnRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_CancelTurnRequest); ok { + return x.CancelTurnRequest + } + return nil +} + +func (x *ProtocolMessage) GetCancelTurnResponse() *CancelTurnResponse { + if x, ok := x.GetMessage().(*ProtocolMessage_CancelTurnResponse); ok { + return x.CancelTurnResponse + } + return nil +} + +func (x *ProtocolMessage) GetCloseSessionRequest() *CloseSessionRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_CloseSessionRequest); ok { + return x.CloseSessionRequest + } + return nil +} + +func (x *ProtocolMessage) GetCloseSessionResponse() *CloseSessionResponse { + if x, ok := x.GetMessage().(*ProtocolMessage_CloseSessionResponse); ok { + return x.CloseSessionResponse + } + return nil +} + +func (x *ProtocolMessage) GetWatchEventsRequest() *WatchEventsRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_WatchEventsRequest); ok { + return x.WatchEventsRequest + } + return nil +} + +func (x *ProtocolMessage) GetListEventsRequest() *ListEventsRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_ListEventsRequest); ok { + return x.ListEventsRequest + } + return nil +} + +func (x *ProtocolMessage) GetListEventsResponse() *ListEventsResponse { + if x, ok := x.GetMessage().(*ProtocolMessage_ListEventsResponse); ok { + return x.ListEventsResponse + } + return nil +} + +func (x *ProtocolMessage) GetEvent() *Event { + if x, ok := x.GetMessage().(*ProtocolMessage_Event); ok { + return x.Event + } + return nil +} + +func (x *ProtocolMessage) GetCancelOperation() *CancelOperation { + if x, ok := x.GetMessage().(*ProtocolMessage_CancelOperation); ok { + return x.CancelOperation + } + return nil +} + +func (x *ProtocolMessage) GetProtocolError() *ProtocolError { + if x, ok := x.GetMessage().(*ProtocolMessage_ProtocolError); ok { + return x.ProtocolError + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_AgentHello struct { + AgentHello *AgentHello `protobuf:"bytes,10,opt,name=agent_hello,json=agentHello,proto3,oneof"` +} + +type ProtocolMessage_AgentAccepted struct { + AgentAccepted *AgentAccepted `protobuf:"bytes,11,opt,name=agent_accepted,json=agentAccepted,proto3,oneof"` +} + +type ProtocolMessage_AgentStatus struct { + AgentStatus *AgentStatus `protobuf:"bytes,12,opt,name=agent_status,json=agentStatus,proto3,oneof"` +} + +type ProtocolMessage_AgentStats struct { + AgentStats *AgentStats `protobuf:"bytes,13,opt,name=agent_stats,json=agentStats,proto3,oneof"` +} + +type ProtocolMessage_OpenSessionRequest struct { + OpenSessionRequest *OpenSessionRequest `protobuf:"bytes,20,opt,name=open_session_request,json=openSessionRequest,proto3,oneof"` +} + +type ProtocolMessage_OpenSessionResponse struct { + OpenSessionResponse *OpenSessionResponse `protobuf:"bytes,21,opt,name=open_session_response,json=openSessionResponse,proto3,oneof"` +} + +type ProtocolMessage_RunTurnRequest struct { + RunTurnRequest *RunTurnRequest `protobuf:"bytes,22,opt,name=run_turn_request,json=runTurnRequest,proto3,oneof"` +} + +type ProtocolMessage_RunTurnResponse struct { + RunTurnResponse *RunTurnResponse `protobuf:"bytes,23,opt,name=run_turn_response,json=runTurnResponse,proto3,oneof"` +} + +type ProtocolMessage_CancelTurnRequest struct { + CancelTurnRequest *CancelTurnRequest `protobuf:"bytes,24,opt,name=cancel_turn_request,json=cancelTurnRequest,proto3,oneof"` +} + +type ProtocolMessage_CancelTurnResponse struct { + CancelTurnResponse *CancelTurnResponse `protobuf:"bytes,25,opt,name=cancel_turn_response,json=cancelTurnResponse,proto3,oneof"` +} + +type ProtocolMessage_CloseSessionRequest struct { + CloseSessionRequest *CloseSessionRequest `protobuf:"bytes,26,opt,name=close_session_request,json=closeSessionRequest,proto3,oneof"` +} + +type ProtocolMessage_CloseSessionResponse struct { + CloseSessionResponse *CloseSessionResponse `protobuf:"bytes,27,opt,name=close_session_response,json=closeSessionResponse,proto3,oneof"` +} + +type ProtocolMessage_WatchEventsRequest struct { + WatchEventsRequest *WatchEventsRequest `protobuf:"bytes,28,opt,name=watch_events_request,json=watchEventsRequest,proto3,oneof"` +} + +type ProtocolMessage_ListEventsRequest struct { + ListEventsRequest *ListEventsRequest `protobuf:"bytes,29,opt,name=list_events_request,json=listEventsRequest,proto3,oneof"` +} + +type ProtocolMessage_ListEventsResponse struct { + ListEventsResponse *ListEventsResponse `protobuf:"bytes,30,opt,name=list_events_response,json=listEventsResponse,proto3,oneof"` +} + +type ProtocolMessage_Event struct { + Event *Event `protobuf:"bytes,31,opt,name=event,proto3,oneof"` +} + +type ProtocolMessage_CancelOperation struct { + CancelOperation *CancelOperation `protobuf:"bytes,40,opt,name=cancel_operation,json=cancelOperation,proto3,oneof"` +} + +type ProtocolMessage_ProtocolError struct { + ProtocolError *ProtocolError `protobuf:"bytes,41,opt,name=protocol_error,json=protocolError,proto3,oneof"` +} + +func (*ProtocolMessage_AgentHello) isProtocolMessage_Message() {} + +func (*ProtocolMessage_AgentAccepted) isProtocolMessage_Message() {} + +func (*ProtocolMessage_AgentStatus) isProtocolMessage_Message() {} + +func (*ProtocolMessage_AgentStats) isProtocolMessage_Message() {} + +func (*ProtocolMessage_OpenSessionRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_OpenSessionResponse) isProtocolMessage_Message() {} + +func (*ProtocolMessage_RunTurnRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_RunTurnResponse) isProtocolMessage_Message() {} + +func (*ProtocolMessage_CancelTurnRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_CancelTurnResponse) isProtocolMessage_Message() {} + +func (*ProtocolMessage_CloseSessionRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_CloseSessionResponse) isProtocolMessage_Message() {} + +func (*ProtocolMessage_WatchEventsRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_ListEventsRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_ListEventsResponse) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Event) isProtocolMessage_Message() {} + +func (*ProtocolMessage_CancelOperation) isProtocolMessage_Message() {} + +func (*ProtocolMessage_ProtocolError) isProtocolMessage_Message() {} + +var File_aop_protocol_proto protoreflect.FileDescriptor + +var file_aop_protocol_proto_rawDesc = []byte{ + 0x0a, 0x12, 0x61, 0x6f, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x6f, 0x70, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, + 0x68, 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, + 0x70, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1c, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x73, + 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xd6, 0x01, 0x0a, 0x10, + 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x49, 0x6e, 0x66, 0x6f, + 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, + 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x6f, 0x72, 0x6b, + 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x69, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x77, + 0x6f, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x44, 0x69, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x6f, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x63, + 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x63, 0x68, 0x12, 0x10, 0x0a, + 0x03, 0x70, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, + 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x22, 0xdf, 0x01, 0x0a, 0x0a, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x48, 0x65, + 0x6c, 0x6c, 0x6f, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, + 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x69, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x44, 0x65, + 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x12, + 0x2f, 0x0a, 0x07, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x15, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x75, 0x6e, 0x74, + 0x69, 0x6d, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, + 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x4e, 0x0a, 0x0d, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, + 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, + 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, + 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x62, + 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0xd6, 0x02, + 0x0a, 0x0a, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, + 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x74, 0x75, 0x72, + 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, + 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x6f, 0x6f, + 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, + 0x67, 0x54, 0x6f, 0x6f, 0x6c, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x69, 0x6e, + 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x21, + 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0f, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x2c, 0x0a, + 0x12, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x04, 0x52, 0x10, 0x63, 0x61, 0x63, 0x68, 0x65, + 0x57, 0x72, 0x69, 0x74, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, + 0x61, 0x73, 0x74, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6c, 0x61, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, + 0x4a, 0x04, 0x08, 0x0a, 0x10, 0x0b, 0x22, 0x46, 0x0a, 0x0f, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, + 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x61, 0x72, + 0x67, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x61, + 0x72, 0x67, 0x65, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0xdc, + 0x09, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x32, 0x0a, 0x0b, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x6c, 0x6c, + 0x6f, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x3b, 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, + 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x65, 0x64, 0x48, 0x00, 0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x0b, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, 0x0a, 0x0b, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, + 0x48, 0x00, 0x52, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x4b, + 0x0a, 0x14, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x12, 0x6f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x15, 0x6f, + 0x70, 0x65, 0x6e, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x13, 0x6f, 0x70, 0x65, 0x6e, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x10, 0x72, + 0x75, 0x6e, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, + 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, 0x6e, 0x54, + 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0e, 0x72, 0x75, + 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x42, 0x0a, 0x11, + 0x72, 0x75, 0x6e, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x75, + 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, + 0x0f, 0x72, 0x75, 0x6e, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x48, 0x0a, 0x13, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x5f, + 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x11, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, + 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4b, 0x0a, 0x14, 0x63, 0x61, + 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x18, 0x19, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x48, 0x00, 0x52, 0x12, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x75, 0x72, 0x6e, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x15, 0x63, 0x6c, 0x6f, 0x73, 0x65, + 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x18, 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, 0x6f, + 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x48, 0x00, 0x52, 0x13, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x51, 0x0a, 0x16, 0x63, 0x6c, 0x6f, 0x73, 0x65, + 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x43, 0x6c, + 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x48, 0x00, 0x52, 0x14, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x14, 0x77, 0x61, + 0x74, 0x63, 0x68, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x18, 0x1c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x57, + 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x48, 0x00, 0x52, 0x12, 0x77, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x48, 0x0a, 0x13, 0x6c, 0x69, 0x73, 0x74, 0x5f, + 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x1d, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x11, + 0x6c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x4b, 0x0a, 0x14, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x12, 0x6c, 0x69, 0x73, 0x74, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, + 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x1f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x65, 0x76, 0x65, + 0x6e, 0x74, 0x12, 0x41, 0x0a, 0x10, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x5f, 0x6f, 0x70, 0x65, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x28, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x0f, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3b, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, + 0x6c, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x29, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x48, 0x00, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x25, 0x5a, + 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, + 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2f, 0x61, 0x6f, 0x70, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_protocol_proto_rawDescOnce sync.Once + file_aop_protocol_proto_rawDescData = file_aop_protocol_proto_rawDesc +) + +func file_aop_protocol_proto_rawDescGZIP() []byte { + file_aop_protocol_proto_rawDescOnce.Do(func() { + file_aop_protocol_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_protocol_proto_rawDescData) + }) + return file_aop_protocol_proto_rawDescData +} + +var file_aop_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_aop_protocol_proto_goTypes = []interface{}{ + (*AgentRuntimeInfo)(nil), // 0: aop.AgentRuntimeInfo + (*AgentHello)(nil), // 1: aop.AgentHello + (*AgentAccepted)(nil), // 2: aop.AgentAccepted + (*AgentStatus)(nil), // 3: aop.AgentStatus + (*AgentStats)(nil), // 4: aop.AgentStats + (*CancelOperation)(nil), // 5: aop.CancelOperation + (*ProtocolMessage)(nil), // 6: aop.ProtocolMessage + (*structpb.Struct)(nil), // 7: google.protobuf.Struct + (*ToolDefinition)(nil), // 8: aop.ToolDefinition + (*OpenSessionRequest)(nil), // 9: aop.OpenSessionRequest + (*OpenSessionResponse)(nil), // 10: aop.OpenSessionResponse + (*RunTurnRequest)(nil), // 11: aop.RunTurnRequest + (*RunTurnResponse)(nil), // 12: aop.RunTurnResponse + (*CancelTurnRequest)(nil), // 13: aop.CancelTurnRequest + (*CancelTurnResponse)(nil), // 14: aop.CancelTurnResponse + (*CloseSessionRequest)(nil), // 15: aop.CloseSessionRequest + (*CloseSessionResponse)(nil), // 16: aop.CloseSessionResponse + (*WatchEventsRequest)(nil), // 17: aop.WatchEventsRequest + (*ListEventsRequest)(nil), // 18: aop.ListEventsRequest + (*ListEventsResponse)(nil), // 19: aop.ListEventsResponse + (*Event)(nil), // 20: aop.Event + (*ProtocolError)(nil), // 21: aop.ProtocolError +} +var file_aop_protocol_proto_depIdxs = []int32{ + 7, // 0: aop.AgentRuntimeInfo.metadata:type_name -> google.protobuf.Struct + 8, // 1: aop.AgentHello.tools:type_name -> aop.ToolDefinition + 0, // 2: aop.AgentHello.runtime:type_name -> aop.AgentRuntimeInfo + 1, // 3: aop.ProtocolMessage.agent_hello:type_name -> aop.AgentHello + 2, // 4: aop.ProtocolMessage.agent_accepted:type_name -> aop.AgentAccepted + 3, // 5: aop.ProtocolMessage.agent_status:type_name -> aop.AgentStatus + 4, // 6: aop.ProtocolMessage.agent_stats:type_name -> aop.AgentStats + 9, // 7: aop.ProtocolMessage.open_session_request:type_name -> aop.OpenSessionRequest + 10, // 8: aop.ProtocolMessage.open_session_response:type_name -> aop.OpenSessionResponse + 11, // 9: aop.ProtocolMessage.run_turn_request:type_name -> aop.RunTurnRequest + 12, // 10: aop.ProtocolMessage.run_turn_response:type_name -> aop.RunTurnResponse + 13, // 11: aop.ProtocolMessage.cancel_turn_request:type_name -> aop.CancelTurnRequest + 14, // 12: aop.ProtocolMessage.cancel_turn_response:type_name -> aop.CancelTurnResponse + 15, // 13: aop.ProtocolMessage.close_session_request:type_name -> aop.CloseSessionRequest + 16, // 14: aop.ProtocolMessage.close_session_response:type_name -> aop.CloseSessionResponse + 17, // 15: aop.ProtocolMessage.watch_events_request:type_name -> aop.WatchEventsRequest + 18, // 16: aop.ProtocolMessage.list_events_request:type_name -> aop.ListEventsRequest + 19, // 17: aop.ProtocolMessage.list_events_response:type_name -> aop.ListEventsResponse + 20, // 18: aop.ProtocolMessage.event:type_name -> aop.Event + 5, // 19: aop.ProtocolMessage.cancel_operation:type_name -> aop.CancelOperation + 21, // 20: aop.ProtocolMessage.protocol_error:type_name -> aop.ProtocolError + 21, // [21:21] is the sub-list for method output_type + 21, // [21:21] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name +} + +func init() { file_aop_protocol_proto_init() } +func file_aop_protocol_proto_init() { + if File_aop_protocol_proto != nil { + return + } + file_aop_chat_proto_init() + file_aop_content_proto_init() + file_aop_event_proto_init() + if !protoimpl.UnsafeEnabled { + file_aop_protocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentRuntimeInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_protocol_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentHello); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_protocol_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentAccepted); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_protocol_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentStatus); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_protocol_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentStats); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_protocol_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelOperation); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_protocol_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_protocol_proto_msgTypes[6].OneofWrappers = []interface{}{ + (*ProtocolMessage_AgentHello)(nil), + (*ProtocolMessage_AgentAccepted)(nil), + (*ProtocolMessage_AgentStatus)(nil), + (*ProtocolMessage_AgentStats)(nil), + (*ProtocolMessage_OpenSessionRequest)(nil), + (*ProtocolMessage_OpenSessionResponse)(nil), + (*ProtocolMessage_RunTurnRequest)(nil), + (*ProtocolMessage_RunTurnResponse)(nil), + (*ProtocolMessage_CancelTurnRequest)(nil), + (*ProtocolMessage_CancelTurnResponse)(nil), + (*ProtocolMessage_CloseSessionRequest)(nil), + (*ProtocolMessage_CloseSessionResponse)(nil), + (*ProtocolMessage_WatchEventsRequest)(nil), + (*ProtocolMessage_ListEventsRequest)(nil), + (*ProtocolMessage_ListEventsResponse)(nil), + (*ProtocolMessage_Event)(nil), + (*ProtocolMessage_CancelOperation)(nil), + (*ProtocolMessage_ProtocolError)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_protocol_proto_rawDesc, + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_protocol_proto_goTypes, + DependencyIndexes: file_aop_protocol_proto_depIdxs, + MessageInfos: file_aop_protocol_proto_msgTypes, + }.Build() + File_aop_protocol_proto = out.File + file_aop_protocol_proto_rawDesc = nil + file_aop_protocol_proto_goTypes = nil + file_aop_protocol_proto_depIdxs = nil +} diff --git a/aop/pty/protocol.pb.go b/aop/pty/protocol.pb.go new file mode 100644 index 00000000..849d4718 --- /dev/null +++ b/aop/pty/protocol.pb.go @@ -0,0 +1,1869 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/pty/protocol.proto + +package pty + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Session struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Command string `protobuf:"bytes,4,opt,name=command,proto3" json:"command,omitempty"` + Pid int32 `protobuf:"varint,5,opt,name=pid,proto3" json:"pid,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + LastActivityAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_activity_at,json=lastActivityAt,proto3" json:"last_activity_at,omitempty"` + EndedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + ActivitySeq int64 `protobuf:"varint,9,opt,name=activity_seq,json=activitySeq,proto3" json:"activity_seq,omitempty"` + OutputBytes int64 `protobuf:"varint,10,opt,name=output_bytes,json=outputBytes,proto3" json:"output_bytes,omitempty"` + ExitCode int32 `protobuf:"varint,11,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + State string `protobuf:"bytes,12,opt,name=state,proto3" json:"state,omitempty"` + KillCause string `protobuf:"bytes,13,opt,name=kill_cause,json=killCause,proto3" json:"kill_cause,omitempty"` +} + +func (x *Session) Reset() { + *x = Session{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Session) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Session) ProtoMessage() {} + +func (x *Session) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Session.ProtoReflect.Descriptor instead. +func (*Session) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{0} +} + +func (x *Session) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Session) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *Session) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Session) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *Session) GetPid() int32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *Session) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +func (x *Session) GetLastActivityAt() *timestamppb.Timestamp { + if x != nil { + return x.LastActivityAt + } + return nil +} + +func (x *Session) GetEndedAt() *timestamppb.Timestamp { + if x != nil { + return x.EndedAt + } + return nil +} + +func (x *Session) GetActivitySeq() int64 { + if x != nil { + return x.ActivitySeq + } + return 0 +} + +func (x *Session) GetOutputBytes() int64 { + if x != nil { + return x.OutputBytes + } + return 0 +} + +func (x *Session) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *Session) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Session) GetKillCause() string { + if x != nil { + return x.KillCause + } + return "" +} + +type Open struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + NodeUri string `protobuf:"bytes,2,opt,name=node_uri,json=nodeUri,proto3" json:"node_uri,omitempty"` + Kind string `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + Command string `protobuf:"bytes,5,opt,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,6,rep,name=args,proto3" json:"args,omitempty"` + Cols int32 `protobuf:"varint,7,opt,name=cols,proto3" json:"cols,omitempty"` + Rows int32 `protobuf:"varint,8,opt,name=rows,proto3" json:"rows,omitempty"` + Singleton bool `protobuf:"varint,9,opt,name=singleton,proto3" json:"singleton,omitempty"` +} + +func (x *Open) Reset() { + *x = Open{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Open) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Open) ProtoMessage() {} + +func (x *Open) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Open.ProtoReflect.Descriptor instead. +func (*Open) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{1} +} + +func (x *Open) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Open) GetNodeUri() string { + if x != nil { + return x.NodeUri + } + return "" +} + +func (x *Open) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *Open) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Open) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *Open) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *Open) GetCols() int32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *Open) GetRows() int32 { + if x != nil { + return x.Rows + } + return 0 +} + +func (x *Open) GetSingleton() bool { + if x != nil { + return x.Singleton + } + return false +} + +type Opened struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Session *Session `protobuf:"bytes,2,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *Opened) Reset() { + *x = Opened{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Opened) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Opened) ProtoMessage() {} + +func (x *Opened) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Opened.ProtoReflect.Descriptor instead. +func (*Opened) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{2} +} + +func (x *Opened) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Opened) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +type Input struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *Input) Reset() { + *x = Input{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Input) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Input) ProtoMessage() {} + +func (x *Input) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Input.ProtoReflect.Descriptor instead. +func (*Input) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{3} +} + +func (x *Input) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Input) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type Output struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + Offset int64 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` +} + +func (x *Output) Reset() { + *x = Output{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Output) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Output) ProtoMessage() {} + +func (x *Output) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Output.ProtoReflect.Descriptor instead. +func (*Output) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{4} +} + +func (x *Output) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Output) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *Output) GetOffset() int64 { + if x != nil { + return x.Offset + } + return 0 +} + +type Resize struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Cols int32 `protobuf:"varint,2,opt,name=cols,proto3" json:"cols,omitempty"` + Rows int32 `protobuf:"varint,3,opt,name=rows,proto3" json:"rows,omitempty"` +} + +func (x *Resize) Reset() { + *x = Resize{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Resize) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Resize) ProtoMessage() {} + +func (x *Resize) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Resize.ProtoReflect.Descriptor instead. +func (*Resize) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{5} +} + +func (x *Resize) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Resize) GetCols() int32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *Resize) GetRows() int32 { + if x != nil { + return x.Rows + } + return 0 +} + +type List struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + NodeUri string `protobuf:"bytes,2,opt,name=node_uri,json=nodeUri,proto3" json:"node_uri,omitempty"` +} + +func (x *List) Reset() { + *x = List{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *List) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*List) ProtoMessage() {} + +func (x *List) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use List.ProtoReflect.Descriptor instead. +func (*List) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{6} +} + +func (x *List) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *List) GetNodeUri() string { + if x != nil { + return x.NodeUri + } + return "" +} + +type Sessions struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Sessions []*Session `protobuf:"bytes,2,rep,name=sessions,proto3" json:"sessions,omitempty"` +} + +func (x *Sessions) Reset() { + *x = Sessions{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Sessions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Sessions) ProtoMessage() {} + +func (x *Sessions) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Sessions.ProtoReflect.Descriptor instead. +func (*Sessions) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{7} +} + +func (x *Sessions) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Sessions) GetSessions() []*Session { + if x != nil { + return x.Sessions + } + return nil +} + +type Attach struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Cols int32 `protobuf:"varint,3,opt,name=cols,proto3" json:"cols,omitempty"` + Rows int32 `protobuf:"varint,4,opt,name=rows,proto3" json:"rows,omitempty"` +} + +func (x *Attach) Reset() { + *x = Attach{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Attach) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Attach) ProtoMessage() {} + +func (x *Attach) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Attach.ProtoReflect.Descriptor instead. +func (*Attach) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{8} +} + +func (x *Attach) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Attach) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Attach) GetCols() int32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *Attach) GetRows() int32 { + if x != nil { + return x.Rows + } + return 0 +} + +type Attached struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Session *Session `protobuf:"bytes,2,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *Attached) Reset() { + *x = Attached{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Attached) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Attached) ProtoMessage() {} + +func (x *Attached) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Attached.ProtoReflect.Descriptor instead. +func (*Attached) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{9} +} + +func (x *Attached) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Attached) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +type Detach struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` +} + +func (x *Detach) Reset() { + *x = Detach{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Detach) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Detach) ProtoMessage() {} + +func (x *Detach) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Detach.ProtoReflect.Descriptor instead. +func (*Detach) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{10} +} + +func (x *Detach) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +type Detached struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` +} + +func (x *Detached) Reset() { + *x = Detached{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Detached) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Detached) ProtoMessage() {} + +func (x *Detached) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Detached.ProtoReflect.Descriptor instead. +func (*Detached) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{11} +} + +func (x *Detached) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +type Kill struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` +} + +func (x *Kill) Reset() { + *x = Kill{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Kill) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Kill) ProtoMessage() {} + +func (x *Kill) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Kill.ProtoReflect.Descriptor instead. +func (*Kill) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{12} +} + +func (x *Kill) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +type Close struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` +} + +func (x *Close) Reset() { + *x = Close{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Close) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Close) ProtoMessage() {} + +func (x *Close) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Close.ProtoReflect.Descriptor instead. +func (*Close) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{13} +} + +func (x *Close) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +type Closed struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Session *Session `protobuf:"bytes,2,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *Closed) Reset() { + *x = Closed{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Closed) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Closed) ProtoMessage() {} + +func (x *Closed) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Closed.ProtoReflect.Descriptor instead. +func (*Closed) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{14} +} + +func (x *Closed) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Closed) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +type State struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Session *Session `protobuf:"bytes,2,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *State) Reset() { + *x = State{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *State) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*State) ProtoMessage() {} + +func (x *State) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use State.ProtoReflect.Descriptor instead. +func (*State) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{15} +} + +func (x *State) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *State) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +type Error struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *Error) Reset() { + *x = Error{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Error) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Error) ProtoMessage() {} + +func (x *Error) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Error.ProtoReflect.Descriptor instead. +func (*Error) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{16} +} + +func (x *Error) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *Error) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_Open + // *ProtocolMessage_Input + // *ProtocolMessage_Output + // *ProtocolMessage_Resize + // *ProtocolMessage_List + // *ProtocolMessage_Sessions + // *ProtocolMessage_Attach + // *ProtocolMessage_Detach + // *ProtocolMessage_Close + // *ProtocolMessage_State + // *ProtocolMessage_Error + // *ProtocolMessage_Opened + // *ProtocolMessage_Attached + // *ProtocolMessage_Detached + // *ProtocolMessage_Kill + // *ProtocolMessage_Closed + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_pty_protocol_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aop_pty_protocol_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aop_pty_protocol_proto_rawDescGZIP(), []int{17} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetOpen() *Open { + if x, ok := x.GetMessage().(*ProtocolMessage_Open); ok { + return x.Open + } + return nil +} + +func (x *ProtocolMessage) GetInput() *Input { + if x, ok := x.GetMessage().(*ProtocolMessage_Input); ok { + return x.Input + } + return nil +} + +func (x *ProtocolMessage) GetOutput() *Output { + if x, ok := x.GetMessage().(*ProtocolMessage_Output); ok { + return x.Output + } + return nil +} + +func (x *ProtocolMessage) GetResize() *Resize { + if x, ok := x.GetMessage().(*ProtocolMessage_Resize); ok { + return x.Resize + } + return nil +} + +func (x *ProtocolMessage) GetList() *List { + if x, ok := x.GetMessage().(*ProtocolMessage_List); ok { + return x.List + } + return nil +} + +func (x *ProtocolMessage) GetSessions() *Sessions { + if x, ok := x.GetMessage().(*ProtocolMessage_Sessions); ok { + return x.Sessions + } + return nil +} + +func (x *ProtocolMessage) GetAttach() *Attach { + if x, ok := x.GetMessage().(*ProtocolMessage_Attach); ok { + return x.Attach + } + return nil +} + +func (x *ProtocolMessage) GetDetach() *Detach { + if x, ok := x.GetMessage().(*ProtocolMessage_Detach); ok { + return x.Detach + } + return nil +} + +func (x *ProtocolMessage) GetClose() *Close { + if x, ok := x.GetMessage().(*ProtocolMessage_Close); ok { + return x.Close + } + return nil +} + +func (x *ProtocolMessage) GetState() *State { + if x, ok := x.GetMessage().(*ProtocolMessage_State); ok { + return x.State + } + return nil +} + +func (x *ProtocolMessage) GetError() *Error { + if x, ok := x.GetMessage().(*ProtocolMessage_Error); ok { + return x.Error + } + return nil +} + +func (x *ProtocolMessage) GetOpened() *Opened { + if x, ok := x.GetMessage().(*ProtocolMessage_Opened); ok { + return x.Opened + } + return nil +} + +func (x *ProtocolMessage) GetAttached() *Attached { + if x, ok := x.GetMessage().(*ProtocolMessage_Attached); ok { + return x.Attached + } + return nil +} + +func (x *ProtocolMessage) GetDetached() *Detached { + if x, ok := x.GetMessage().(*ProtocolMessage_Detached); ok { + return x.Detached + } + return nil +} + +func (x *ProtocolMessage) GetKill() *Kill { + if x, ok := x.GetMessage().(*ProtocolMessage_Kill); ok { + return x.Kill + } + return nil +} + +func (x *ProtocolMessage) GetClosed() *Closed { + if x, ok := x.GetMessage().(*ProtocolMessage_Closed); ok { + return x.Closed + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_Open struct { + Open *Open `protobuf:"bytes,10,opt,name=open,proto3,oneof"` +} + +type ProtocolMessage_Input struct { + Input *Input `protobuf:"bytes,11,opt,name=input,proto3,oneof"` +} + +type ProtocolMessage_Output struct { + Output *Output `protobuf:"bytes,12,opt,name=output,proto3,oneof"` +} + +type ProtocolMessage_Resize struct { + Resize *Resize `protobuf:"bytes,13,opt,name=resize,proto3,oneof"` +} + +type ProtocolMessage_List struct { + List *List `protobuf:"bytes,14,opt,name=list,proto3,oneof"` +} + +type ProtocolMessage_Sessions struct { + Sessions *Sessions `protobuf:"bytes,15,opt,name=sessions,proto3,oneof"` +} + +type ProtocolMessage_Attach struct { + Attach *Attach `protobuf:"bytes,16,opt,name=attach,proto3,oneof"` +} + +type ProtocolMessage_Detach struct { + Detach *Detach `protobuf:"bytes,17,opt,name=detach,proto3,oneof"` +} + +type ProtocolMessage_Close struct { + Close *Close `protobuf:"bytes,18,opt,name=close,proto3,oneof"` +} + +type ProtocolMessage_State struct { + State *State `protobuf:"bytes,19,opt,name=state,proto3,oneof"` +} + +type ProtocolMessage_Error struct { + Error *Error `protobuf:"bytes,20,opt,name=error,proto3,oneof"` +} + +type ProtocolMessage_Opened struct { + Opened *Opened `protobuf:"bytes,21,opt,name=opened,proto3,oneof"` +} + +type ProtocolMessage_Attached struct { + Attached *Attached `protobuf:"bytes,22,opt,name=attached,proto3,oneof"` +} + +type ProtocolMessage_Detached struct { + Detached *Detached `protobuf:"bytes,23,opt,name=detached,proto3,oneof"` +} + +type ProtocolMessage_Kill struct { + Kill *Kill `protobuf:"bytes,24,opt,name=kill,proto3,oneof"` +} + +type ProtocolMessage_Closed struct { + Closed *Closed `protobuf:"bytes,25,opt,name=closed,proto3,oneof"` +} + +func (*ProtocolMessage_Open) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Input) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Output) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Resize) isProtocolMessage_Message() {} + +func (*ProtocolMessage_List) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Sessions) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Attach) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Detach) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Close) isProtocolMessage_Message() {} + +func (*ProtocolMessage_State) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Error) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Opened) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Attached) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Detached) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Kill) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Closed) isProtocolMessage_Message() {} + +var File_aop_pty_protocol_proto protoreflect.FileDescriptor + +var file_aop_pty_protocol_proto_rawDesc = []byte{ + 0x0a, 0x16, 0x61, 0x6f, 0x70, 0x2f, 0x70, 0x74, 0x79, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, + 0x79, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x22, 0xbd, 0x03, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, + 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, + 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x70, + 0x69, 0x64, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x44, 0x0a, + 0x10, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x61, + 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, + 0x79, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, + 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x73, 0x65, 0x71, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x53, 0x65, 0x71, 0x12, 0x21, 0x0a, + 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x42, 0x79, 0x74, 0x65, 0x73, + 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, 0x69, 0x6c, 0x6c, 0x5f, 0x63, 0x61, 0x75, 0x73, + 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6b, 0x69, 0x6c, 0x6c, 0x43, 0x61, 0x75, + 0x73, 0x65, 0x22, 0xda, 0x01, 0x0a, 0x04, 0x4f, 0x70, 0x65, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x73, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x64, 0x65, + 0x5f, 0x75, 0x72, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x6f, 0x64, 0x65, + 0x55, 0x72, 0x69, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, + 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x6c, + 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x12, 0x12, 0x0a, + 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x72, 0x6f, 0x77, + 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x74, 0x6f, 0x6e, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x74, 0x6f, 0x6e, 0x22, + 0x51, 0x0a, 0x06, 0x4f, 0x70, 0x65, 0x6e, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, + 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, + 0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x22, 0x38, 0x0a, 0x05, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x51, 0x0a, 0x06, + 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x22, + 0x4d, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, + 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, + 0x77, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x22, 0x3e, + 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x75, 0x72, 0x69, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x6f, 0x64, 0x65, 0x55, 0x72, 0x69, 0x22, 0x55, + 0x0a, 0x08, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x70, 0x74, 0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x6c, 0x0a, 0x06, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x12, + 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x63, + 0x6f, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x63, 0x6f, 0x6c, 0x73, 0x12, + 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x72, + 0x6f, 0x77, 0x73, 0x22, 0x53, 0x0a, 0x08, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x65, 0x64, 0x12, + 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x07, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x25, 0x0a, 0x06, 0x44, 0x65, 0x74, 0x61, + 0x63, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x22, + 0x27, 0x0a, 0x08, 0x44, 0x65, 0x74, 0x61, 0x63, 0x68, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x22, 0x23, 0x0a, 0x04, 0x4b, 0x69, 0x6c, 0x6c, + 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x22, 0x24, 0x0a, + 0x05, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x49, 0x64, 0x22, 0x51, 0x0a, 0x06, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x12, 0x1b, 0x0a, + 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x07, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, + 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x50, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x07, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x3e, 0x0a, 0x05, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x18, + 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xc0, 0x05, 0x0a, 0x0f, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x23, 0x0a, 0x04, + 0x6f, 0x70, 0x65, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x48, 0x00, 0x52, 0x04, 0x6f, 0x70, 0x65, + 0x6e, 0x12, 0x26, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x49, 0x6e, 0x70, 0x75, 0x74, + 0x48, 0x00, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x29, 0x0a, 0x06, 0x6f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, + 0x70, 0x74, 0x79, 0x2e, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x0d, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x52, + 0x65, 0x73, 0x69, 0x7a, 0x65, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x73, 0x69, 0x7a, 0x65, 0x12, + 0x23, 0x0a, 0x04, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, + 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x48, 0x00, 0x52, 0x04, + 0x6c, 0x69, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x08, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, + 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x48, 0x00, 0x52, 0x08, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x29, 0x0a, 0x06, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x18, + 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, + 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x48, 0x00, 0x52, 0x06, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, + 0x12, 0x29, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, 0x63, 0x68, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x74, 0x61, 0x63, + 0x68, 0x48, 0x00, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x63, 0x68, 0x12, 0x26, 0x0a, 0x05, 0x63, + 0x6c, 0x6f, 0x73, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x48, 0x00, 0x52, 0x05, 0x63, 0x6c, + 0x6f, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x13, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x53, 0x74, 0x61, + 0x74, 0x65, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x26, 0x0a, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, + 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x06, 0x6f, 0x70, 0x65, 0x6e, 0x65, 0x64, 0x18, 0x15, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x4f, 0x70, + 0x65, 0x6e, 0x65, 0x64, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x70, 0x65, 0x6e, 0x65, 0x64, 0x12, 0x2f, + 0x0a, 0x08, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x65, 0x64, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x41, 0x74, 0x74, 0x61, 0x63, + 0x68, 0x65, 0x64, 0x48, 0x00, 0x52, 0x08, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x65, 0x64, 0x12, + 0x2f, 0x0a, 0x08, 0x64, 0x65, 0x74, 0x61, 0x63, 0x68, 0x65, 0x64, 0x18, 0x17, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x44, 0x65, 0x74, 0x61, + 0x63, 0x68, 0x65, 0x64, 0x48, 0x00, 0x52, 0x08, 0x64, 0x65, 0x74, 0x61, 0x63, 0x68, 0x65, 0x64, + 0x12, 0x23, 0x0a, 0x04, 0x6b, 0x69, 0x6c, 0x6c, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, + 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, 0x4b, 0x69, 0x6c, 0x6c, 0x48, 0x00, 0x52, + 0x04, 0x6b, 0x69, 0x6c, 0x6c, 0x12, 0x29, 0x0a, 0x06, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x18, + 0x19, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x70, 0x74, 0x79, 0x2e, + 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x48, 0x00, 0x52, 0x06, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x64, + 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x2d, 0x5a, 0x2b, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, + 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, + 0x6f, 0x70, 0x2f, 0x70, 0x74, 0x79, 0x3b, 0x70, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_aop_pty_protocol_proto_rawDescOnce sync.Once + file_aop_pty_protocol_proto_rawDescData = file_aop_pty_protocol_proto_rawDesc +) + +func file_aop_pty_protocol_proto_rawDescGZIP() []byte { + file_aop_pty_protocol_proto_rawDescOnce.Do(func() { + file_aop_pty_protocol_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_pty_protocol_proto_rawDescData) + }) + return file_aop_pty_protocol_proto_rawDescData +} + +var file_aop_pty_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_aop_pty_protocol_proto_goTypes = []interface{}{ + (*Session)(nil), // 0: aop.pty.Session + (*Open)(nil), // 1: aop.pty.Open + (*Opened)(nil), // 2: aop.pty.Opened + (*Input)(nil), // 3: aop.pty.Input + (*Output)(nil), // 4: aop.pty.Output + (*Resize)(nil), // 5: aop.pty.Resize + (*List)(nil), // 6: aop.pty.List + (*Sessions)(nil), // 7: aop.pty.Sessions + (*Attach)(nil), // 8: aop.pty.Attach + (*Attached)(nil), // 9: aop.pty.Attached + (*Detach)(nil), // 10: aop.pty.Detach + (*Detached)(nil), // 11: aop.pty.Detached + (*Kill)(nil), // 12: aop.pty.Kill + (*Close)(nil), // 13: aop.pty.Close + (*Closed)(nil), // 14: aop.pty.Closed + (*State)(nil), // 15: aop.pty.State + (*Error)(nil), // 16: aop.pty.Error + (*ProtocolMessage)(nil), // 17: aop.pty.ProtocolMessage + (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp +} +var file_aop_pty_protocol_proto_depIdxs = []int32{ + 18, // 0: aop.pty.Session.started_at:type_name -> google.protobuf.Timestamp + 18, // 1: aop.pty.Session.last_activity_at:type_name -> google.protobuf.Timestamp + 18, // 2: aop.pty.Session.ended_at:type_name -> google.protobuf.Timestamp + 0, // 3: aop.pty.Opened.session:type_name -> aop.pty.Session + 0, // 4: aop.pty.Sessions.sessions:type_name -> aop.pty.Session + 0, // 5: aop.pty.Attached.session:type_name -> aop.pty.Session + 0, // 6: aop.pty.Closed.session:type_name -> aop.pty.Session + 0, // 7: aop.pty.State.session:type_name -> aop.pty.Session + 1, // 8: aop.pty.ProtocolMessage.open:type_name -> aop.pty.Open + 3, // 9: aop.pty.ProtocolMessage.input:type_name -> aop.pty.Input + 4, // 10: aop.pty.ProtocolMessage.output:type_name -> aop.pty.Output + 5, // 11: aop.pty.ProtocolMessage.resize:type_name -> aop.pty.Resize + 6, // 12: aop.pty.ProtocolMessage.list:type_name -> aop.pty.List + 7, // 13: aop.pty.ProtocolMessage.sessions:type_name -> aop.pty.Sessions + 8, // 14: aop.pty.ProtocolMessage.attach:type_name -> aop.pty.Attach + 10, // 15: aop.pty.ProtocolMessage.detach:type_name -> aop.pty.Detach + 13, // 16: aop.pty.ProtocolMessage.close:type_name -> aop.pty.Close + 15, // 17: aop.pty.ProtocolMessage.state:type_name -> aop.pty.State + 16, // 18: aop.pty.ProtocolMessage.error:type_name -> aop.pty.Error + 2, // 19: aop.pty.ProtocolMessage.opened:type_name -> aop.pty.Opened + 9, // 20: aop.pty.ProtocolMessage.attached:type_name -> aop.pty.Attached + 11, // 21: aop.pty.ProtocolMessage.detached:type_name -> aop.pty.Detached + 12, // 22: aop.pty.ProtocolMessage.kill:type_name -> aop.pty.Kill + 14, // 23: aop.pty.ProtocolMessage.closed:type_name -> aop.pty.Closed + 24, // [24:24] is the sub-list for method output_type + 24, // [24:24] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name +} + +func init() { file_aop_pty_protocol_proto_init() } +func file_aop_pty_protocol_proto_init() { + if File_aop_pty_protocol_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aop_pty_protocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Session); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Open); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Opened); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Input); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Output); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Resize); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*List); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Sessions); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Attach); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Attached); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Detach); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Detached); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Kill); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Close); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Closed); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*State); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Error); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_pty_protocol_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_pty_protocol_proto_msgTypes[17].OneofWrappers = []interface{}{ + (*ProtocolMessage_Open)(nil), + (*ProtocolMessage_Input)(nil), + (*ProtocolMessage_Output)(nil), + (*ProtocolMessage_Resize)(nil), + (*ProtocolMessage_List)(nil), + (*ProtocolMessage_Sessions)(nil), + (*ProtocolMessage_Attach)(nil), + (*ProtocolMessage_Detach)(nil), + (*ProtocolMessage_Close)(nil), + (*ProtocolMessage_State)(nil), + (*ProtocolMessage_Error)(nil), + (*ProtocolMessage_Opened)(nil), + (*ProtocolMessage_Attached)(nil), + (*ProtocolMessage_Detached)(nil), + (*ProtocolMessage_Kill)(nil), + (*ProtocolMessage_Closed)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_pty_protocol_proto_rawDesc, + NumEnums: 0, + NumMessages: 18, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_pty_protocol_proto_goTypes, + DependencyIndexes: file_aop_pty_protocol_proto_depIdxs, + MessageInfos: file_aop_pty_protocol_proto_msgTypes, + }.Build() + File_aop_pty_protocol_proto = out.File + file_aop_pty_protocol_proto_rawDesc = nil + file_aop_pty_protocol_proto_goTypes = nil + file_aop_pty_protocol_proto_depIdxs = nil +} diff --git a/aop/sco/protocol.pb.go b/aop/sco/protocol.pb.go new file mode 100644 index 00000000..28dd8ea2 --- /dev/null +++ b/aop/sco/protocol.pb.go @@ -0,0 +1,244 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/sco/protocol.proto + +package sco + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Nodes carries libcstx-owned node documents without copying the libcstx +// schema into AOP. Each entry uses the declared media type. +type Nodes struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Nodes [][]byte `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` + MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *Nodes) Reset() { + *x = Nodes{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_sco_protocol_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Nodes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Nodes) ProtoMessage() {} + +func (x *Nodes) ProtoReflect() protoreflect.Message { + mi := &file_aop_sco_protocol_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Nodes.ProtoReflect.Descriptor instead. +func (*Nodes) Descriptor() ([]byte, []int) { + return file_aop_sco_protocol_proto_rawDescGZIP(), []int{0} +} + +func (x *Nodes) GetNodes() [][]byte { + if x != nil { + return x.Nodes + } + return nil +} + +func (x *Nodes) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_Nodes + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_sco_protocol_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aop_sco_protocol_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aop_sco_protocol_proto_rawDescGZIP(), []int{1} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetNodes() *Nodes { + if x, ok := x.GetMessage().(*ProtocolMessage_Nodes); ok { + return x.Nodes + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_Nodes struct { + Nodes *Nodes `protobuf:"bytes,10,opt,name=nodes,proto3,oneof"` +} + +func (*ProtocolMessage_Nodes) isProtocolMessage_Message() {} + +var File_aop_sco_protocol_proto protoreflect.FileDescriptor + +var file_aop_sco_protocol_proto_rawDesc = []byte{ + 0x0a, 0x16, 0x61, 0x6f, 0x70, 0x2f, 0x73, 0x63, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x61, 0x6f, 0x70, 0x2e, 0x73, 0x63, + 0x6f, 0x22, 0x3c, 0x0a, 0x05, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, + 0x64, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, + 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x22, + 0x44, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x26, 0x0a, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x4e, 0x6f, 0x64, 0x65, + 0x73, 0x48, 0x00, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, + 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, 0x73, 0x63, 0x6f, + 0x3b, 0x73, 0x63, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_sco_protocol_proto_rawDescOnce sync.Once + file_aop_sco_protocol_proto_rawDescData = file_aop_sco_protocol_proto_rawDesc +) + +func file_aop_sco_protocol_proto_rawDescGZIP() []byte { + file_aop_sco_protocol_proto_rawDescOnce.Do(func() { + file_aop_sco_protocol_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_sco_protocol_proto_rawDescData) + }) + return file_aop_sco_protocol_proto_rawDescData +} + +var file_aop_sco_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_aop_sco_protocol_proto_goTypes = []interface{}{ + (*Nodes)(nil), // 0: aop.sco.Nodes + (*ProtocolMessage)(nil), // 1: aop.sco.ProtocolMessage +} +var file_aop_sco_protocol_proto_depIdxs = []int32{ + 0, // 0: aop.sco.ProtocolMessage.nodes:type_name -> aop.sco.Nodes + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_aop_sco_protocol_proto_init() } +func file_aop_sco_protocol_proto_init() { + if File_aop_sco_protocol_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aop_sco_protocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Nodes); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_sco_protocol_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_sco_protocol_proto_msgTypes[1].OneofWrappers = []interface{}{ + (*ProtocolMessage_Nodes)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_sco_protocol_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_sco_protocol_proto_goTypes, + DependencyIndexes: file_aop_sco_protocol_proto_depIdxs, + MessageInfos: file_aop_sco_protocol_proto_msgTypes, + }.Build() + File_aop_sco_protocol_proto = out.File + file_aop_sco_protocol_proto_rawDesc = nil + file_aop_sco_protocol_proto_goTypes = nil + file_aop_sco_protocol_proto_depIdxs = nil +} diff --git a/aop/stream.go b/aop/stream.go new file mode 100644 index 00000000..1c98d0c9 --- /dev/null +++ b/aop/stream.go @@ -0,0 +1,11 @@ +package aop + +// EnvelopeStream is the transport boundary for AOP. Implementations only +// frame and carry protobuf Envelopes; application routing and lifecycle stay +// in the concrete Hub or AgentRuntime loop. +// +// A caller must use at most one Recv goroutine and one Send goroutine. +type EnvelopeStream interface { + Recv() (*Envelope, error) + Send(*Envelope) error +} diff --git a/aop/tool/protocol.pb.go b/aop/tool/protocol.pb.go new file mode 100644 index 00000000..81039350 --- /dev/null +++ b/aop/tool/protocol.pb.go @@ -0,0 +1,374 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aop/tool/protocol.proto + +package tool + +import ( + aop "github.com/chainreactors/aiscan/aop" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Call struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + TurnId string `protobuf:"bytes,2,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + Call *aop.ToolCall `protobuf:"bytes,3,opt,name=call,proto3" json:"call,omitempty"` +} + +func (x *Call) Reset() { + *x = Call{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_tool_protocol_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Call) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Call) ProtoMessage() {} + +func (x *Call) ProtoReflect() protoreflect.Message { + mi := &file_aop_tool_protocol_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Call.ProtoReflect.Descriptor instead. +func (*Call) Descriptor() ([]byte, []int) { + return file_aop_tool_protocol_proto_rawDescGZIP(), []int{0} +} + +func (x *Call) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Call) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +func (x *Call) GetCall() *aop.ToolCall { + if x != nil { + return x.Call + } + return nil +} + +type Progress struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Tool string `protobuf:"bytes,1,opt,name=tool,proto3" json:"tool,omitempty"` + Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` + Timestamp *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + Text string `protobuf:"bytes,6,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *Progress) Reset() { + *x = Progress{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_tool_protocol_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Progress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Progress) ProtoMessage() {} + +func (x *Progress) ProtoReflect() protoreflect.Message { + mi := &file_aop_tool_protocol_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Progress.ProtoReflect.Descriptor instead. +func (*Progress) Descriptor() ([]byte, []int) { + return file_aop_tool_protocol_proto_rawDescGZIP(), []int{1} +} + +func (x *Progress) GetTool() string { + if x != nil { + return x.Tool + } + return "" +} + +func (x *Progress) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *Progress) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +func (x *Progress) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_Progress + // *ProtocolMessage_Call + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aop_tool_protocol_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aop_tool_protocol_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aop_tool_protocol_proto_rawDescGZIP(), []int{2} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetProgress() *Progress { + if x, ok := x.GetMessage().(*ProtocolMessage_Progress); ok { + return x.Progress + } + return nil +} + +func (x *ProtocolMessage) GetCall() *Call { + if x, ok := x.GetMessage().(*ProtocolMessage_Call); ok { + return x.Call + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_Progress struct { + Progress *Progress `protobuf:"bytes,10,opt,name=progress,proto3,oneof"` +} + +type ProtocolMessage_Call struct { + Call *Call `protobuf:"bytes,11,opt,name=call,proto3,oneof"` +} + +func (*ProtocolMessage_Progress) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Call) isProtocolMessage_Message() {} + +var File_aop_tool_protocol_proto protoreflect.FileDescriptor + +var file_aop_tool_protocol_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x6f, 0x70, 0x2f, 0x74, 0x6f, 0x6f, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x61, 0x6f, 0x70, 0x2e, 0x74, + 0x6f, 0x6f, 0x6c, 0x1a, 0x11, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x61, 0x0a, 0x04, 0x43, 0x61, 0x6c, 0x6c, 0x12, + 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, + 0x0a, 0x07, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, + 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x22, 0x90, 0x01, 0x0a, 0x08, 0x50, + 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x74, + 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, + 0x67, 0x65, 0x74, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x12, 0x0a, + 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, + 0x74, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x22, 0x74, 0x0a, + 0x0f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x30, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x74, 0x6f, 0x6f, 0x6c, 0x2e, 0x50, 0x72, + 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x24, 0x0a, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x74, 0x6f, 0x6f, 0x6c, 0x2e, 0x43, 0x61, 0x6c, 0x6c, + 0x48, 0x00, 0x52, 0x04, 0x63, 0x61, 0x6c, 0x6c, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x42, 0x2f, 0x5a, 0x2d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, 0x74, 0x6f, 0x6f, 0x6c, 0x3b, + 0x74, 0x6f, 0x6f, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aop_tool_protocol_proto_rawDescOnce sync.Once + file_aop_tool_protocol_proto_rawDescData = file_aop_tool_protocol_proto_rawDesc +) + +func file_aop_tool_protocol_proto_rawDescGZIP() []byte { + file_aop_tool_protocol_proto_rawDescOnce.Do(func() { + file_aop_tool_protocol_proto_rawDescData = protoimpl.X.CompressGZIP(file_aop_tool_protocol_proto_rawDescData) + }) + return file_aop_tool_protocol_proto_rawDescData +} + +var file_aop_tool_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_aop_tool_protocol_proto_goTypes = []interface{}{ + (*Call)(nil), // 0: aop.tool.Call + (*Progress)(nil), // 1: aop.tool.Progress + (*ProtocolMessage)(nil), // 2: aop.tool.ProtocolMessage + (*aop.ToolCall)(nil), // 3: aop.ToolCall + (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp +} +var file_aop_tool_protocol_proto_depIdxs = []int32{ + 3, // 0: aop.tool.Call.call:type_name -> aop.ToolCall + 4, // 1: aop.tool.Progress.timestamp:type_name -> google.protobuf.Timestamp + 1, // 2: aop.tool.ProtocolMessage.progress:type_name -> aop.tool.Progress + 0, // 3: aop.tool.ProtocolMessage.call:type_name -> aop.tool.Call + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_aop_tool_protocol_proto_init() } +func file_aop_tool_protocol_proto_init() { + if File_aop_tool_protocol_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aop_tool_protocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Call); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_tool_protocol_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Progress); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aop_tool_protocol_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aop_tool_protocol_proto_msgTypes[2].OneofWrappers = []interface{}{ + (*ProtocolMessage_Progress)(nil), + (*ProtocolMessage_Call)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aop_tool_protocol_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aop_tool_protocol_proto_goTypes, + DependencyIndexes: file_aop_tool_protocol_proto_depIdxs, + MessageInfos: file_aop_tool_protocol_proto_msgTypes, + }.Build() + File_aop_tool_protocol_proto = out.File + file_aop_tool_protocol_proto_rawDesc = nil + file_aop_tool_protocol_proto_goTypes = nil + file_aop_tool_protocol_proto_depIdxs = nil +} diff --git a/aop/value.pb.go b/aop/value.pb.go index 6ee51840..6c325dbb 100644 --- a/aop/value.pb.go +++ b/aop/value.pb.go @@ -20,7 +20,8 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// EncodedValue preserves structured or binary data without JSON coercion. +// EncodedValue carries genuinely opaque data whose schema is not protobuf, +// notably provider/tool JSON arguments and JSON Schema documents. type EncodedValue struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -76,62 +77,6 @@ func (x *EncodedValue) GetMediaType() string { return "" } -// Extension carries namespaced semantics outside the stable AOP core. -type Extension struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` - Value *EncodedValue `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *Extension) Reset() { - *x = Extension{} - if protoimpl.UnsafeEnabled { - mi := &file_aop_value_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Extension) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Extension) ProtoMessage() {} - -func (x *Extension) ProtoReflect() protoreflect.Message { - mi := &file_aop_value_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Extension.ProtoReflect.Descriptor instead. -func (*Extension) Descriptor() ([]byte, []int) { - return file_aop_value_proto_rawDescGZIP(), []int{1} -} - -func (x *Extension) GetNamespace() string { - if x != nil { - return x.Namespace - } - return "" -} - -func (x *Extension) GetValue() *EncodedValue { - if x != nil { - return x.Value - } - return nil -} - var File_aop_value_proto protoreflect.FileDescriptor var file_aop_value_proto_rawDesc = []byte{ @@ -140,13 +85,10 @@ var file_aop_value_proto_rawDesc = []byte{ 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x22, 0x52, 0x0a, 0x09, 0x45, 0x78, 0x74, - 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x65, - 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, + 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -161,18 +103,16 @@ func file_aop_value_proto_rawDescGZIP() []byte { return file_aop_value_proto_rawDescData } -var file_aop_value_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_aop_value_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_aop_value_proto_goTypes = []interface{}{ (*EncodedValue)(nil), // 0: aop.EncodedValue - (*Extension)(nil), // 1: aop.Extension } var file_aop_value_proto_depIdxs = []int32{ - 0, // 0: aop.Extension.value:type_name -> aop.EncodedValue - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name } func init() { file_aop_value_proto_init() } @@ -193,18 +133,6 @@ func file_aop_value_proto_init() { return nil } } - file_aop_value_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Extension); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } } type x struct{} out := protoimpl.TypeBuilder{ @@ -212,7 +140,7 @@ func file_aop_value_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_aop_value_proto_rawDesc, NumEnums: 0, - NumMessages: 2, + NumMessages: 1, NumExtensions: 0, NumServices: 0, }, diff --git a/aop/wire.go b/aop/wire.go new file mode 100644 index 00000000..fe74f2ed --- /dev/null +++ b/aop/wire.go @@ -0,0 +1,38 @@ +package aop + +import ( + "fmt" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +func Wrap(id, replyTo string, message proto.Message) (*Envelope, error) { + if message == nil { + return nil, fmt.Errorf("AOP payload is required") + } + payload, err := anypb.New(message) + if err != nil { + return nil, err + } + return &Envelope{Id: id, ReplyTo: replyTo, Payload: payload}, nil +} + +func MustWrap(id, replyTo string, message proto.Message) *Envelope { + envelope, err := Wrap(id, replyTo, message) + if err != nil { + panic(err) + } + return envelope +} + +func Unwrap(envelope *Envelope) (proto.Message, error) { + if envelope == nil || envelope.Payload == nil { + return nil, fmt.Errorf("AOP envelope payload is required") + } + message, err := envelope.Payload.UnmarshalNew() + if err != nil { + return nil, fmt.Errorf("decode %s: %w", envelope.Payload.TypeUrl, err) + } + return message, nil +} diff --git a/cmd/gen/main.go b/cmd/gen/main.go new file mode 100644 index 00000000..2ad1feb6 --- /dev/null +++ b/cmd/gen/main.go @@ -0,0 +1,216 @@ +// Command gen is the single protobuf generation entrypoint for AIScan. +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +const modulePath = "github.com/chainreactors/aiscan" + +var aopProtos = []string{ + "aop/value.proto", + "aop/content.proto", + "aop/event.proto", + "aop/chat.proto", + "aop/envelope.proto", + "aop/protocol.proto", + "aop/file/protocol.proto", + "aop/exec/protocol.proto", + "aop/pty/protocol.proto", + "aop/tool/protocol.proto", + "aop/sco/protocol.proto", +} + +var typeProtos = []string{ + "aiscan/types/agent.proto", + "aiscan/types/chat.proto", + "aiscan/types/command.proto", + "aiscan/types/config.proto", + "aiscan/types/reload.proto", + "aiscan/types/scan.proto", + "aiscan/types/sco.proto", + "aiscan/types/system.proto", +} + +var rpcProtos = []string{ + "aiscan/rpc/agent.proto", + "aiscan/rpc/chat.proto", + "aiscan/rpc/config.proto", + "aiscan/rpc/scan.proto", + "aiscan/rpc/sco.proto", + "aiscan/rpc/system.proto", +} + +func main() { + root, err := repositoryRoot() + if err != nil { + fatal("locate repository root", err) + } + protoc, err := exec.LookPath("protoc") + if err != nil { + fatal("find protoc", err) + } + esPlugin, err := findESPlugin(root) + if err != nil { + fatal("find protoc-gen-es (run npm install in web/frontend)", err) + } + + cyberProto := filepath.Join(root, "web", "frontend", "cyber-ui", "packages", "aop", "proto") + productProto := filepath.Join(root, "proto") + aopTS := filepath.Join(root, "web", "frontend", "cyber-ui", "packages", "aop", "src", "gen", "aop") + productTS := filepath.Join(root, "web", "frontend", "src", "gen", "aiscan") + + for _, path := range []string{ + filepath.Join(root, "pkg", "types", "agent"), + filepath.Join(root, "pkg", "types", "chat"), + filepath.Join(root, "pkg", "types", "command"), + filepath.Join(root, "pkg", "types", "config"), + filepath.Join(root, "pkg", "types", "reload"), + filepath.Join(root, "pkg", "types", "scan"), + filepath.Join(root, "pkg", "types", "sco"), + filepath.Join(root, "pkg", "types", "system"), + filepath.Join(root, "pkg", "rpc"), + filepath.Join(root, "web", "frontend", "cyber-ui", "packages", "aop", "src", "gen", "aiscan"), + aopTS, + productTS, + } { + if err := os.RemoveAll(path); err != nil { + fatal("clear generated output "+path, err) + } + } + + goInputs := append(append([]string{}, aopProtos...), typeProtos...) + goInputs = append(goInputs, rpcProtos...) + sort.Strings(goInputs) + goArgs := []string{ + "-I", cyberProto, + "-I", productProto, + "--go_out=" + root, + "--go_opt=module=" + modulePath, + } + goArgs = append(goArgs, absoluteInputs(cyberProto, productProto, goInputs)...) + run(root, protoc, goArgs...) + + connectArgs := []string{ + "-I", cyberProto, + "-I", productProto, + "--connect-go_out=" + root, + "--connect-go_opt=module=" + modulePath, + } + connectArgs = append(connectArgs, absoluteInputs(cyberProto, productProto, rpcProtos)...) + run(root, protoc, connectArgs...) + + if err := os.MkdirAll(filepath.Dir(aopTS), 0o755); err != nil { + fatal("create AOP TypeScript output", err) + } + aopArgs := []string{ + "-I", cyberProto, + "-I", productProto, + "--plugin=protoc-gen-es=" + esPlugin, + "--es_out=" + filepath.Dir(aopTS), + "--es_opt=target=ts,import_extension=js", + } + aopArgs = append(aopArgs, absoluteInputs(cyberProto, productProto, aopProtos)...) + run(root, protoc, aopArgs...) + + if err := os.MkdirAll(filepath.Dir(productTS), 0o755); err != nil { + fatal("create AIScan TypeScript output", err) + } + productInputs := append(append([]string{}, typeProtos...), rpcProtos...) + sort.Strings(productInputs) + productArgs := []string{ + "-I", cyberProto, + "-I", productProto, + "--plugin=protoc-gen-es=" + esPlugin, + "--es_out=" + filepath.Join(root, "web", "frontend", "src", "gen"), + "--es_opt=target=ts,import_extension=js", + } + productArgs = append(productArgs, absoluteInputs(cyberProto, productProto, productInputs)...) + run(root, protoc, productArgs...) + if err := rewriteProductAOPImports(productTS); err != nil { + fatal("rewrite AIScan TypeScript AOP imports", err) + } +} + +func rewriteProductAOPImports(root string) error { + return filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".ts" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + value := string(data) + next := strings.ReplaceAll(value, `"../../aop/`, `"../../../../cyber-ui/packages/aop/src/gen/aop/`) + if next == value { + return nil + } + return os.WriteFile(path, []byte(next), 0o644) + }) +} + +func absoluteInputs(cyberProto, productProto string, inputs []string) []string { + values := make([]string, 0, len(inputs)) + for _, input := range inputs { + base := productProto + if len(input) >= 4 && input[:4] == "aop/" { + base = cyberProto + } + values = append(values, filepath.Join(base, filepath.FromSlash(input))) + } + return values +} + +func findESPlugin(root string) (string, error) { + name := "protoc-gen-es" + if runtime.GOOS == "windows" { + name += ".cmd" + } + local := filepath.Join(root, "web", "frontend", "node_modules", ".bin", name) + if _, err := os.Stat(local); err == nil { + return local, nil + } + return exec.LookPath("protoc-gen-es") +} + +func repositoryRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found") + } + dir = parent + } +} + +func run(dir, command string, args ...string) { + cmd := exec.Command(command, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fatal(command, err) + } +} + +func fatal(action string, err error) { + fmt.Fprintf(os.Stderr, "%s: %v\n", action, err) + os.Exit(1) +} diff --git a/core/deps/architecture_test.go b/core/deps/architecture_test.go index 257143ba..89e4f3b6 100644 --- a/core/deps/architecture_test.go +++ b/core/deps/architecture_test.go @@ -18,15 +18,15 @@ func TestLayerImportsAreUnidirectional(t *testing.T) { root := repositoryRoot(t) assertNoFirstPartyImports(t, filepath.Join(root, "core"), map[string]bool{ "agent": true, - "pkg": true, "tools": true, "cmd": true, }) assertNoFirstPartyImports(t, filepath.Join(root, "agent"), map[string]bool{ - "pkg": true, "tools": true, "cmd": true, }) + assertNoPkgImportsExceptTypes(t, filepath.Join(root, "core")) + assertNoPkgImportsExceptTypes(t, filepath.Join(root, "agent")) } func TestAOPProtocolLayerHasNoRuntimeDependencies(t *testing.T) { @@ -43,6 +43,7 @@ func TestAOPProtocolLayerHasNoRuntimeDependencies(t *testing.T) { func TestRunnerDoesNotDependOnWeb(t *testing.T) { root := repositoryRoot(t) assertNoImportPrefix(t, filepath.Join(root, "pkg", "runner"), modulePath+"/pkg/web") + assertNoImportPrefix(t, filepath.Join(root, "pkg", "runner"), modulePath+"/pkg/rpc") } func TestLegacyPackagesCannotReturn(t *testing.T) { @@ -67,6 +68,7 @@ func TestLegacyPackagesCannotReturn(t *testing.T) { {dir: filepath.Join("internal", "gen"), importPath: modulePath + "/internal/gen"}, {dir: "api", importPath: modulePath + "/api"}, {dir: filepath.Join("aop", "ext"), importPath: modulePath + "/aop/ext"}, + {dir: filepath.Join("aop", "aiscan"), importPath: modulePath + "/aop/aiscan"}, } for _, item := range legacy { legacyDir := filepath.Join(root, item.dir) @@ -106,7 +108,7 @@ func TestLegacyPackagesCannotReturn(t *testing.T) { } } -func TestGeneratedProtobufLivesUnderAOP(t *testing.T) { +func TestGeneratedProtobufLivesInOwnedProtocolTrees(t *testing.T) { root := repositoryRoot(t) err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { if err != nil { @@ -123,8 +125,8 @@ func TestGeneratedProtobufLivesUnderAOP(t *testing.T) { return nil } rel := filepath.ToSlash(relative(root, path)) - if !strings.HasPrefix(rel, "aop/") { - t.Errorf("generated protobuf file outside aop/: %s", rel) + if !strings.HasPrefix(rel, "aop/") && !strings.HasPrefix(rel, "pkg/types/") && !strings.HasPrefix(rel, "pkg/rpc/") { + t.Errorf("generated protobuf file outside owned protocol trees: %s", rel) } return nil }) @@ -133,6 +135,14 @@ func TestGeneratedProtobufLivesUnderAOP(t *testing.T) { } } +func TestSharedTypesDoNotDependOnRPCOrConnect(t *testing.T) { + root := repositoryRoot(t) + tree := filepath.Join(root, "pkg", "types") + for _, forbidden := range []string{modulePath + "/pkg/rpc", modulePath + "/pkg/web", "connectrpc.com/connect"} { + assertNoImportPrefix(t, tree, forbidden) + } +} + func TestWebProtocolDoesNotDefineGenericJSONEnvelope(t *testing.T) { root := repositoryRoot(t) tree := filepath.Join(root, "pkg", "web") @@ -275,6 +285,34 @@ func assertNoImportPrefix(t *testing.T, tree, forbidden string) { } } +func assertNoPkgImportsExceptTypes(t *testing.T, tree string) { + t.Helper() + root := repositoryRoot(t) + prefix := modulePath + "/pkg/" + allowed := modulePath + "/pkg/types" + err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + imports, parseErr := importsInFile(path) + if parseErr != nil { + return parseErr + } + for _, importPath := range imports { + if strings.HasPrefix(importPath, prefix) && importPath != allowed && !strings.HasPrefix(importPath, allowed+"/") { + t.Errorf("forbidden pkg dependency %q in %s", importPath, relative(root, path)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + func hasGoFiles(tree string) bool { found := false _ = filepath.WalkDir(tree, func(path string, entry fs.DirEntry, err error) error { diff --git a/docs/protocol-architecture.md b/docs/protocol-architecture.md new file mode 100644 index 00000000..96537856 --- /dev/null +++ b/docs/protocol-architecture.md @@ -0,0 +1,162 @@ +# AIScan 协议与传输架构 + +本文定义 AIScan 的目标协议职责,以及当前迁移边界。Runner 已使用统一 AOP WebSocket;ConnectRPC 承担产品管理与查询。浏览器单 AOP WebSocket 仍延期:当前保留 Chat/WatchEvents ConnectRPC 与独立 Terminal WebSocket,后续迁移不改变本文件定义的最终边界。 + +## 1. 唯一真相 + +跨进程、跨语言和跨前后端的数据类型只在 protobuf 中定义。业务代码可以拥有领域对象或 UI view model,但不得再定义与 protobuf 同构的 wire DTO,也不得在 AOP、Connect、REST 或 JSON-RPC 之间做同一语义的多次转换。 + +libcstx 独占安全事实模型:IP、Port、URL/Web、App、Framework、Vulnerability 等节点由 libcstx 定义。AIScan 只记录操作、会话和这些节点之间的关系,不再定义 Asset、Service、WebProbe、Framework Vulnerability 等平行事实类型。 + +## 2. 两个平面 + +| 平面 | 传输 | 职责 | +| --- | --- | --- | +| AOP 应用平面 | binary protobuf WebSocket `/api/aop/ws` | Agent 会话、Turn、事件、工具、命令、file、exec、PTY、SCO 增量、取消和实时 scan 事件;Runner 已接入,浏览器统一接入延期 | +| AIScan 管理平面 | ConnectRPC unary | 查询、配置、Agent 列表与本地进程生命周期、Session 历史、Scan CRUD、SCO 查询/导入、系统状态 | + +目标态不存在 JSON-RPC、AOP ChatService、独立 Agent socket、独立 terminal socket、Connect streaming RPC 或额外的 WebSocket wire。当前浏览器 live adapter 是迁移期明确保留项,不扩展其语义。Connect handler 是管理 HTTP 边界;Runner 的 Agent 核心交互不经过 Connect。 + +该边界按“语义”而不是按“调用者”划分:Runner 只通过 WebSocket 接入 Web;浏览器的管理/历史查询走 ConnectRPC,但浏览器的实时 Session/Turn、命令、文件与 PTY 也走 WebSocket。Web 服务拥有 Agent Pool、调度、持久化和管理 RPC,节点只拥有自身 Runtime、工具与执行状态。 + +Agent 对外只需要一个 `--server-url` 作为 AIScan Web/AOP 基址;旧 `--web-url` 是同一字段的兼容别名。IOA 使用独立的 `--ioa-url`,但 Web 默认托管同源 IOA,因此 Web Agent 未指定 `--ioa-url` 时自动使用 `/ioa`。 + +## 3. Namespace 所有权 + +### AOP + +`cyber-ui/packages/aop/proto/aop` 定义跨产品的语义: + +- `aop.ProtocolMessage`:Agent 注册与 Session/Turn 生命周期; +- `aop.Event`:message、tool、usage、status、error 和生命周期事件; +- `aop.file`、`aop.exec`、`aop.pty`、`aop.tool`、`aop.sco`:通用扩展协议。 + +这些扩展不是 AIScan DTO。PTY 和 file 对任何 AOP Agent 都成立,因此由 AOP 拥有。 + +### AIScan + +`proto/aiscan` 只定义产品机制: + +- `aiscan.command`:AIScan 命令目录、请求、结果与 receipt; +- `aiscan.scan`:Scan 状态、快照和实时事件; +- `aiscan.reload`:AIScan 配置热重载; +- `aiscan.agent/config/chat/sco/system`:Connect 管理服务及其返回类型。 + +AIScan 专有元数据通过 `google.protobuf.Any` 携带 namespace-owned message;protobuf full name / `Any.type_url` 是唯一类型身份,不得再增加 namespace 字符串或把 protobuf 编码成 JSON bytes。 + +### Cairn + +Cairn 复用 `aop.Envelope`、AOP namespace 和同一条应用 WebSocket。只有 Cairn 自己拥有的产品语义才进入 Cairn namespace;不得在 AIScan 中创建 Cairn DTO、registry 或转发协议。 + +## 4. Envelope 语义 + +`aop.Envelope` 是唯一 framing 单元: + +- `id`:本次 operation 的唯一标识,也是 request/reply correlation key; +- `reply_to`:响应或输出所对应的 request `id`; +- `payload`:`google.protobuf.Any`,type URL 决定 protobuf namespace; +- `delivery_cursor`:持久化订阅的位置,只用于恢复,不等同于 `Event.seq`。 + +请求 message 内不再重复 `request_id`。同步响应、流式输出和取消都围绕同一个 Envelope ID: + +```text +request.id = op-1 +reply.reply_to = op-1 +stream item.reply_to = op-1, delivery_cursor = 42 +CancelOperation.target_id = op-1 +``` + +`Event.seq` 是 Session 内的事件语义顺序;`delivery_cursor` 是存储/投递位置。两者不能互换。 + +WebSocket 本身提供连续字节传输,但不提供业务 correlation、可恢复 cursor 或精确取消,因此 Envelope 仍然必要;`WatchEventsResponse` 之类再包装则没有必要,事件直接作为 reply stream item 发送。 + +## 5. 连接和并发 + +目标态中,每个浏览器应用实例和每个 Runner 各自使用一条应用层 WebSocket。连接只有一个 reader;所有输出通过一个 FIFO writer。协议不引入优先级队列。 + +浏览器最终唯一连接所有者是 `@cyber/aop` 的 `AOPClient`。Terminal、Chat、Command、File 和 Scan watcher 将只提交 protobuf message,不创建 socket。该浏览器 cutover 当前延期,现有 Chat/WatchEvents ConnectRPC 与 Terminal WebSocket 暂时保留。 + +服务端第一帧若是 `AgentHello`,进入 Agent peer loop;其他支持的 request 进入 browser peer loop。顶层 `.ProtocolMessage` 通过应用实例拥有的 `NamespaceMux` 注册;namespace 内部 oneof 继续使用显式 type switch。Mux 不拥有 Connection、PendingOperations、Session 或 Turn 生命周期。 + +Go 传输边界只有: + +```go +type EnvelopeStream interface { + Recv() (*Envelope, error) + Send(*Envelope) error +} +``` + +Context 由调用者显式传入,Stream 不拥有 Session、Turn 或 operation 状态。 + +## 6. Framing + +- WebSocket:一条 binary message 对应一个 protobuf binary `Envelope`; +- stdio:一行 protobuf JSON 对应一个 `Envelope`。 + +两种 framing 进入相同的 Runtime protobuf loop。stdio 不是第二套协议,不存在 `ServerFrame/AgentFrame` 或 JSON DTO。 + +## 7. Agent 身份 + +`AgentHello.agent_id` 是节点本地 ID,`AgentHello.authority` 是身份 authority。服务端组合两者得到 `AgentView.node_uri`。 + +`node_uri` 是 Pool key、`Session.node_uri`、PTY 路由和前端选择状态使用的唯一身份。`hello.agent_id` 只用于展示和诊断;不得作为跨 authority 的路由 key,也不得以 Agent name 做 fallback 匹配。 + +## 8. 类型与管理服务 + +- `aop/`:AOP core 与官方 `aop.*` 生成类型; +- `pkg/types/`:Agent、Runner、TUI、Web 共用的 AIScan protobuf message,不依赖 Connect; +- `pkg/rpc/`:AIScan ConnectRPC service descriptor、client 和 handler; +- `pkg/web/`:管理 RPC 的 Hub 实现; +- `cmd/gen/`:唯一 protobuf/TypeScript 生成入口。 + +非 `full` 构建不得依赖 `pkg/rpc` 或 `connectrpc.com/connect`。当前 Runner transport 对 `pkg/web/agent` 的依赖由独立迁移负责,不在本轮通过移动 RPC 类型解决。 + +ConnectRPC 只暴露以下 unary 服务: + +- `aiscan.rpc.system.SystemService` +- `aiscan.rpc.config.ConfigService` +- `aiscan.rpc.agent.AgentService` +- `aiscan.rpc.chat.SessionService` +- `aiscan.rpc.scan.ScanService` +- `aiscan.rpc.sco.SCOService` + +生成流程只生成 protobuf 与 Connect-Go 代码,不生成 grpc-go service/client。REST `/api/*` 仅保留认证和 `/api/aop/ws`;未知管理 REST 返回 404。`/health` 和原生 `/ioa/` 不属于 AIScan RPC。 + +## 9. 持久化边界 + +- Session 和 Scan 以 protobuf 为存储真相; +- AOP 历史只存 `aop.Event` ProtoJSON; +- Scanner 文件输出只写 libcstx SCO JSONL; +- 不保留旧扁平 DTO 列、Record/Timeline 双写或 fallback read。 + +历史读取是纯查询,不派发 Agent frame、不收敛 operation,也不复制 terminal event。 + +## 10. 抽象预算 + +允许的协议/传输抽象只有 Go `EnvelopeStream`、实例级 `NamespaceMux` 和浏览器 `AOPClient`。其余逻辑使用具体 owner;顶层 namespace 由 Mux 注册,namespace 内部 oneof 使用显式 switch: + +- Session/Turn 状态属于 Runtime/Service; +- Agent pending task 属于具体 `remoteAgent`; +- browser subscription 与 PTY route 属于该 browser peer loop; +- Connect handler 只做 request wrapper 与错误映射。 + +新增抽象必须证明至少有两个真实 owner、不能由 protobuf message + 普通函数表达,并在本文补充职责和生命周期。允许的 namespace 注册抽象只做 full-name → handler 路由;不得扩展成全局 schema registry、通用 pending manager、link、wire 或兼容 adapter。 + +## 11. 实现位置与验收 + +- AOP schema:`web/frontend/cyber-ui/packages/aop/proto/aop` +- AIScan message schema:`proto/aiscan/types` +- AIScan RPC schema:`proto/aiscan/rpc` +- AIScan Go message:`pkg/types` +- AIScan Go RPC:`pkg/rpc` +- 生成入口:`cmd/gen` +- WebSocket endpoint:`pkg/web/aop_endpoint.go` +- browser peer:`pkg/web/aop_ws.go` +- Agent peer:`pkg/web/agent_stream.go` +- Runtime loop:`pkg/runner/runtime_protocol.go` +- stdio framing:`pkg/runner/stdio.go` +- Browser client:`web/frontend/cyber-ui/packages/aop/src/client.ts` +- Connect boundary:`pkg/web/connect.go` + +完成态验收:全仓只能由 `AOPClient` 创建浏览器 WebSocket;不存在 ChatService、WatchEventsResponse、WatchScanEventsResponse、AgentTransport frame、terminal 专用 socket、手写 wire DTO 或 grpc-go service 生成物。 diff --git a/go.mod b/go.mod index 7cb6ee7d..241db9fc 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/chainreactors/aiscan go 1.25.7 require ( + connectrpc.com/connect v1.20.0 github.com/alecthomas/chroma/v2 v2.14.0 github.com/carapace-sh/carapace v1.11.6 github.com/chainreactors/crtm v0.0.3-0.20260618163257-073207497076 @@ -47,17 +48,11 @@ require ( golang.org/x/image v0.42.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.40.1 ) -require ( - connectrpc.com/connect v1.20.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect - google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect -) - require ( go.yaml.in/yaml/v2 v2.4.2 // indirect sigs.k8s.io/yaml v1.6.0 @@ -104,7 +99,7 @@ require ( github.com/chainreactors/files v0.0.0-20240716182835-7884ee1e77f0 // indirect github.com/chainreactors/neutron/operators/full v0.1.1-0.20260704194031-f57d0a560e32 // indirect github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe // indirect - github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863 // indirect + github.com/chainreactors/utils/cert v0.0.0-20260722180147-5b1816060721 // indirect github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 // indirect github.com/charlievieth/fastwalk v1.0.14 // indirect github.com/charmbracelet/bubbletea v1.3.10 @@ -243,7 +238,7 @@ require ( github.com/sahilm/fuzzy v0.1.1 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/satori/go.uuid v1.2.0 // indirect github.com/sijms/go-ora/v2 v2.9.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect diff --git a/go.sum b/go.sum index f42a41db..98547158 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,6 @@ github.com/bits-and-blooms/bloom/v3 v3.5.0 h1:AKDvi1V3xJCmSR6QhcBfHbCN4Vf8FfxeWk github.com/bits-and-blooms/bloom/v3 v3.5.0/go.mod h1:Y8vrn7nk1tPIlmLtW2ZPV+W7StdVMor6bC1xgpjMZFs= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= -github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0= github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= @@ -218,10 +216,8 @@ github.com/chainreactors/tui/readline v0.0.0-20260723062039-ed89e758c21b/go.mod github.com/chainreactors/utils v0.0.0-20240716182459-e85f2b01ee16/go.mod h1:LajXuvESQwP+qCMAvlcoSXppQCjuLlBrnQpu9XQ1HtU= github.com/chainreactors/utils v0.0.0-20260711153742-f3d210a5fa9d h1:wlJ6oMbVLKrpxHmaXGSxmJt1F8l3kvqily0N58FGfLM= github.com/chainreactors/utils v0.0.0-20260711153742-f3d210a5fa9d/go.mod h1:xwbUlFoSSxLHujyb8D48o1s2DqmEAxUNfxIy0DVUmcg= -github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863 h1:41tvJzi9t1NUlM/CzVdl8OG+W6PMFChsDOChohI2VeU= -github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863/go.mod h1:xvvWMcU9Fcht6GR1cc9ceAZ3/Hl2HrkoRzpeyOzx1rQ= -github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 h1:r6UUUUQt4r/0SL6vgrwoq6ynidAkN3auSZsvzZ5BBRE= -github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M= +github.com/chainreactors/utils/cert v0.0.0-20260722180147-5b1816060721 h1:mtC+2UKpXO5Yel5JL2Ah6Z2r/X6wx4Fbii/36MmKcLI= +github.com/chainreactors/utils/cert v0.0.0-20260722180147-5b1816060721/go.mod h1:xvvWMcU9Fcht6GR1cc9ceAZ3/Hl2HrkoRzpeyOzx1rQ= github.com/chainreactors/utils/mitmproxy v0.0.0-20260722180147-5b1816060721 h1:BJh043izz46BCpNN3SJBGwEQDWW9SrKLGUFrbP5+/H0= github.com/chainreactors/utils/mitmproxy v0.0.0-20260722180147-5b1816060721/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M= github.com/chainreactors/utils/parsers v0.0.3 h1:3ld7xG5TSvzikVOCkQHjqjHO3otjODwSwHQDkMKbu5o= @@ -824,7 +820,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagernet/sing v0.7.6 h1:6LBfDH+aI/26J3r9UHlaxTNjJeMhBpU/wrk0JKDZYI4= github.com/sagernet/sing v0.7.6/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= @@ -1042,8 +1037,6 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= -go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1166,7 +1159,6 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= @@ -1503,13 +1495,7 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1537,10 +1523,6 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= -google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1557,8 +1539,6 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= diff --git a/pkg/rpc/agent/agent.pb.go b/pkg/rpc/agent/agent.pb.go new file mode 100644 index 00000000..c4dc103c --- /dev/null +++ b/pkg/rpc/agent/agent.pb.go @@ -0,0 +1,109 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/rpc/agent.proto + +package agent + +import ( + agent "github.com/chainreactors/aiscan/pkg/types/agent" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_aiscan_rpc_agent_proto protoreflect.FileDescriptor + +var file_aiscan_rpc_agent_proto_rawDesc = []byte{ + 0x0a, 0x16, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x1a, 0x18, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x32, 0xff, 0x02, 0x0a, 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4f, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x73, 0x12, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x6f, + 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x6f, 0x63, + 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x25, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4c, + 0x69, 0x73, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x10, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68, + 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68, + 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e, 0x53, 0x74, 0x6f, + 0x70, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x4c, + 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x24, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x53, 0x74, 0x6f, 0x70, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x35, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, + 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x72, 0x70, + 0x63, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x3b, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var file_aiscan_rpc_agent_proto_goTypes = []interface{}{ + (*agent.ListAgentsRequest)(nil), // 0: aiscan.agent.ListAgentsRequest + (*agent.ListLocalAgentsRequest)(nil), // 1: aiscan.agent.ListLocalAgentsRequest + (*agent.LaunchLocalAgentRequest)(nil), // 2: aiscan.agent.LaunchLocalAgentRequest + (*agent.StopLocalAgentRequest)(nil), // 3: aiscan.agent.StopLocalAgentRequest + (*agent.ListAgentsResponse)(nil), // 4: aiscan.agent.ListAgentsResponse + (*agent.ListLocalAgentsResponse)(nil), // 5: aiscan.agent.ListLocalAgentsResponse + (*agent.LaunchLocalAgentResponse)(nil), // 6: aiscan.agent.LaunchLocalAgentResponse + (*agent.StopLocalAgentResponse)(nil), // 7: aiscan.agent.StopLocalAgentResponse +} +var file_aiscan_rpc_agent_proto_depIdxs = []int32{ + 0, // 0: aiscan.rpc.agent.AgentService.ListAgents:input_type -> aiscan.agent.ListAgentsRequest + 1, // 1: aiscan.rpc.agent.AgentService.ListLocalAgents:input_type -> aiscan.agent.ListLocalAgentsRequest + 2, // 2: aiscan.rpc.agent.AgentService.LaunchLocalAgent:input_type -> aiscan.agent.LaunchLocalAgentRequest + 3, // 3: aiscan.rpc.agent.AgentService.StopLocalAgent:input_type -> aiscan.agent.StopLocalAgentRequest + 4, // 4: aiscan.rpc.agent.AgentService.ListAgents:output_type -> aiscan.agent.ListAgentsResponse + 5, // 5: aiscan.rpc.agent.AgentService.ListLocalAgents:output_type -> aiscan.agent.ListLocalAgentsResponse + 6, // 6: aiscan.rpc.agent.AgentService.LaunchLocalAgent:output_type -> aiscan.agent.LaunchLocalAgentResponse + 7, // 7: aiscan.rpc.agent.AgentService.StopLocalAgent:output_type -> aiscan.agent.StopLocalAgentResponse + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_aiscan_rpc_agent_proto_init() } +func file_aiscan_rpc_agent_proto_init() { + if File_aiscan_rpc_agent_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_rpc_agent_proto_rawDesc, + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_rpc_agent_proto_goTypes, + DependencyIndexes: file_aiscan_rpc_agent_proto_depIdxs, + }.Build() + File_aiscan_rpc_agent_proto = out.File + file_aiscan_rpc_agent_proto_rawDesc = nil + file_aiscan_rpc_agent_proto_goTypes = nil + file_aiscan_rpc_agent_proto_depIdxs = nil +} diff --git a/pkg/rpc/agent/agentconnect/agent.connect.go b/pkg/rpc/agent/agentconnect/agent.connect.go new file mode 100644 index 00000000..c9e030c5 --- /dev/null +++ b/pkg/rpc/agent/agentconnect/agent.connect.go @@ -0,0 +1,196 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: aiscan/rpc/agent.proto + +package agentconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + agent1 "github.com/chainreactors/aiscan/pkg/rpc/agent" + agent "github.com/chainreactors/aiscan/pkg/types/agent" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // AgentServiceName is the fully-qualified name of the AgentService service. + AgentServiceName = "aiscan.rpc.agent.AgentService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // AgentServiceListAgentsProcedure is the fully-qualified name of the AgentService's ListAgents RPC. + AgentServiceListAgentsProcedure = "/aiscan.rpc.agent.AgentService/ListAgents" + // AgentServiceListLocalAgentsProcedure is the fully-qualified name of the AgentService's + // ListLocalAgents RPC. + AgentServiceListLocalAgentsProcedure = "/aiscan.rpc.agent.AgentService/ListLocalAgents" + // AgentServiceLaunchLocalAgentProcedure is the fully-qualified name of the AgentService's + // LaunchLocalAgent RPC. + AgentServiceLaunchLocalAgentProcedure = "/aiscan.rpc.agent.AgentService/LaunchLocalAgent" + // AgentServiceStopLocalAgentProcedure is the fully-qualified name of the AgentService's + // StopLocalAgent RPC. + AgentServiceStopLocalAgentProcedure = "/aiscan.rpc.agent.AgentService/StopLocalAgent" +) + +// AgentServiceClient is a client for the aiscan.rpc.agent.AgentService service. +type AgentServiceClient interface { + ListAgents(context.Context, *connect.Request[agent.ListAgentsRequest]) (*connect.Response[agent.ListAgentsResponse], error) + ListLocalAgents(context.Context, *connect.Request[agent.ListLocalAgentsRequest]) (*connect.Response[agent.ListLocalAgentsResponse], error) + LaunchLocalAgent(context.Context, *connect.Request[agent.LaunchLocalAgentRequest]) (*connect.Response[agent.LaunchLocalAgentResponse], error) + StopLocalAgent(context.Context, *connect.Request[agent.StopLocalAgentRequest]) (*connect.Response[agent.StopLocalAgentResponse], error) +} + +// NewAgentServiceClient constructs a client for the aiscan.rpc.agent.AgentService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AgentServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + agentServiceMethods := agent1.File_aiscan_rpc_agent_proto.Services().ByName("AgentService").Methods() + return &agentServiceClient{ + listAgents: connect.NewClient[agent.ListAgentsRequest, agent.ListAgentsResponse]( + httpClient, + baseURL+AgentServiceListAgentsProcedure, + connect.WithSchema(agentServiceMethods.ByName("ListAgents")), + connect.WithClientOptions(opts...), + ), + listLocalAgents: connect.NewClient[agent.ListLocalAgentsRequest, agent.ListLocalAgentsResponse]( + httpClient, + baseURL+AgentServiceListLocalAgentsProcedure, + connect.WithSchema(agentServiceMethods.ByName("ListLocalAgents")), + connect.WithClientOptions(opts...), + ), + launchLocalAgent: connect.NewClient[agent.LaunchLocalAgentRequest, agent.LaunchLocalAgentResponse]( + httpClient, + baseURL+AgentServiceLaunchLocalAgentProcedure, + connect.WithSchema(agentServiceMethods.ByName("LaunchLocalAgent")), + connect.WithClientOptions(opts...), + ), + stopLocalAgent: connect.NewClient[agent.StopLocalAgentRequest, agent.StopLocalAgentResponse]( + httpClient, + baseURL+AgentServiceStopLocalAgentProcedure, + connect.WithSchema(agentServiceMethods.ByName("StopLocalAgent")), + connect.WithClientOptions(opts...), + ), + } +} + +// agentServiceClient implements AgentServiceClient. +type agentServiceClient struct { + listAgents *connect.Client[agent.ListAgentsRequest, agent.ListAgentsResponse] + listLocalAgents *connect.Client[agent.ListLocalAgentsRequest, agent.ListLocalAgentsResponse] + launchLocalAgent *connect.Client[agent.LaunchLocalAgentRequest, agent.LaunchLocalAgentResponse] + stopLocalAgent *connect.Client[agent.StopLocalAgentRequest, agent.StopLocalAgentResponse] +} + +// ListAgents calls aiscan.rpc.agent.AgentService.ListAgents. +func (c *agentServiceClient) ListAgents(ctx context.Context, req *connect.Request[agent.ListAgentsRequest]) (*connect.Response[agent.ListAgentsResponse], error) { + return c.listAgents.CallUnary(ctx, req) +} + +// ListLocalAgents calls aiscan.rpc.agent.AgentService.ListLocalAgents. +func (c *agentServiceClient) ListLocalAgents(ctx context.Context, req *connect.Request[agent.ListLocalAgentsRequest]) (*connect.Response[agent.ListLocalAgentsResponse], error) { + return c.listLocalAgents.CallUnary(ctx, req) +} + +// LaunchLocalAgent calls aiscan.rpc.agent.AgentService.LaunchLocalAgent. +func (c *agentServiceClient) LaunchLocalAgent(ctx context.Context, req *connect.Request[agent.LaunchLocalAgentRequest]) (*connect.Response[agent.LaunchLocalAgentResponse], error) { + return c.launchLocalAgent.CallUnary(ctx, req) +} + +// StopLocalAgent calls aiscan.rpc.agent.AgentService.StopLocalAgent. +func (c *agentServiceClient) StopLocalAgent(ctx context.Context, req *connect.Request[agent.StopLocalAgentRequest]) (*connect.Response[agent.StopLocalAgentResponse], error) { + return c.stopLocalAgent.CallUnary(ctx, req) +} + +// AgentServiceHandler is an implementation of the aiscan.rpc.agent.AgentService service. +type AgentServiceHandler interface { + ListAgents(context.Context, *connect.Request[agent.ListAgentsRequest]) (*connect.Response[agent.ListAgentsResponse], error) + ListLocalAgents(context.Context, *connect.Request[agent.ListLocalAgentsRequest]) (*connect.Response[agent.ListLocalAgentsResponse], error) + LaunchLocalAgent(context.Context, *connect.Request[agent.LaunchLocalAgentRequest]) (*connect.Response[agent.LaunchLocalAgentResponse], error) + StopLocalAgent(context.Context, *connect.Request[agent.StopLocalAgentRequest]) (*connect.Response[agent.StopLocalAgentResponse], error) +} + +// NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + agentServiceMethods := agent1.File_aiscan_rpc_agent_proto.Services().ByName("AgentService").Methods() + agentServiceListAgentsHandler := connect.NewUnaryHandler( + AgentServiceListAgentsProcedure, + svc.ListAgents, + connect.WithSchema(agentServiceMethods.ByName("ListAgents")), + connect.WithHandlerOptions(opts...), + ) + agentServiceListLocalAgentsHandler := connect.NewUnaryHandler( + AgentServiceListLocalAgentsProcedure, + svc.ListLocalAgents, + connect.WithSchema(agentServiceMethods.ByName("ListLocalAgents")), + connect.WithHandlerOptions(opts...), + ) + agentServiceLaunchLocalAgentHandler := connect.NewUnaryHandler( + AgentServiceLaunchLocalAgentProcedure, + svc.LaunchLocalAgent, + connect.WithSchema(agentServiceMethods.ByName("LaunchLocalAgent")), + connect.WithHandlerOptions(opts...), + ) + agentServiceStopLocalAgentHandler := connect.NewUnaryHandler( + AgentServiceStopLocalAgentProcedure, + svc.StopLocalAgent, + connect.WithSchema(agentServiceMethods.ByName("StopLocalAgent")), + connect.WithHandlerOptions(opts...), + ) + return "/aiscan.rpc.agent.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AgentServiceListAgentsProcedure: + agentServiceListAgentsHandler.ServeHTTP(w, r) + case AgentServiceListLocalAgentsProcedure: + agentServiceListLocalAgentsHandler.ServeHTTP(w, r) + case AgentServiceLaunchLocalAgentProcedure: + agentServiceLaunchLocalAgentHandler.ServeHTTP(w, r) + case AgentServiceStopLocalAgentProcedure: + agentServiceStopLocalAgentHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAgentServiceHandler struct{} + +func (UnimplementedAgentServiceHandler) ListAgents(context.Context, *connect.Request[agent.ListAgentsRequest]) (*connect.Response[agent.ListAgentsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.agent.AgentService.ListAgents is not implemented")) +} + +func (UnimplementedAgentServiceHandler) ListLocalAgents(context.Context, *connect.Request[agent.ListLocalAgentsRequest]) (*connect.Response[agent.ListLocalAgentsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.agent.AgentService.ListLocalAgents is not implemented")) +} + +func (UnimplementedAgentServiceHandler) LaunchLocalAgent(context.Context, *connect.Request[agent.LaunchLocalAgentRequest]) (*connect.Response[agent.LaunchLocalAgentResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.agent.AgentService.LaunchLocalAgent is not implemented")) +} + +func (UnimplementedAgentServiceHandler) StopLocalAgent(context.Context, *connect.Request[agent.StopLocalAgentRequest]) (*connect.Response[agent.StopLocalAgentResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.agent.AgentService.StopLocalAgent is not implemented")) +} diff --git a/pkg/rpc/chat/chat.pb.go b/pkg/rpc/chat/chat.pb.go new file mode 100644 index 00000000..ff67ba6d --- /dev/null +++ b/pkg/rpc/chat/chat.pb.go @@ -0,0 +1,126 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/rpc/chat.proto + +package chat + +import ( + aop "github.com/chainreactors/aiscan/aop" + chat "github.com/chainreactors/aiscan/pkg/types/chat" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_aiscan_rpc_chat_proto protoreflect.FileDescriptor + +var file_aiscan_rpc_chat_proto_rawDesc = []byte{ + 0x0a, 0x15, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x63, 0x68, 0x61, + 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, + 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x17, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x32, 0xf5, 0x03, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, + 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x63, 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0a, 0x47, 0x65, 0x74, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x65, + 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, + 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x21, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, + 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, + 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x16, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4c, + 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x17, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, + 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, + 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x3b, 0x63, 0x68, 0x61, 0x74, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var file_aiscan_rpc_chat_proto_goTypes = []interface{}{ + (*chat.ListSessionsRequest)(nil), // 0: aiscan.chat.ListSessionsRequest + (*chat.GetSessionRequest)(nil), // 1: aiscan.chat.GetSessionRequest + (*chat.ResetSessionRequest)(nil), // 2: aiscan.chat.ResetSessionRequest + (*chat.DeleteSessionRequest)(nil), // 3: aiscan.chat.DeleteSessionRequest + (*chat.ListCommandsRequest)(nil), // 4: aiscan.chat.ListCommandsRequest + (*aop.ListEventsRequest)(nil), // 5: aop.ListEventsRequest + (*chat.ListSessionsResponse)(nil), // 6: aiscan.chat.ListSessionsResponse + (*chat.GetSessionResponse)(nil), // 7: aiscan.chat.GetSessionResponse + (*chat.ResetSessionResponse)(nil), // 8: aiscan.chat.ResetSessionResponse + (*chat.DeleteSessionResponse)(nil), // 9: aiscan.chat.DeleteSessionResponse + (*chat.ListCommandsResponse)(nil), // 10: aiscan.chat.ListCommandsResponse + (*aop.ListEventsResponse)(nil), // 11: aop.ListEventsResponse +} +var file_aiscan_rpc_chat_proto_depIdxs = []int32{ + 0, // 0: aiscan.rpc.chat.SessionService.ListSessions:input_type -> aiscan.chat.ListSessionsRequest + 1, // 1: aiscan.rpc.chat.SessionService.GetSession:input_type -> aiscan.chat.GetSessionRequest + 2, // 2: aiscan.rpc.chat.SessionService.ResetSession:input_type -> aiscan.chat.ResetSessionRequest + 3, // 3: aiscan.rpc.chat.SessionService.DeleteSession:input_type -> aiscan.chat.DeleteSessionRequest + 4, // 4: aiscan.rpc.chat.SessionService.ListCommands:input_type -> aiscan.chat.ListCommandsRequest + 5, // 5: aiscan.rpc.chat.SessionService.ListEvents:input_type -> aop.ListEventsRequest + 6, // 6: aiscan.rpc.chat.SessionService.ListSessions:output_type -> aiscan.chat.ListSessionsResponse + 7, // 7: aiscan.rpc.chat.SessionService.GetSession:output_type -> aiscan.chat.GetSessionResponse + 8, // 8: aiscan.rpc.chat.SessionService.ResetSession:output_type -> aiscan.chat.ResetSessionResponse + 9, // 9: aiscan.rpc.chat.SessionService.DeleteSession:output_type -> aiscan.chat.DeleteSessionResponse + 10, // 10: aiscan.rpc.chat.SessionService.ListCommands:output_type -> aiscan.chat.ListCommandsResponse + 11, // 11: aiscan.rpc.chat.SessionService.ListEvents:output_type -> aop.ListEventsResponse + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_aiscan_rpc_chat_proto_init() } +func file_aiscan_rpc_chat_proto_init() { + if File_aiscan_rpc_chat_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_rpc_chat_proto_rawDesc, + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_rpc_chat_proto_goTypes, + DependencyIndexes: file_aiscan_rpc_chat_proto_depIdxs, + }.Build() + File_aiscan_rpc_chat_proto = out.File + file_aiscan_rpc_chat_proto_rawDesc = nil + file_aiscan_rpc_chat_proto_goTypes = nil + file_aiscan_rpc_chat_proto_depIdxs = nil +} diff --git a/aop/aiscan/chat/chatconnect/session.connect.go b/pkg/rpc/chat/chatconnect/chat.connect.go similarity index 63% rename from aop/aiscan/chat/chatconnect/session.connect.go rename to pkg/rpc/chat/chatconnect/chat.connect.go index 6610ad2b..85436816 100644 --- a/aop/aiscan/chat/chatconnect/session.connect.go +++ b/pkg/rpc/chat/chatconnect/chat.connect.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // -// Source: aiscan/chat/session.proto +// Source: aiscan/rpc/chat.proto package chatconnect @@ -8,7 +8,9 @@ import ( connect "connectrpc.com/connect" context "context" errors "errors" - chat "github.com/chainreactors/aiscan/aop/aiscan/chat" + aop "github.com/chainreactors/aiscan/aop" + chat1 "github.com/chainreactors/aiscan/pkg/rpc/chat" + chat "github.com/chainreactors/aiscan/pkg/types/chat" http "net/http" strings "strings" ) @@ -22,7 +24,7 @@ const _ = connect.IsAtLeastVersion1_13_0 const ( // SessionServiceName is the fully-qualified name of the SessionService service. - SessionServiceName = "aiscan.chat.SessionService" + SessionServiceName = "aiscan.rpc.chat.SessionService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're @@ -35,39 +37,35 @@ const ( const ( // SessionServiceListSessionsProcedure is the fully-qualified name of the SessionService's // ListSessions RPC. - SessionServiceListSessionsProcedure = "/aiscan.chat.SessionService/ListSessions" + SessionServiceListSessionsProcedure = "/aiscan.rpc.chat.SessionService/ListSessions" // SessionServiceGetSessionProcedure is the fully-qualified name of the SessionService's GetSession // RPC. - SessionServiceGetSessionProcedure = "/aiscan.chat.SessionService/GetSession" + SessionServiceGetSessionProcedure = "/aiscan.rpc.chat.SessionService/GetSession" // SessionServiceResetSessionProcedure is the fully-qualified name of the SessionService's // ResetSession RPC. - SessionServiceResetSessionProcedure = "/aiscan.chat.SessionService/ResetSession" + SessionServiceResetSessionProcedure = "/aiscan.rpc.chat.SessionService/ResetSession" // SessionServiceDeleteSessionProcedure is the fully-qualified name of the SessionService's // DeleteSession RPC. - SessionServiceDeleteSessionProcedure = "/aiscan.chat.SessionService/DeleteSession" + SessionServiceDeleteSessionProcedure = "/aiscan.rpc.chat.SessionService/DeleteSession" // SessionServiceListCommandsProcedure is the fully-qualified name of the SessionService's // ListCommands RPC. - SessionServiceListCommandsProcedure = "/aiscan.chat.SessionService/ListCommands" - // SessionServiceExecuteCommandProcedure is the fully-qualified name of the SessionService's - // ExecuteCommand RPC. - SessionServiceExecuteCommandProcedure = "/aiscan.chat.SessionService/ExecuteCommand" - // SessionServiceUploadSessionFileProcedure is the fully-qualified name of the SessionService's - // UploadSessionFile RPC. - SessionServiceUploadSessionFileProcedure = "/aiscan.chat.SessionService/UploadSessionFile" + SessionServiceListCommandsProcedure = "/aiscan.rpc.chat.SessionService/ListCommands" + // SessionServiceListEventsProcedure is the fully-qualified name of the SessionService's ListEvents + // RPC. + SessionServiceListEventsProcedure = "/aiscan.rpc.chat.SessionService/ListEvents" ) -// SessionServiceClient is a client for the aiscan.chat.SessionService service. +// SessionServiceClient is a client for the aiscan.rpc.chat.SessionService service. type SessionServiceClient interface { ListSessions(context.Context, *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) GetSession(context.Context, *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) ResetSession(context.Context, *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) DeleteSession(context.Context, *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) ListCommands(context.Context, *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) - ExecuteCommand(context.Context, *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) - UploadSessionFile(context.Context, *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) + ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) } -// NewSessionServiceClient constructs a client for the aiscan.chat.SessionService service. By +// NewSessionServiceClient constructs a client for the aiscan.rpc.chat.SessionService service. By // default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, // and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the // connect.WithGRPC() or connect.WithGRPCWeb() options. @@ -76,7 +74,7 @@ type SessionServiceClient interface { // http://api.acme.com or https://acme.com/grpc). func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SessionServiceClient { baseURL = strings.TrimRight(baseURL, "/") - sessionServiceMethods := chat.File_aiscan_chat_session_proto.Services().ByName("SessionService").Methods() + sessionServiceMethods := chat1.File_aiscan_rpc_chat_proto.Services().ByName("SessionService").Methods() return &sessionServiceClient{ listSessions: connect.NewClient[chat.ListSessionsRequest, chat.ListSessionsResponse]( httpClient, @@ -108,16 +106,10 @@ func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(sessionServiceMethods.ByName("ListCommands")), connect.WithClientOptions(opts...), ), - executeCommand: connect.NewClient[chat.ExecuteCommandRequest, chat.ExecuteCommandResponse]( + listEvents: connect.NewClient[aop.ListEventsRequest, aop.ListEventsResponse]( httpClient, - baseURL+SessionServiceExecuteCommandProcedure, - connect.WithSchema(sessionServiceMethods.ByName("ExecuteCommand")), - connect.WithClientOptions(opts...), - ), - uploadSessionFile: connect.NewClient[chat.UploadSessionFileRequest, chat.UploadSessionFileResponse]( - httpClient, - baseURL+SessionServiceUploadSessionFileProcedure, - connect.WithSchema(sessionServiceMethods.ByName("UploadSessionFile")), + baseURL+SessionServiceListEventsProcedure, + connect.WithSchema(sessionServiceMethods.ByName("ListEvents")), connect.WithClientOptions(opts...), ), } @@ -125,59 +117,52 @@ func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts // sessionServiceClient implements SessionServiceClient. type sessionServiceClient struct { - listSessions *connect.Client[chat.ListSessionsRequest, chat.ListSessionsResponse] - getSession *connect.Client[chat.GetSessionRequest, chat.GetSessionResponse] - resetSession *connect.Client[chat.ResetSessionRequest, chat.ResetSessionResponse] - deleteSession *connect.Client[chat.DeleteSessionRequest, chat.DeleteSessionResponse] - listCommands *connect.Client[chat.ListCommandsRequest, chat.ListCommandsResponse] - executeCommand *connect.Client[chat.ExecuteCommandRequest, chat.ExecuteCommandResponse] - uploadSessionFile *connect.Client[chat.UploadSessionFileRequest, chat.UploadSessionFileResponse] + listSessions *connect.Client[chat.ListSessionsRequest, chat.ListSessionsResponse] + getSession *connect.Client[chat.GetSessionRequest, chat.GetSessionResponse] + resetSession *connect.Client[chat.ResetSessionRequest, chat.ResetSessionResponse] + deleteSession *connect.Client[chat.DeleteSessionRequest, chat.DeleteSessionResponse] + listCommands *connect.Client[chat.ListCommandsRequest, chat.ListCommandsResponse] + listEvents *connect.Client[aop.ListEventsRequest, aop.ListEventsResponse] } -// ListSessions calls aiscan.chat.SessionService.ListSessions. +// ListSessions calls aiscan.rpc.chat.SessionService.ListSessions. func (c *sessionServiceClient) ListSessions(ctx context.Context, req *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) { return c.listSessions.CallUnary(ctx, req) } -// GetSession calls aiscan.chat.SessionService.GetSession. +// GetSession calls aiscan.rpc.chat.SessionService.GetSession. func (c *sessionServiceClient) GetSession(ctx context.Context, req *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) { return c.getSession.CallUnary(ctx, req) } -// ResetSession calls aiscan.chat.SessionService.ResetSession. +// ResetSession calls aiscan.rpc.chat.SessionService.ResetSession. func (c *sessionServiceClient) ResetSession(ctx context.Context, req *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) { return c.resetSession.CallUnary(ctx, req) } -// DeleteSession calls aiscan.chat.SessionService.DeleteSession. +// DeleteSession calls aiscan.rpc.chat.SessionService.DeleteSession. func (c *sessionServiceClient) DeleteSession(ctx context.Context, req *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) { return c.deleteSession.CallUnary(ctx, req) } -// ListCommands calls aiscan.chat.SessionService.ListCommands. +// ListCommands calls aiscan.rpc.chat.SessionService.ListCommands. func (c *sessionServiceClient) ListCommands(ctx context.Context, req *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) { return c.listCommands.CallUnary(ctx, req) } -// ExecuteCommand calls aiscan.chat.SessionService.ExecuteCommand. -func (c *sessionServiceClient) ExecuteCommand(ctx context.Context, req *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) { - return c.executeCommand.CallUnary(ctx, req) -} - -// UploadSessionFile calls aiscan.chat.SessionService.UploadSessionFile. -func (c *sessionServiceClient) UploadSessionFile(ctx context.Context, req *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) { - return c.uploadSessionFile.CallUnary(ctx, req) +// ListEvents calls aiscan.rpc.chat.SessionService.ListEvents. +func (c *sessionServiceClient) ListEvents(ctx context.Context, req *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { + return c.listEvents.CallUnary(ctx, req) } -// SessionServiceHandler is an implementation of the aiscan.chat.SessionService service. +// SessionServiceHandler is an implementation of the aiscan.rpc.chat.SessionService service. type SessionServiceHandler interface { ListSessions(context.Context, *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) GetSession(context.Context, *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) ResetSession(context.Context, *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) DeleteSession(context.Context, *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) ListCommands(context.Context, *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) - ExecuteCommand(context.Context, *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) - UploadSessionFile(context.Context, *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) + ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) } // NewSessionServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -186,7 +171,7 @@ type SessionServiceHandler interface { // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewSessionServiceHandler(svc SessionServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - sessionServiceMethods := chat.File_aiscan_chat_session_proto.Services().ByName("SessionService").Methods() + sessionServiceMethods := chat1.File_aiscan_rpc_chat_proto.Services().ByName("SessionService").Methods() sessionServiceListSessionsHandler := connect.NewUnaryHandler( SessionServiceListSessionsProcedure, svc.ListSessions, @@ -217,19 +202,13 @@ func NewSessionServiceHandler(svc SessionServiceHandler, opts ...connect.Handler connect.WithSchema(sessionServiceMethods.ByName("ListCommands")), connect.WithHandlerOptions(opts...), ) - sessionServiceExecuteCommandHandler := connect.NewUnaryHandler( - SessionServiceExecuteCommandProcedure, - svc.ExecuteCommand, - connect.WithSchema(sessionServiceMethods.ByName("ExecuteCommand")), - connect.WithHandlerOptions(opts...), - ) - sessionServiceUploadSessionFileHandler := connect.NewUnaryHandler( - SessionServiceUploadSessionFileProcedure, - svc.UploadSessionFile, - connect.WithSchema(sessionServiceMethods.ByName("UploadSessionFile")), + sessionServiceListEventsHandler := connect.NewUnaryHandler( + SessionServiceListEventsProcedure, + svc.ListEvents, + connect.WithSchema(sessionServiceMethods.ByName("ListEvents")), connect.WithHandlerOptions(opts...), ) - return "/aiscan.chat.SessionService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return "/aiscan.rpc.chat.SessionService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case SessionServiceListSessionsProcedure: sessionServiceListSessionsHandler.ServeHTTP(w, r) @@ -241,10 +220,8 @@ func NewSessionServiceHandler(svc SessionServiceHandler, opts ...connect.Handler sessionServiceDeleteSessionHandler.ServeHTTP(w, r) case SessionServiceListCommandsProcedure: sessionServiceListCommandsHandler.ServeHTTP(w, r) - case SessionServiceExecuteCommandProcedure: - sessionServiceExecuteCommandHandler.ServeHTTP(w, r) - case SessionServiceUploadSessionFileProcedure: - sessionServiceUploadSessionFileHandler.ServeHTTP(w, r) + case SessionServiceListEventsProcedure: + sessionServiceListEventsHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -255,29 +232,25 @@ func NewSessionServiceHandler(svc SessionServiceHandler, opts ...connect.Handler type UnimplementedSessionServiceHandler struct{} func (UnimplementedSessionServiceHandler) ListSessions(context.Context, *connect.Request[chat.ListSessionsRequest]) (*connect.Response[chat.ListSessionsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ListSessions is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.chat.SessionService.ListSessions is not implemented")) } func (UnimplementedSessionServiceHandler) GetSession(context.Context, *connect.Request[chat.GetSessionRequest]) (*connect.Response[chat.GetSessionResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.GetSession is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.chat.SessionService.GetSession is not implemented")) } func (UnimplementedSessionServiceHandler) ResetSession(context.Context, *connect.Request[chat.ResetSessionRequest]) (*connect.Response[chat.ResetSessionResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ResetSession is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.chat.SessionService.ResetSession is not implemented")) } func (UnimplementedSessionServiceHandler) DeleteSession(context.Context, *connect.Request[chat.DeleteSessionRequest]) (*connect.Response[chat.DeleteSessionResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.DeleteSession is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.chat.SessionService.DeleteSession is not implemented")) } func (UnimplementedSessionServiceHandler) ListCommands(context.Context, *connect.Request[chat.ListCommandsRequest]) (*connect.Response[chat.ListCommandsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ListCommands is not implemented")) -} - -func (UnimplementedSessionServiceHandler) ExecuteCommand(context.Context, *connect.Request[chat.ExecuteCommandRequest]) (*connect.Response[chat.ExecuteCommandResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.ExecuteCommand is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.chat.SessionService.ListCommands is not implemented")) } -func (UnimplementedSessionServiceHandler) UploadSessionFile(context.Context, *connect.Request[chat.UploadSessionFileRequest]) (*connect.Response[chat.UploadSessionFileResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.chat.SessionService.UploadSessionFile is not implemented")) +func (UnimplementedSessionServiceHandler) ListEvents(context.Context, *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.chat.SessionService.ListEvents is not implemented")) } diff --git a/pkg/rpc/config/config.pb.go b/pkg/rpc/config/config.pb.go new file mode 100644 index 00000000..7d434a3f --- /dev/null +++ b/pkg/rpc/config/config.pb.go @@ -0,0 +1,128 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/rpc/config.proto + +package config + +import ( + config "github.com/chainreactors/aiscan/pkg/types/config" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_aiscan_rpc_config_proto protoreflect.FileDescriptor + +var file_aiscan_rpc_config_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x11, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x1a, 0x19, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x32, 0x89, 0x04, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x20, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, + 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x22, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x23, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x60, 0x0a, 0x0f, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x25, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x26, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x2e, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x48, 0x0a, 0x07, 0x54, 0x65, 0x73, 0x74, + 0x4c, 0x4c, 0x4d, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x4c, 0x4c, 0x4d, 0x50, 0x72, 0x6f, 0x62, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x4c, 0x4c, 0x4d, 0x50, 0x72, 0x6f, 0x62, 0x65, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x12, 0x4d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x73, + 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x2e, 0x4c, 0x4c, 0x4d, 0x50, 0x72, 0x6f, 0x62, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x12, 0x5d, 0x0a, 0x0e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, + 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x3b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var file_aiscan_rpc_config_proto_goTypes = []interface{}{ + (*emptypb.Empty)(nil), // 0: google.protobuf.Empty + (*config.UpdateConfigRequest)(nil), // 1: aiscan.config.UpdateConfigRequest + (*config.ActivateProfileRequest)(nil), // 2: aiscan.config.ActivateProfileRequest + (*config.LLMProbeRequest)(nil), // 3: aiscan.config.LLMProbeRequest + (*config.TestConnectionRequest)(nil), // 4: aiscan.config.TestConnectionRequest + (*config.GetConfigResponse)(nil), // 5: aiscan.config.GetConfigResponse + (*config.UpdateConfigResponse)(nil), // 6: aiscan.config.UpdateConfigResponse + (*config.ActivateProfileResponse)(nil), // 7: aiscan.config.ActivateProfileResponse + (*config.LLMProbeResult)(nil), // 8: aiscan.config.LLMProbeResult + (*config.ListModelsResult)(nil), // 9: aiscan.config.ListModelsResult + (*config.TestConnectionResponse)(nil), // 10: aiscan.config.TestConnectionResponse +} +var file_aiscan_rpc_config_proto_depIdxs = []int32{ + 0, // 0: aiscan.rpc.config.ConfigService.GetConfig:input_type -> google.protobuf.Empty + 1, // 1: aiscan.rpc.config.ConfigService.UpdateConfig:input_type -> aiscan.config.UpdateConfigRequest + 2, // 2: aiscan.rpc.config.ConfigService.ActivateProfile:input_type -> aiscan.config.ActivateProfileRequest + 3, // 3: aiscan.rpc.config.ConfigService.TestLLM:input_type -> aiscan.config.LLMProbeRequest + 3, // 4: aiscan.rpc.config.ConfigService.ListModels:input_type -> aiscan.config.LLMProbeRequest + 4, // 5: aiscan.rpc.config.ConfigService.TestConnection:input_type -> aiscan.config.TestConnectionRequest + 5, // 6: aiscan.rpc.config.ConfigService.GetConfig:output_type -> aiscan.config.GetConfigResponse + 6, // 7: aiscan.rpc.config.ConfigService.UpdateConfig:output_type -> aiscan.config.UpdateConfigResponse + 7, // 8: aiscan.rpc.config.ConfigService.ActivateProfile:output_type -> aiscan.config.ActivateProfileResponse + 8, // 9: aiscan.rpc.config.ConfigService.TestLLM:output_type -> aiscan.config.LLMProbeResult + 9, // 10: aiscan.rpc.config.ConfigService.ListModels:output_type -> aiscan.config.ListModelsResult + 10, // 11: aiscan.rpc.config.ConfigService.TestConnection:output_type -> aiscan.config.TestConnectionResponse + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_aiscan_rpc_config_proto_init() } +func file_aiscan_rpc_config_proto_init() { + if File_aiscan_rpc_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_rpc_config_proto_rawDesc, + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_rpc_config_proto_goTypes, + DependencyIndexes: file_aiscan_rpc_config_proto_depIdxs, + }.Build() + File_aiscan_rpc_config_proto = out.File + file_aiscan_rpc_config_proto_rawDesc = nil + file_aiscan_rpc_config_proto_goTypes = nil + file_aiscan_rpc_config_proto_depIdxs = nil +} diff --git a/pkg/rpc/config/configconnect/config.connect.go b/pkg/rpc/config/configconnect/config.connect.go new file mode 100644 index 00000000..0d47baa2 --- /dev/null +++ b/pkg/rpc/config/configconnect/config.connect.go @@ -0,0 +1,254 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: aiscan/rpc/config.proto + +package configconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + config1 "github.com/chainreactors/aiscan/pkg/rpc/config" + config "github.com/chainreactors/aiscan/pkg/types/config" + emptypb "google.golang.org/protobuf/types/known/emptypb" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ConfigServiceName is the fully-qualified name of the ConfigService service. + ConfigServiceName = "aiscan.rpc.config.ConfigService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ConfigServiceGetConfigProcedure is the fully-qualified name of the ConfigService's GetConfig RPC. + ConfigServiceGetConfigProcedure = "/aiscan.rpc.config.ConfigService/GetConfig" + // ConfigServiceUpdateConfigProcedure is the fully-qualified name of the ConfigService's + // UpdateConfig RPC. + ConfigServiceUpdateConfigProcedure = "/aiscan.rpc.config.ConfigService/UpdateConfig" + // ConfigServiceActivateProfileProcedure is the fully-qualified name of the ConfigService's + // ActivateProfile RPC. + ConfigServiceActivateProfileProcedure = "/aiscan.rpc.config.ConfigService/ActivateProfile" + // ConfigServiceTestLLMProcedure is the fully-qualified name of the ConfigService's TestLLM RPC. + ConfigServiceTestLLMProcedure = "/aiscan.rpc.config.ConfigService/TestLLM" + // ConfigServiceListModelsProcedure is the fully-qualified name of the ConfigService's ListModels + // RPC. + ConfigServiceListModelsProcedure = "/aiscan.rpc.config.ConfigService/ListModels" + // ConfigServiceTestConnectionProcedure is the fully-qualified name of the ConfigService's + // TestConnection RPC. + ConfigServiceTestConnectionProcedure = "/aiscan.rpc.config.ConfigService/TestConnection" +) + +// ConfigServiceClient is a client for the aiscan.rpc.config.ConfigService service. +type ConfigServiceClient interface { + GetConfig(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[config.GetConfigResponse], error) + UpdateConfig(context.Context, *connect.Request[config.UpdateConfigRequest]) (*connect.Response[config.UpdateConfigResponse], error) + ActivateProfile(context.Context, *connect.Request[config.ActivateProfileRequest]) (*connect.Response[config.ActivateProfileResponse], error) + TestLLM(context.Context, *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.LLMProbeResult], error) + ListModels(context.Context, *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.ListModelsResult], error) + TestConnection(context.Context, *connect.Request[config.TestConnectionRequest]) (*connect.Response[config.TestConnectionResponse], error) +} + +// NewConfigServiceClient constructs a client for the aiscan.rpc.config.ConfigService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewConfigServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ConfigServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + configServiceMethods := config1.File_aiscan_rpc_config_proto.Services().ByName("ConfigService").Methods() + return &configServiceClient{ + getConfig: connect.NewClient[emptypb.Empty, config.GetConfigResponse]( + httpClient, + baseURL+ConfigServiceGetConfigProcedure, + connect.WithSchema(configServiceMethods.ByName("GetConfig")), + connect.WithClientOptions(opts...), + ), + updateConfig: connect.NewClient[config.UpdateConfigRequest, config.UpdateConfigResponse]( + httpClient, + baseURL+ConfigServiceUpdateConfigProcedure, + connect.WithSchema(configServiceMethods.ByName("UpdateConfig")), + connect.WithClientOptions(opts...), + ), + activateProfile: connect.NewClient[config.ActivateProfileRequest, config.ActivateProfileResponse]( + httpClient, + baseURL+ConfigServiceActivateProfileProcedure, + connect.WithSchema(configServiceMethods.ByName("ActivateProfile")), + connect.WithClientOptions(opts...), + ), + testLLM: connect.NewClient[config.LLMProbeRequest, config.LLMProbeResult]( + httpClient, + baseURL+ConfigServiceTestLLMProcedure, + connect.WithSchema(configServiceMethods.ByName("TestLLM")), + connect.WithClientOptions(opts...), + ), + listModels: connect.NewClient[config.LLMProbeRequest, config.ListModelsResult]( + httpClient, + baseURL+ConfigServiceListModelsProcedure, + connect.WithSchema(configServiceMethods.ByName("ListModels")), + connect.WithClientOptions(opts...), + ), + testConnection: connect.NewClient[config.TestConnectionRequest, config.TestConnectionResponse]( + httpClient, + baseURL+ConfigServiceTestConnectionProcedure, + connect.WithSchema(configServiceMethods.ByName("TestConnection")), + connect.WithClientOptions(opts...), + ), + } +} + +// configServiceClient implements ConfigServiceClient. +type configServiceClient struct { + getConfig *connect.Client[emptypb.Empty, config.GetConfigResponse] + updateConfig *connect.Client[config.UpdateConfigRequest, config.UpdateConfigResponse] + activateProfile *connect.Client[config.ActivateProfileRequest, config.ActivateProfileResponse] + testLLM *connect.Client[config.LLMProbeRequest, config.LLMProbeResult] + listModels *connect.Client[config.LLMProbeRequest, config.ListModelsResult] + testConnection *connect.Client[config.TestConnectionRequest, config.TestConnectionResponse] +} + +// GetConfig calls aiscan.rpc.config.ConfigService.GetConfig. +func (c *configServiceClient) GetConfig(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[config.GetConfigResponse], error) { + return c.getConfig.CallUnary(ctx, req) +} + +// UpdateConfig calls aiscan.rpc.config.ConfigService.UpdateConfig. +func (c *configServiceClient) UpdateConfig(ctx context.Context, req *connect.Request[config.UpdateConfigRequest]) (*connect.Response[config.UpdateConfigResponse], error) { + return c.updateConfig.CallUnary(ctx, req) +} + +// ActivateProfile calls aiscan.rpc.config.ConfigService.ActivateProfile. +func (c *configServiceClient) ActivateProfile(ctx context.Context, req *connect.Request[config.ActivateProfileRequest]) (*connect.Response[config.ActivateProfileResponse], error) { + return c.activateProfile.CallUnary(ctx, req) +} + +// TestLLM calls aiscan.rpc.config.ConfigService.TestLLM. +func (c *configServiceClient) TestLLM(ctx context.Context, req *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.LLMProbeResult], error) { + return c.testLLM.CallUnary(ctx, req) +} + +// ListModels calls aiscan.rpc.config.ConfigService.ListModels. +func (c *configServiceClient) ListModels(ctx context.Context, req *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.ListModelsResult], error) { + return c.listModels.CallUnary(ctx, req) +} + +// TestConnection calls aiscan.rpc.config.ConfigService.TestConnection. +func (c *configServiceClient) TestConnection(ctx context.Context, req *connect.Request[config.TestConnectionRequest]) (*connect.Response[config.TestConnectionResponse], error) { + return c.testConnection.CallUnary(ctx, req) +} + +// ConfigServiceHandler is an implementation of the aiscan.rpc.config.ConfigService service. +type ConfigServiceHandler interface { + GetConfig(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[config.GetConfigResponse], error) + UpdateConfig(context.Context, *connect.Request[config.UpdateConfigRequest]) (*connect.Response[config.UpdateConfigResponse], error) + ActivateProfile(context.Context, *connect.Request[config.ActivateProfileRequest]) (*connect.Response[config.ActivateProfileResponse], error) + TestLLM(context.Context, *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.LLMProbeResult], error) + ListModels(context.Context, *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.ListModelsResult], error) + TestConnection(context.Context, *connect.Request[config.TestConnectionRequest]) (*connect.Response[config.TestConnectionResponse], error) +} + +// NewConfigServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewConfigServiceHandler(svc ConfigServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + configServiceMethods := config1.File_aiscan_rpc_config_proto.Services().ByName("ConfigService").Methods() + configServiceGetConfigHandler := connect.NewUnaryHandler( + ConfigServiceGetConfigProcedure, + svc.GetConfig, + connect.WithSchema(configServiceMethods.ByName("GetConfig")), + connect.WithHandlerOptions(opts...), + ) + configServiceUpdateConfigHandler := connect.NewUnaryHandler( + ConfigServiceUpdateConfigProcedure, + svc.UpdateConfig, + connect.WithSchema(configServiceMethods.ByName("UpdateConfig")), + connect.WithHandlerOptions(opts...), + ) + configServiceActivateProfileHandler := connect.NewUnaryHandler( + ConfigServiceActivateProfileProcedure, + svc.ActivateProfile, + connect.WithSchema(configServiceMethods.ByName("ActivateProfile")), + connect.WithHandlerOptions(opts...), + ) + configServiceTestLLMHandler := connect.NewUnaryHandler( + ConfigServiceTestLLMProcedure, + svc.TestLLM, + connect.WithSchema(configServiceMethods.ByName("TestLLM")), + connect.WithHandlerOptions(opts...), + ) + configServiceListModelsHandler := connect.NewUnaryHandler( + ConfigServiceListModelsProcedure, + svc.ListModels, + connect.WithSchema(configServiceMethods.ByName("ListModels")), + connect.WithHandlerOptions(opts...), + ) + configServiceTestConnectionHandler := connect.NewUnaryHandler( + ConfigServiceTestConnectionProcedure, + svc.TestConnection, + connect.WithSchema(configServiceMethods.ByName("TestConnection")), + connect.WithHandlerOptions(opts...), + ) + return "/aiscan.rpc.config.ConfigService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ConfigServiceGetConfigProcedure: + configServiceGetConfigHandler.ServeHTTP(w, r) + case ConfigServiceUpdateConfigProcedure: + configServiceUpdateConfigHandler.ServeHTTP(w, r) + case ConfigServiceActivateProfileProcedure: + configServiceActivateProfileHandler.ServeHTTP(w, r) + case ConfigServiceTestLLMProcedure: + configServiceTestLLMHandler.ServeHTTP(w, r) + case ConfigServiceListModelsProcedure: + configServiceListModelsHandler.ServeHTTP(w, r) + case ConfigServiceTestConnectionProcedure: + configServiceTestConnectionHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedConfigServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedConfigServiceHandler struct{} + +func (UnimplementedConfigServiceHandler) GetConfig(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[config.GetConfigResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.config.ConfigService.GetConfig is not implemented")) +} + +func (UnimplementedConfigServiceHandler) UpdateConfig(context.Context, *connect.Request[config.UpdateConfigRequest]) (*connect.Response[config.UpdateConfigResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.config.ConfigService.UpdateConfig is not implemented")) +} + +func (UnimplementedConfigServiceHandler) ActivateProfile(context.Context, *connect.Request[config.ActivateProfileRequest]) (*connect.Response[config.ActivateProfileResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.config.ConfigService.ActivateProfile is not implemented")) +} + +func (UnimplementedConfigServiceHandler) TestLLM(context.Context, *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.LLMProbeResult], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.config.ConfigService.TestLLM is not implemented")) +} + +func (UnimplementedConfigServiceHandler) ListModels(context.Context, *connect.Request[config.LLMProbeRequest]) (*connect.Response[config.ListModelsResult], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.config.ConfigService.ListModels is not implemented")) +} + +func (UnimplementedConfigServiceHandler) TestConnection(context.Context, *connect.Request[config.TestConnectionRequest]) (*connect.Response[config.TestConnectionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.config.ConfigService.TestConnection is not implemented")) +} diff --git a/pkg/rpc/scan/scan.pb.go b/pkg/rpc/scan/scan.pb.go new file mode 100644 index 00000000..320932e8 --- /dev/null +++ b/pkg/rpc/scan/scan.pb.go @@ -0,0 +1,114 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/rpc/scan.proto + +package scan + +import ( + scan "github.com/chainreactors/aiscan/pkg/types/scan" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_aiscan_rpc_scan_proto protoreflect.FileDescriptor + +var file_aiscan_rpc_scan_proto_rawDesc = []byte{ + 0x0a, 0x15, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x72, 0x70, 0x63, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x1a, 0x17, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x32, 0x95, 0x03, 0x0a, 0x0b, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0x4d, 0x0a, 0x0a, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x12, + 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x75, + 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x75, + 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x44, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x1b, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, + 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, + 0x61, 0x6e, 0x73, 0x12, 0x1d, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0a, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, + 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x43, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x43, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x56, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, + 0x72, 0x74, 0x12, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, + 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, + 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x3b, 0x73, 0x63, 0x61, 0x6e, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var file_aiscan_rpc_scan_proto_goTypes = []interface{}{ + (*scan.SubmitScanRequest)(nil), // 0: aiscan.scan.SubmitScanRequest + (*scan.GetScanRequest)(nil), // 1: aiscan.scan.GetScanRequest + (*scan.ListScansRequest)(nil), // 2: aiscan.scan.ListScansRequest + (*scan.CancelScanRequest)(nil), // 3: aiscan.scan.CancelScanRequest + (*scan.GetScanReportRequest)(nil), // 4: aiscan.scan.GetScanReportRequest + (*scan.SubmitScanResponse)(nil), // 5: aiscan.scan.SubmitScanResponse + (*scan.GetScanResponse)(nil), // 6: aiscan.scan.GetScanResponse + (*scan.ListScansResponse)(nil), // 7: aiscan.scan.ListScansResponse + (*scan.CancelScanResponse)(nil), // 8: aiscan.scan.CancelScanResponse + (*scan.GetScanReportResponse)(nil), // 9: aiscan.scan.GetScanReportResponse +} +var file_aiscan_rpc_scan_proto_depIdxs = []int32{ + 0, // 0: aiscan.rpc.scan.ScanService.SubmitScan:input_type -> aiscan.scan.SubmitScanRequest + 1, // 1: aiscan.rpc.scan.ScanService.GetScan:input_type -> aiscan.scan.GetScanRequest + 2, // 2: aiscan.rpc.scan.ScanService.ListScans:input_type -> aiscan.scan.ListScansRequest + 3, // 3: aiscan.rpc.scan.ScanService.CancelScan:input_type -> aiscan.scan.CancelScanRequest + 4, // 4: aiscan.rpc.scan.ScanService.GetScanReport:input_type -> aiscan.scan.GetScanReportRequest + 5, // 5: aiscan.rpc.scan.ScanService.SubmitScan:output_type -> aiscan.scan.SubmitScanResponse + 6, // 6: aiscan.rpc.scan.ScanService.GetScan:output_type -> aiscan.scan.GetScanResponse + 7, // 7: aiscan.rpc.scan.ScanService.ListScans:output_type -> aiscan.scan.ListScansResponse + 8, // 8: aiscan.rpc.scan.ScanService.CancelScan:output_type -> aiscan.scan.CancelScanResponse + 9, // 9: aiscan.rpc.scan.ScanService.GetScanReport:output_type -> aiscan.scan.GetScanReportResponse + 5, // [5:10] is the sub-list for method output_type + 0, // [0:5] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_aiscan_rpc_scan_proto_init() } +func file_aiscan_rpc_scan_proto_init() { + if File_aiscan_rpc_scan_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_rpc_scan_proto_rawDesc, + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_rpc_scan_proto_goTypes, + DependencyIndexes: file_aiscan_rpc_scan_proto_depIdxs, + }.Build() + File_aiscan_rpc_scan_proto = out.File + file_aiscan_rpc_scan_proto_rawDesc = nil + file_aiscan_rpc_scan_proto_goTypes = nil + file_aiscan_rpc_scan_proto_depIdxs = nil +} diff --git a/aop/aiscan/scan/scanconnect/scan.connect.go b/pkg/rpc/scan/scanconnect/scan.connect.go similarity index 69% rename from aop/aiscan/scan/scanconnect/scan.connect.go rename to pkg/rpc/scan/scanconnect/scan.connect.go index b221e4bf..b5b2fe9e 100644 --- a/aop/aiscan/scan/scanconnect/scan.connect.go +++ b/pkg/rpc/scan/scanconnect/scan.connect.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // -// Source: aiscan/scan/scan.proto +// Source: aiscan/rpc/scan.proto package scanconnect @@ -8,7 +8,8 @@ import ( connect "connectrpc.com/connect" context "context" errors "errors" - scan "github.com/chainreactors/aiscan/aop/aiscan/scan" + scan1 "github.com/chainreactors/aiscan/pkg/rpc/scan" + scan "github.com/chainreactors/aiscan/pkg/types/scan" http "net/http" strings "strings" ) @@ -22,7 +23,7 @@ const _ = connect.IsAtLeastVersion1_13_0 const ( // ScanServiceName is the fully-qualified name of the ScanService service. - ScanServiceName = "aiscan.scan.ScanService" + ScanServiceName = "aiscan.rpc.scan.ScanService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're @@ -34,41 +35,37 @@ const ( // period. const ( // ScanServiceSubmitScanProcedure is the fully-qualified name of the ScanService's SubmitScan RPC. - ScanServiceSubmitScanProcedure = "/aiscan.scan.ScanService/SubmitScan" + ScanServiceSubmitScanProcedure = "/aiscan.rpc.scan.ScanService/SubmitScan" // ScanServiceGetScanProcedure is the fully-qualified name of the ScanService's GetScan RPC. - ScanServiceGetScanProcedure = "/aiscan.scan.ScanService/GetScan" + ScanServiceGetScanProcedure = "/aiscan.rpc.scan.ScanService/GetScan" // ScanServiceListScansProcedure is the fully-qualified name of the ScanService's ListScans RPC. - ScanServiceListScansProcedure = "/aiscan.scan.ScanService/ListScans" + ScanServiceListScansProcedure = "/aiscan.rpc.scan.ScanService/ListScans" // ScanServiceCancelScanProcedure is the fully-qualified name of the ScanService's CancelScan RPC. - ScanServiceCancelScanProcedure = "/aiscan.scan.ScanService/CancelScan" - // ScanServiceWatchScanEventsProcedure is the fully-qualified name of the ScanService's - // WatchScanEvents RPC. - ScanServiceWatchScanEventsProcedure = "/aiscan.scan.ScanService/WatchScanEvents" + ScanServiceCancelScanProcedure = "/aiscan.rpc.scan.ScanService/CancelScan" // ScanServiceGetScanReportProcedure is the fully-qualified name of the ScanService's GetScanReport // RPC. - ScanServiceGetScanReportProcedure = "/aiscan.scan.ScanService/GetScanReport" + ScanServiceGetScanReportProcedure = "/aiscan.rpc.scan.ScanService/GetScanReport" ) -// ScanServiceClient is a client for the aiscan.scan.ScanService service. +// ScanServiceClient is a client for the aiscan.rpc.scan.ScanService service. type ScanServiceClient interface { SubmitScan(context.Context, *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) GetScan(context.Context, *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) ListScans(context.Context, *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) CancelScan(context.Context, *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) - WatchScanEvents(context.Context, *connect.Request[scan.WatchScanEventsRequest]) (*connect.ServerStreamForClient[scan.WatchScanEventsResponse], error) GetScanReport(context.Context, *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) } -// NewScanServiceClient constructs a client for the aiscan.scan.ScanService service. By default, it -// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends -// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or -// connect.WithGRPCWeb() options. +// NewScanServiceClient constructs a client for the aiscan.rpc.scan.ScanService service. By default, +// it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and +// sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() +// or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewScanServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ScanServiceClient { baseURL = strings.TrimRight(baseURL, "/") - scanServiceMethods := scan.File_aiscan_scan_scan_proto.Services().ByName("ScanService").Methods() + scanServiceMethods := scan1.File_aiscan_rpc_scan_proto.Services().ByName("ScanService").Methods() return &scanServiceClient{ submitScan: connect.NewClient[scan.SubmitScanRequest, scan.SubmitScanResponse]( httpClient, @@ -94,12 +91,6 @@ func NewScanServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. connect.WithSchema(scanServiceMethods.ByName("CancelScan")), connect.WithClientOptions(opts...), ), - watchScanEvents: connect.NewClient[scan.WatchScanEventsRequest, scan.WatchScanEventsResponse]( - httpClient, - baseURL+ScanServiceWatchScanEventsProcedure, - connect.WithSchema(scanServiceMethods.ByName("WatchScanEvents")), - connect.WithClientOptions(opts...), - ), getScanReport: connect.NewClient[scan.GetScanReportRequest, scan.GetScanReportResponse]( httpClient, baseURL+ScanServiceGetScanReportProcedure, @@ -111,51 +102,44 @@ func NewScanServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. // scanServiceClient implements ScanServiceClient. type scanServiceClient struct { - submitScan *connect.Client[scan.SubmitScanRequest, scan.SubmitScanResponse] - getScan *connect.Client[scan.GetScanRequest, scan.GetScanResponse] - listScans *connect.Client[scan.ListScansRequest, scan.ListScansResponse] - cancelScan *connect.Client[scan.CancelScanRequest, scan.CancelScanResponse] - watchScanEvents *connect.Client[scan.WatchScanEventsRequest, scan.WatchScanEventsResponse] - getScanReport *connect.Client[scan.GetScanReportRequest, scan.GetScanReportResponse] + submitScan *connect.Client[scan.SubmitScanRequest, scan.SubmitScanResponse] + getScan *connect.Client[scan.GetScanRequest, scan.GetScanResponse] + listScans *connect.Client[scan.ListScansRequest, scan.ListScansResponse] + cancelScan *connect.Client[scan.CancelScanRequest, scan.CancelScanResponse] + getScanReport *connect.Client[scan.GetScanReportRequest, scan.GetScanReportResponse] } -// SubmitScan calls aiscan.scan.ScanService.SubmitScan. +// SubmitScan calls aiscan.rpc.scan.ScanService.SubmitScan. func (c *scanServiceClient) SubmitScan(ctx context.Context, req *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) { return c.submitScan.CallUnary(ctx, req) } -// GetScan calls aiscan.scan.ScanService.GetScan. +// GetScan calls aiscan.rpc.scan.ScanService.GetScan. func (c *scanServiceClient) GetScan(ctx context.Context, req *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) { return c.getScan.CallUnary(ctx, req) } -// ListScans calls aiscan.scan.ScanService.ListScans. +// ListScans calls aiscan.rpc.scan.ScanService.ListScans. func (c *scanServiceClient) ListScans(ctx context.Context, req *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) { return c.listScans.CallUnary(ctx, req) } -// CancelScan calls aiscan.scan.ScanService.CancelScan. +// CancelScan calls aiscan.rpc.scan.ScanService.CancelScan. func (c *scanServiceClient) CancelScan(ctx context.Context, req *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) { return c.cancelScan.CallUnary(ctx, req) } -// WatchScanEvents calls aiscan.scan.ScanService.WatchScanEvents. -func (c *scanServiceClient) WatchScanEvents(ctx context.Context, req *connect.Request[scan.WatchScanEventsRequest]) (*connect.ServerStreamForClient[scan.WatchScanEventsResponse], error) { - return c.watchScanEvents.CallServerStream(ctx, req) -} - -// GetScanReport calls aiscan.scan.ScanService.GetScanReport. +// GetScanReport calls aiscan.rpc.scan.ScanService.GetScanReport. func (c *scanServiceClient) GetScanReport(ctx context.Context, req *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) { return c.getScanReport.CallUnary(ctx, req) } -// ScanServiceHandler is an implementation of the aiscan.scan.ScanService service. +// ScanServiceHandler is an implementation of the aiscan.rpc.scan.ScanService service. type ScanServiceHandler interface { SubmitScan(context.Context, *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) GetScan(context.Context, *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) ListScans(context.Context, *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) CancelScan(context.Context, *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) - WatchScanEvents(context.Context, *connect.Request[scan.WatchScanEventsRequest], *connect.ServerStream[scan.WatchScanEventsResponse]) error GetScanReport(context.Context, *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) } @@ -165,7 +149,7 @@ type ScanServiceHandler interface { // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewScanServiceHandler(svc ScanServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - scanServiceMethods := scan.File_aiscan_scan_scan_proto.Services().ByName("ScanService").Methods() + scanServiceMethods := scan1.File_aiscan_rpc_scan_proto.Services().ByName("ScanService").Methods() scanServiceSubmitScanHandler := connect.NewUnaryHandler( ScanServiceSubmitScanProcedure, svc.SubmitScan, @@ -190,19 +174,13 @@ func NewScanServiceHandler(svc ScanServiceHandler, opts ...connect.HandlerOption connect.WithSchema(scanServiceMethods.ByName("CancelScan")), connect.WithHandlerOptions(opts...), ) - scanServiceWatchScanEventsHandler := connect.NewServerStreamHandler( - ScanServiceWatchScanEventsProcedure, - svc.WatchScanEvents, - connect.WithSchema(scanServiceMethods.ByName("WatchScanEvents")), - connect.WithHandlerOptions(opts...), - ) scanServiceGetScanReportHandler := connect.NewUnaryHandler( ScanServiceGetScanReportProcedure, svc.GetScanReport, connect.WithSchema(scanServiceMethods.ByName("GetScanReport")), connect.WithHandlerOptions(opts...), ) - return "/aiscan.scan.ScanService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return "/aiscan.rpc.scan.ScanService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ScanServiceSubmitScanProcedure: scanServiceSubmitScanHandler.ServeHTTP(w, r) @@ -212,8 +190,6 @@ func NewScanServiceHandler(svc ScanServiceHandler, opts ...connect.HandlerOption scanServiceListScansHandler.ServeHTTP(w, r) case ScanServiceCancelScanProcedure: scanServiceCancelScanHandler.ServeHTTP(w, r) - case ScanServiceWatchScanEventsProcedure: - scanServiceWatchScanEventsHandler.ServeHTTP(w, r) case ScanServiceGetScanReportProcedure: scanServiceGetScanReportHandler.ServeHTTP(w, r) default: @@ -226,25 +202,21 @@ func NewScanServiceHandler(svc ScanServiceHandler, opts ...connect.HandlerOption type UnimplementedScanServiceHandler struct{} func (UnimplementedScanServiceHandler) SubmitScan(context.Context, *connect.Request[scan.SubmitScanRequest]) (*connect.Response[scan.SubmitScanResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.SubmitScan is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.scan.ScanService.SubmitScan is not implemented")) } func (UnimplementedScanServiceHandler) GetScan(context.Context, *connect.Request[scan.GetScanRequest]) (*connect.Response[scan.GetScanResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.GetScan is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.scan.ScanService.GetScan is not implemented")) } func (UnimplementedScanServiceHandler) ListScans(context.Context, *connect.Request[scan.ListScansRequest]) (*connect.Response[scan.ListScansResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.ListScans is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.scan.ScanService.ListScans is not implemented")) } func (UnimplementedScanServiceHandler) CancelScan(context.Context, *connect.Request[scan.CancelScanRequest]) (*connect.Response[scan.CancelScanResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.CancelScan is not implemented")) -} - -func (UnimplementedScanServiceHandler) WatchScanEvents(context.Context, *connect.Request[scan.WatchScanEventsRequest], *connect.ServerStream[scan.WatchScanEventsResponse]) error { - return connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.WatchScanEvents is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.scan.ScanService.CancelScan is not implemented")) } func (UnimplementedScanServiceHandler) GetScanReport(context.Context, *connect.Request[scan.GetScanReportRequest]) (*connect.Response[scan.GetScanReportResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.scan.ScanService.GetScanReport is not implemented")) + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.scan.ScanService.GetScanReport is not implemented")) } diff --git a/pkg/rpc/sco/sco.pb.go b/pkg/rpc/sco/sco.pb.go new file mode 100644 index 00000000..54af1dd1 --- /dev/null +++ b/pkg/rpc/sco/sco.pb.go @@ -0,0 +1,122 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/rpc/sco.proto + +package sco + +import ( + sco "github.com/chainreactors/aiscan/pkg/types/sco" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_aiscan_rpc_sco_proto protoreflect.FileDescriptor + +var file_aiscan_rpc_sco_proto_rawDesc = []byte{ + 0x0a, 0x14, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x63, 0x6f, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x72, + 0x70, 0x63, 0x2e, 0x73, 0x63, 0x6f, 0x1a, 0x16, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, + 0x79, 0x70, 0x65, 0x73, 0x2f, 0x73, 0x63, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x32, 0xd7, + 0x03, 0x0a, 0x0a, 0x53, 0x43, 0x4f, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x48, 0x0a, + 0x09, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x1c, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x6f, 0x64, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x4e, 0x6f, + 0x64, 0x65, 0x12, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, + 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4e, + 0x6f, 0x64, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x08, 0x47, + 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1b, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, + 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0b, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, + 0x73, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0b, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4e, 0x6f, 0x64, 0x65, + 0x73, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x49, + 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x49, + 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, + 0x63, 0x74, 0x73, 0x12, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x6f, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, + 0x63, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x31, 0x5a, 0x2f, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x72, 0x70, 0x63, 0x2f, 0x73, 0x63, 0x6f, 0x3b, 0x73, 0x63, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} + +var file_aiscan_rpc_sco_proto_goTypes = []interface{}{ + (*sco.ListNodesRequest)(nil), // 0: aiscan.sco.ListNodesRequest + (*sco.GetNodeRequest)(nil), // 1: aiscan.sco.GetNodeRequest + (*sco.GetStatsRequest)(nil), // 2: aiscan.sco.GetStatsRequest + (*sco.DeleteNodesRequest)(nil), // 3: aiscan.sco.DeleteNodesRequest + (*sco.ImportNodesRequest)(nil), // 4: aiscan.sco.ImportNodesRequest + (*sco.ListArtifactsRequest)(nil), // 5: aiscan.sco.ListArtifactsRequest + (*sco.ListNodesResponse)(nil), // 6: aiscan.sco.ListNodesResponse + (*sco.GetNodeResponse)(nil), // 7: aiscan.sco.GetNodeResponse + (*sco.GetStatsResponse)(nil), // 8: aiscan.sco.GetStatsResponse + (*sco.DeleteNodesResponse)(nil), // 9: aiscan.sco.DeleteNodesResponse + (*sco.ImportNodesResponse)(nil), // 10: aiscan.sco.ImportNodesResponse + (*sco.ListArtifactsResponse)(nil), // 11: aiscan.sco.ListArtifactsResponse +} +var file_aiscan_rpc_sco_proto_depIdxs = []int32{ + 0, // 0: aiscan.rpc.sco.SCOService.ListNodes:input_type -> aiscan.sco.ListNodesRequest + 1, // 1: aiscan.rpc.sco.SCOService.GetNode:input_type -> aiscan.sco.GetNodeRequest + 2, // 2: aiscan.rpc.sco.SCOService.GetStats:input_type -> aiscan.sco.GetStatsRequest + 3, // 3: aiscan.rpc.sco.SCOService.DeleteNodes:input_type -> aiscan.sco.DeleteNodesRequest + 4, // 4: aiscan.rpc.sco.SCOService.ImportNodes:input_type -> aiscan.sco.ImportNodesRequest + 5, // 5: aiscan.rpc.sco.SCOService.ListArtifacts:input_type -> aiscan.sco.ListArtifactsRequest + 6, // 6: aiscan.rpc.sco.SCOService.ListNodes:output_type -> aiscan.sco.ListNodesResponse + 7, // 7: aiscan.rpc.sco.SCOService.GetNode:output_type -> aiscan.sco.GetNodeResponse + 8, // 8: aiscan.rpc.sco.SCOService.GetStats:output_type -> aiscan.sco.GetStatsResponse + 9, // 9: aiscan.rpc.sco.SCOService.DeleteNodes:output_type -> aiscan.sco.DeleteNodesResponse + 10, // 10: aiscan.rpc.sco.SCOService.ImportNodes:output_type -> aiscan.sco.ImportNodesResponse + 11, // 11: aiscan.rpc.sco.SCOService.ListArtifacts:output_type -> aiscan.sco.ListArtifactsResponse + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_aiscan_rpc_sco_proto_init() } +func file_aiscan_rpc_sco_proto_init() { + if File_aiscan_rpc_sco_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_rpc_sco_proto_rawDesc, + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_rpc_sco_proto_goTypes, + DependencyIndexes: file_aiscan_rpc_sco_proto_depIdxs, + }.Build() + File_aiscan_rpc_sco_proto = out.File + file_aiscan_rpc_sco_proto_rawDesc = nil + file_aiscan_rpc_sco_proto_goTypes = nil + file_aiscan_rpc_sco_proto_depIdxs = nil +} diff --git a/pkg/rpc/sco/scoconnect/sco.connect.go b/pkg/rpc/sco/scoconnect/sco.connect.go new file mode 100644 index 00000000..cdb32bae --- /dev/null +++ b/pkg/rpc/sco/scoconnect/sco.connect.go @@ -0,0 +1,250 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: aiscan/rpc/sco.proto + +package scoconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + sco1 "github.com/chainreactors/aiscan/pkg/rpc/sco" + sco "github.com/chainreactors/aiscan/pkg/types/sco" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // SCOServiceName is the fully-qualified name of the SCOService service. + SCOServiceName = "aiscan.rpc.sco.SCOService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // SCOServiceListNodesProcedure is the fully-qualified name of the SCOService's ListNodes RPC. + SCOServiceListNodesProcedure = "/aiscan.rpc.sco.SCOService/ListNodes" + // SCOServiceGetNodeProcedure is the fully-qualified name of the SCOService's GetNode RPC. + SCOServiceGetNodeProcedure = "/aiscan.rpc.sco.SCOService/GetNode" + // SCOServiceGetStatsProcedure is the fully-qualified name of the SCOService's GetStats RPC. + SCOServiceGetStatsProcedure = "/aiscan.rpc.sco.SCOService/GetStats" + // SCOServiceDeleteNodesProcedure is the fully-qualified name of the SCOService's DeleteNodes RPC. + SCOServiceDeleteNodesProcedure = "/aiscan.rpc.sco.SCOService/DeleteNodes" + // SCOServiceImportNodesProcedure is the fully-qualified name of the SCOService's ImportNodes RPC. + SCOServiceImportNodesProcedure = "/aiscan.rpc.sco.SCOService/ImportNodes" + // SCOServiceListArtifactsProcedure is the fully-qualified name of the SCOService's ListArtifacts + // RPC. + SCOServiceListArtifactsProcedure = "/aiscan.rpc.sco.SCOService/ListArtifacts" +) + +// SCOServiceClient is a client for the aiscan.rpc.sco.SCOService service. +type SCOServiceClient interface { + ListNodes(context.Context, *connect.Request[sco.ListNodesRequest]) (*connect.Response[sco.ListNodesResponse], error) + GetNode(context.Context, *connect.Request[sco.GetNodeRequest]) (*connect.Response[sco.GetNodeResponse], error) + GetStats(context.Context, *connect.Request[sco.GetStatsRequest]) (*connect.Response[sco.GetStatsResponse], error) + DeleteNodes(context.Context, *connect.Request[sco.DeleteNodesRequest]) (*connect.Response[sco.DeleteNodesResponse], error) + ImportNodes(context.Context, *connect.Request[sco.ImportNodesRequest]) (*connect.Response[sco.ImportNodesResponse], error) + ListArtifacts(context.Context, *connect.Request[sco.ListArtifactsRequest]) (*connect.Response[sco.ListArtifactsResponse], error) +} + +// NewSCOServiceClient constructs a client for the aiscan.rpc.sco.SCOService service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewSCOServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SCOServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + sCOServiceMethods := sco1.File_aiscan_rpc_sco_proto.Services().ByName("SCOService").Methods() + return &sCOServiceClient{ + listNodes: connect.NewClient[sco.ListNodesRequest, sco.ListNodesResponse]( + httpClient, + baseURL+SCOServiceListNodesProcedure, + connect.WithSchema(sCOServiceMethods.ByName("ListNodes")), + connect.WithClientOptions(opts...), + ), + getNode: connect.NewClient[sco.GetNodeRequest, sco.GetNodeResponse]( + httpClient, + baseURL+SCOServiceGetNodeProcedure, + connect.WithSchema(sCOServiceMethods.ByName("GetNode")), + connect.WithClientOptions(opts...), + ), + getStats: connect.NewClient[sco.GetStatsRequest, sco.GetStatsResponse]( + httpClient, + baseURL+SCOServiceGetStatsProcedure, + connect.WithSchema(sCOServiceMethods.ByName("GetStats")), + connect.WithClientOptions(opts...), + ), + deleteNodes: connect.NewClient[sco.DeleteNodesRequest, sco.DeleteNodesResponse]( + httpClient, + baseURL+SCOServiceDeleteNodesProcedure, + connect.WithSchema(sCOServiceMethods.ByName("DeleteNodes")), + connect.WithClientOptions(opts...), + ), + importNodes: connect.NewClient[sco.ImportNodesRequest, sco.ImportNodesResponse]( + httpClient, + baseURL+SCOServiceImportNodesProcedure, + connect.WithSchema(sCOServiceMethods.ByName("ImportNodes")), + connect.WithClientOptions(opts...), + ), + listArtifacts: connect.NewClient[sco.ListArtifactsRequest, sco.ListArtifactsResponse]( + httpClient, + baseURL+SCOServiceListArtifactsProcedure, + connect.WithSchema(sCOServiceMethods.ByName("ListArtifacts")), + connect.WithClientOptions(opts...), + ), + } +} + +// sCOServiceClient implements SCOServiceClient. +type sCOServiceClient struct { + listNodes *connect.Client[sco.ListNodesRequest, sco.ListNodesResponse] + getNode *connect.Client[sco.GetNodeRequest, sco.GetNodeResponse] + getStats *connect.Client[sco.GetStatsRequest, sco.GetStatsResponse] + deleteNodes *connect.Client[sco.DeleteNodesRequest, sco.DeleteNodesResponse] + importNodes *connect.Client[sco.ImportNodesRequest, sco.ImportNodesResponse] + listArtifacts *connect.Client[sco.ListArtifactsRequest, sco.ListArtifactsResponse] +} + +// ListNodes calls aiscan.rpc.sco.SCOService.ListNodes. +func (c *sCOServiceClient) ListNodes(ctx context.Context, req *connect.Request[sco.ListNodesRequest]) (*connect.Response[sco.ListNodesResponse], error) { + return c.listNodes.CallUnary(ctx, req) +} + +// GetNode calls aiscan.rpc.sco.SCOService.GetNode. +func (c *sCOServiceClient) GetNode(ctx context.Context, req *connect.Request[sco.GetNodeRequest]) (*connect.Response[sco.GetNodeResponse], error) { + return c.getNode.CallUnary(ctx, req) +} + +// GetStats calls aiscan.rpc.sco.SCOService.GetStats. +func (c *sCOServiceClient) GetStats(ctx context.Context, req *connect.Request[sco.GetStatsRequest]) (*connect.Response[sco.GetStatsResponse], error) { + return c.getStats.CallUnary(ctx, req) +} + +// DeleteNodes calls aiscan.rpc.sco.SCOService.DeleteNodes. +func (c *sCOServiceClient) DeleteNodes(ctx context.Context, req *connect.Request[sco.DeleteNodesRequest]) (*connect.Response[sco.DeleteNodesResponse], error) { + return c.deleteNodes.CallUnary(ctx, req) +} + +// ImportNodes calls aiscan.rpc.sco.SCOService.ImportNodes. +func (c *sCOServiceClient) ImportNodes(ctx context.Context, req *connect.Request[sco.ImportNodesRequest]) (*connect.Response[sco.ImportNodesResponse], error) { + return c.importNodes.CallUnary(ctx, req) +} + +// ListArtifacts calls aiscan.rpc.sco.SCOService.ListArtifacts. +func (c *sCOServiceClient) ListArtifacts(ctx context.Context, req *connect.Request[sco.ListArtifactsRequest]) (*connect.Response[sco.ListArtifactsResponse], error) { + return c.listArtifacts.CallUnary(ctx, req) +} + +// SCOServiceHandler is an implementation of the aiscan.rpc.sco.SCOService service. +type SCOServiceHandler interface { + ListNodes(context.Context, *connect.Request[sco.ListNodesRequest]) (*connect.Response[sco.ListNodesResponse], error) + GetNode(context.Context, *connect.Request[sco.GetNodeRequest]) (*connect.Response[sco.GetNodeResponse], error) + GetStats(context.Context, *connect.Request[sco.GetStatsRequest]) (*connect.Response[sco.GetStatsResponse], error) + DeleteNodes(context.Context, *connect.Request[sco.DeleteNodesRequest]) (*connect.Response[sco.DeleteNodesResponse], error) + ImportNodes(context.Context, *connect.Request[sco.ImportNodesRequest]) (*connect.Response[sco.ImportNodesResponse], error) + ListArtifacts(context.Context, *connect.Request[sco.ListArtifactsRequest]) (*connect.Response[sco.ListArtifactsResponse], error) +} + +// NewSCOServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewSCOServiceHandler(svc SCOServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + sCOServiceMethods := sco1.File_aiscan_rpc_sco_proto.Services().ByName("SCOService").Methods() + sCOServiceListNodesHandler := connect.NewUnaryHandler( + SCOServiceListNodesProcedure, + svc.ListNodes, + connect.WithSchema(sCOServiceMethods.ByName("ListNodes")), + connect.WithHandlerOptions(opts...), + ) + sCOServiceGetNodeHandler := connect.NewUnaryHandler( + SCOServiceGetNodeProcedure, + svc.GetNode, + connect.WithSchema(sCOServiceMethods.ByName("GetNode")), + connect.WithHandlerOptions(opts...), + ) + sCOServiceGetStatsHandler := connect.NewUnaryHandler( + SCOServiceGetStatsProcedure, + svc.GetStats, + connect.WithSchema(sCOServiceMethods.ByName("GetStats")), + connect.WithHandlerOptions(opts...), + ) + sCOServiceDeleteNodesHandler := connect.NewUnaryHandler( + SCOServiceDeleteNodesProcedure, + svc.DeleteNodes, + connect.WithSchema(sCOServiceMethods.ByName("DeleteNodes")), + connect.WithHandlerOptions(opts...), + ) + sCOServiceImportNodesHandler := connect.NewUnaryHandler( + SCOServiceImportNodesProcedure, + svc.ImportNodes, + connect.WithSchema(sCOServiceMethods.ByName("ImportNodes")), + connect.WithHandlerOptions(opts...), + ) + sCOServiceListArtifactsHandler := connect.NewUnaryHandler( + SCOServiceListArtifactsProcedure, + svc.ListArtifacts, + connect.WithSchema(sCOServiceMethods.ByName("ListArtifacts")), + connect.WithHandlerOptions(opts...), + ) + return "/aiscan.rpc.sco.SCOService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case SCOServiceListNodesProcedure: + sCOServiceListNodesHandler.ServeHTTP(w, r) + case SCOServiceGetNodeProcedure: + sCOServiceGetNodeHandler.ServeHTTP(w, r) + case SCOServiceGetStatsProcedure: + sCOServiceGetStatsHandler.ServeHTTP(w, r) + case SCOServiceDeleteNodesProcedure: + sCOServiceDeleteNodesHandler.ServeHTTP(w, r) + case SCOServiceImportNodesProcedure: + sCOServiceImportNodesHandler.ServeHTTP(w, r) + case SCOServiceListArtifactsProcedure: + sCOServiceListArtifactsHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedSCOServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedSCOServiceHandler struct{} + +func (UnimplementedSCOServiceHandler) ListNodes(context.Context, *connect.Request[sco.ListNodesRequest]) (*connect.Response[sco.ListNodesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.sco.SCOService.ListNodes is not implemented")) +} + +func (UnimplementedSCOServiceHandler) GetNode(context.Context, *connect.Request[sco.GetNodeRequest]) (*connect.Response[sco.GetNodeResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.sco.SCOService.GetNode is not implemented")) +} + +func (UnimplementedSCOServiceHandler) GetStats(context.Context, *connect.Request[sco.GetStatsRequest]) (*connect.Response[sco.GetStatsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.sco.SCOService.GetStats is not implemented")) +} + +func (UnimplementedSCOServiceHandler) DeleteNodes(context.Context, *connect.Request[sco.DeleteNodesRequest]) (*connect.Response[sco.DeleteNodesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.sco.SCOService.DeleteNodes is not implemented")) +} + +func (UnimplementedSCOServiceHandler) ImportNodes(context.Context, *connect.Request[sco.ImportNodesRequest]) (*connect.Response[sco.ImportNodesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.sco.SCOService.ImportNodes is not implemented")) +} + +func (UnimplementedSCOServiceHandler) ListArtifacts(context.Context, *connect.Request[sco.ListArtifactsRequest]) (*connect.Response[sco.ListArtifactsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.sco.SCOService.ListArtifacts is not implemented")) +} diff --git a/pkg/rpc/system/system.pb.go b/pkg/rpc/system/system.pb.go new file mode 100644 index 00000000..774a93ba --- /dev/null +++ b/pkg/rpc/system/system.pb.go @@ -0,0 +1,79 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/rpc/system.proto + +package system + +import ( + system "github.com/chainreactors/aiscan/pkg/types/system" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_aiscan_rpc_system_proto protoreflect.FileDescriptor + +var file_aiscan_rpc_system_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x79, 0x73, + 0x74, 0x65, 0x6d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x11, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x1a, 0x19, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x73, 0x79, 0x73, 0x74, 0x65, + 0x6d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x32, 0x5f, 0x0a, 0x0d, 0x53, 0x79, 0x73, 0x74, 0x65, + 0x6d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4e, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, + 0x79, 0x73, 0x74, 0x65, 0x6d, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x72, 0x70, 0x63, 0x2f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x3b, 0x73, 0x79, 0x73, 0x74, 0x65, + 0x6d, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var file_aiscan_rpc_system_proto_goTypes = []interface{}{ + (*system.GetStatusRequest)(nil), // 0: aiscan.system.GetStatusRequest + (*system.GetStatusResponse)(nil), // 1: aiscan.system.GetStatusResponse +} +var file_aiscan_rpc_system_proto_depIdxs = []int32{ + 0, // 0: aiscan.rpc.system.SystemService.GetStatus:input_type -> aiscan.system.GetStatusRequest + 1, // 1: aiscan.rpc.system.SystemService.GetStatus:output_type -> aiscan.system.GetStatusResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_aiscan_rpc_system_proto_init() } +func file_aiscan_rpc_system_proto_init() { + if File_aiscan_rpc_system_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_rpc_system_proto_rawDesc, + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_aiscan_rpc_system_proto_goTypes, + DependencyIndexes: file_aiscan_rpc_system_proto_depIdxs, + }.Build() + File_aiscan_rpc_system_proto = out.File + file_aiscan_rpc_system_proto_rawDesc = nil + file_aiscan_rpc_system_proto_goTypes = nil + file_aiscan_rpc_system_proto_depIdxs = nil +} diff --git a/pkg/rpc/system/systemconnect/system.connect.go b/pkg/rpc/system/systemconnect/system.connect.go new file mode 100644 index 00000000..91fa28a9 --- /dev/null +++ b/pkg/rpc/system/systemconnect/system.connect.go @@ -0,0 +1,109 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: aiscan/rpc/system.proto + +package systemconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + system1 "github.com/chainreactors/aiscan/pkg/rpc/system" + system "github.com/chainreactors/aiscan/pkg/types/system" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // SystemServiceName is the fully-qualified name of the SystemService service. + SystemServiceName = "aiscan.rpc.system.SystemService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // SystemServiceGetStatusProcedure is the fully-qualified name of the SystemService's GetStatus RPC. + SystemServiceGetStatusProcedure = "/aiscan.rpc.system.SystemService/GetStatus" +) + +// SystemServiceClient is a client for the aiscan.rpc.system.SystemService service. +type SystemServiceClient interface { + GetStatus(context.Context, *connect.Request[system.GetStatusRequest]) (*connect.Response[system.GetStatusResponse], error) +} + +// NewSystemServiceClient constructs a client for the aiscan.rpc.system.SystemService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewSystemServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SystemServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + systemServiceMethods := system1.File_aiscan_rpc_system_proto.Services().ByName("SystemService").Methods() + return &systemServiceClient{ + getStatus: connect.NewClient[system.GetStatusRequest, system.GetStatusResponse]( + httpClient, + baseURL+SystemServiceGetStatusProcedure, + connect.WithSchema(systemServiceMethods.ByName("GetStatus")), + connect.WithClientOptions(opts...), + ), + } +} + +// systemServiceClient implements SystemServiceClient. +type systemServiceClient struct { + getStatus *connect.Client[system.GetStatusRequest, system.GetStatusResponse] +} + +// GetStatus calls aiscan.rpc.system.SystemService.GetStatus. +func (c *systemServiceClient) GetStatus(ctx context.Context, req *connect.Request[system.GetStatusRequest]) (*connect.Response[system.GetStatusResponse], error) { + return c.getStatus.CallUnary(ctx, req) +} + +// SystemServiceHandler is an implementation of the aiscan.rpc.system.SystemService service. +type SystemServiceHandler interface { + GetStatus(context.Context, *connect.Request[system.GetStatusRequest]) (*connect.Response[system.GetStatusResponse], error) +} + +// NewSystemServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewSystemServiceHandler(svc SystemServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + systemServiceMethods := system1.File_aiscan_rpc_system_proto.Services().ByName("SystemService").Methods() + systemServiceGetStatusHandler := connect.NewUnaryHandler( + SystemServiceGetStatusProcedure, + svc.GetStatus, + connect.WithSchema(systemServiceMethods.ByName("GetStatus")), + connect.WithHandlerOptions(opts...), + ) + return "/aiscan.rpc.system.SystemService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case SystemServiceGetStatusProcedure: + systemServiceGetStatusHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedSystemServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedSystemServiceHandler struct{} + +func (UnimplementedSystemServiceHandler) GetStatus(context.Context, *connect.Request[system.GetStatusRequest]) (*connect.Response[system.GetStatusResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("aiscan.rpc.system.SystemService.GetStatus is not implemented")) +} diff --git a/pkg/types/agent/agent.pb.go b/pkg/types/agent/agent.pb.go new file mode 100644 index 00000000..b3a189a8 --- /dev/null +++ b/pkg/types/agent/agent.pb.go @@ -0,0 +1,1566 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/types/agent.proto + +package agent + +import ( + aop "github.com/chainreactors/aiscan/aop" + command "github.com/chainreactors/aiscan/pkg/types/command" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type View struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Hello *aop.AgentHello `protobuf:"bytes,1,opt,name=hello,proto3" json:"hello,omitempty"` + Status *aop.AgentStatus `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Stats *aop.AgentStats `protobuf:"bytes,3,opt,name=stats,proto3" json:"stats,omitempty"` + NodeUri string `protobuf:"bytes,4,opt,name=node_uri,json=nodeUri,proto3" json:"node_uri,omitempty"` + ConnectedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=connected_at,json=connectedAt,proto3" json:"connected_at,omitempty"` + Commands []*command.Spec `protobuf:"bytes,6,rep,name=commands,proto3" json:"commands,omitempty"` + Busy bool `protobuf:"varint,7,opt,name=busy,proto3" json:"busy,omitempty"` +} + +func (x *View) Reset() { + *x = View{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *View) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*View) ProtoMessage() {} + +func (x *View) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use View.ProtoReflect.Descriptor instead. +func (*View) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *View) GetHello() *aop.AgentHello { + if x != nil { + return x.Hello + } + return nil +} + +func (x *View) GetStatus() *aop.AgentStatus { + if x != nil { + return x.Status + } + return nil +} + +func (x *View) GetStats() *aop.AgentStats { + if x != nil { + return x.Stats + } + return nil +} + +func (x *View) GetNodeUri() string { + if x != nil { + return x.NodeUri + } + return "" +} + +func (x *View) GetConnectedAt() *timestamppb.Timestamp { + if x != nil { + return x.ConnectedAt + } + return nil +} + +func (x *View) GetCommands() []*command.Spec { + if x != nil { + return x.Commands + } + return nil +} + +func (x *View) GetBusy() bool { + if x != nil { + return x.Busy + } + return false +} + +type LocalAgent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Pid int32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` + Registered bool `protobuf:"varint,3,opt,name=registered,proto3" json:"registered,omitempty"` + Busy bool `protobuf:"varint,4,opt,name=busy,proto3" json:"busy,omitempty"` +} + +func (x *LocalAgent) Reset() { + *x = LocalAgent{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LocalAgent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalAgent) ProtoMessage() {} + +func (x *LocalAgent) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalAgent.ProtoReflect.Descriptor instead. +func (*LocalAgent) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *LocalAgent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *LocalAgent) GetPid() int32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *LocalAgent) GetRegistered() bool { + if x != nil { + return x.Registered + } + return false +} + +func (x *LocalAgent) GetBusy() bool { + if x != nil { + return x.Busy + } + return false +} + +type ListAgentsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ListAgentsRequest) Reset() { + *x = ListAgentsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListAgentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentsRequest) ProtoMessage() {} + +func (x *ListAgentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAgentsRequest.ProtoReflect.Descriptor instead. +func (*ListAgentsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{2} +} + +type ListAgentsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Agents []*View `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents,omitempty"` +} + +func (x *ListAgentsResponse) Reset() { + *x = ListAgentsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListAgentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentsResponse) ProtoMessage() {} + +func (x *ListAgentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAgentsResponse.ProtoReflect.Descriptor instead. +func (*ListAgentsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *ListAgentsResponse) GetAgents() []*View { + if x != nil { + return x.Agents + } + return nil +} + +type ListLocalAgentsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ListLocalAgentsRequest) Reset() { + *x = ListLocalAgentsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListLocalAgentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLocalAgentsRequest) ProtoMessage() {} + +func (x *ListLocalAgentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLocalAgentsRequest.ProtoReflect.Descriptor instead. +func (*ListLocalAgentsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{4} +} + +type ListLocalAgentsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Agents []*LocalAgent `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents,omitempty"` +} + +func (x *ListLocalAgentsResponse) Reset() { + *x = ListLocalAgentsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListLocalAgentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLocalAgentsResponse) ProtoMessage() {} + +func (x *ListLocalAgentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLocalAgentsResponse.ProtoReflect.Descriptor instead. +func (*ListLocalAgentsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *ListLocalAgentsResponse) GetAgents() []*LocalAgent { + if x != nil { + return x.Agents + } + return nil +} + +type LaunchLocalAgentRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *LaunchLocalAgentRequest) Reset() { + *x = LaunchLocalAgentRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LaunchLocalAgentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LaunchLocalAgentRequest) ProtoMessage() {} + +func (x *LaunchLocalAgentRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LaunchLocalAgentRequest.ProtoReflect.Descriptor instead. +func (*LaunchLocalAgentRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{6} +} + +type LaunchLocalAgentResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Agent *LocalAgent `protobuf:"bytes,1,opt,name=agent,proto3" json:"agent,omitempty"` +} + +func (x *LaunchLocalAgentResponse) Reset() { + *x = LaunchLocalAgentResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LaunchLocalAgentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LaunchLocalAgentResponse) ProtoMessage() {} + +func (x *LaunchLocalAgentResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LaunchLocalAgentResponse.ProtoReflect.Descriptor instead. +func (*LaunchLocalAgentResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *LaunchLocalAgentResponse) GetAgent() *LocalAgent { + if x != nil { + return x.Agent + } + return nil +} + +type StopLocalAgentRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *StopLocalAgentRequest) Reset() { + *x = StopLocalAgentRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *StopLocalAgentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopLocalAgentRequest) ProtoMessage() {} + +func (x *StopLocalAgentRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StopLocalAgentRequest.ProtoReflect.Descriptor instead. +func (*StopLocalAgentRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *StopLocalAgentRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type StopLocalAgentResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *StopLocalAgentResponse) Reset() { + *x = StopLocalAgentResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *StopLocalAgentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopLocalAgentResponse) ProtoMessage() {} + +func (x *StopLocalAgentResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StopLocalAgentResponse.ProtoReflect.Descriptor instead. +func (*StopLocalAgentResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{9} +} + +type RunOptions struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + EvalCriteria string `protobuf:"bytes,1,opt,name=eval_criteria,json=evalCriteria,proto3" json:"eval_criteria,omitempty"` + EvalMaxRounds uint32 `protobuf:"varint,2,opt,name=eval_max_rounds,json=evalMaxRounds,proto3" json:"eval_max_rounds,omitempty"` +} + +func (x *RunOptions) Reset() { + *x = RunOptions{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RunOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunOptions) ProtoMessage() {} + +func (x *RunOptions) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunOptions.ProtoReflect.Descriptor instead. +func (*RunOptions) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *RunOptions) GetEvalCriteria() string { + if x != nil { + return x.EvalCriteria + } + return "" +} + +func (x *RunOptions) GetEvalMaxRounds() uint32 { + if x != nil { + return x.EvalMaxRounds + } + return 0 +} + +type CommandDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Line string `protobuf:"bytes,1,opt,name=line,proto3" json:"line,omitempty"` + Presentation string `protobuf:"bytes,2,opt,name=presentation,proto3" json:"presentation,omitempty"` +} + +func (x *CommandDetail) Reset() { + *x = CommandDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandDetail) ProtoMessage() {} + +func (x *CommandDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandDetail.ProtoReflect.Descriptor instead. +func (*CommandDetail) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{11} +} + +func (x *CommandDetail) GetLine() string { + if x != nil { + return x.Line + } + return "" +} + +func (x *CommandDetail) GetPresentation() string { + if x != nil { + return x.Presentation + } + return "" +} + +type CompactDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + KeptMessages uint64 `protobuf:"varint,2,opt,name=kept_messages,json=keptMessages,proto3" json:"kept_messages,omitempty"` + TokensAfter uint64 `protobuf:"varint,3,opt,name=tokens_after,json=tokensAfter,proto3" json:"tokens_after,omitempty"` + TokensBefore uint64 `protobuf:"varint,4,opt,name=tokens_before,json=tokensBefore,proto3" json:"tokens_before,omitempty"` +} + +func (x *CompactDetail) Reset() { + *x = CompactDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CompactDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompactDetail) ProtoMessage() {} + +func (x *CompactDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CompactDetail.ProtoReflect.Descriptor instead. +func (*CompactDetail) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{12} +} + +func (x *CompactDetail) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *CompactDetail) GetKeptMessages() uint64 { + if x != nil { + return x.KeptMessages + } + return 0 +} + +func (x *CompactDetail) GetTokensAfter() uint64 { + if x != nil { + return x.TokensAfter + } + return 0 +} + +func (x *CompactDetail) GetTokensBefore() uint64 { + if x != nil { + return x.TokensBefore + } + return 0 +} + +type DelegationDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + AgentName string `protobuf:"bytes,2,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` + AgentType string `protobuf:"bytes,3,opt,name=agent_type,json=agentType,proto3" json:"agent_type,omitempty"` + ContextMode string `protobuf:"bytes,4,opt,name=context_mode,json=contextMode,proto3" json:"context_mode,omitempty"` + RunMode string `protobuf:"bytes,5,opt,name=run_mode,json=runMode,proto3" json:"run_mode,omitempty"` + Task string `protobuf:"bytes,6,opt,name=task,proto3" json:"task,omitempty"` +} + +func (x *DelegationDetail) Reset() { + *x = DelegationDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DelegationDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DelegationDetail) ProtoMessage() {} + +func (x *DelegationDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DelegationDetail.ProtoReflect.Descriptor instead. +func (*DelegationDetail) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *DelegationDetail) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *DelegationDetail) GetAgentName() string { + if x != nil { + return x.AgentName + } + return "" +} + +func (x *DelegationDetail) GetAgentType() string { + if x != nil { + return x.AgentType + } + return "" +} + +func (x *DelegationDetail) GetContextMode() string { + if x != nil { + return x.ContextMode + } + return "" +} + +func (x *DelegationDetail) GetRunMode() string { + if x != nil { + return x.RunMode + } + return "" +} + +func (x *DelegationDetail) GetTask() string { + if x != nil { + return x.Task + } + return "" +} + +type EvalControl struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Criteria string `protobuf:"bytes,1,opt,name=criteria,proto3" json:"criteria,omitempty"` + MaxRounds uint32 `protobuf:"varint,2,opt,name=max_rounds,json=maxRounds,proto3" json:"max_rounds,omitempty"` +} + +func (x *EvalControl) Reset() { + *x = EvalControl{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EvalControl) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EvalControl) ProtoMessage() {} + +func (x *EvalControl) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EvalControl.ProtoReflect.Descriptor instead. +func (*EvalControl) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{14} +} + +func (x *EvalControl) GetCriteria() string { + if x != nil { + return x.Criteria + } + return "" +} + +func (x *EvalControl) GetMaxRounds() uint32 { + if x != nil { + return x.MaxRounds + } + return 0 +} + +type EvalDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + MaxRounds uint32 `protobuf:"varint,2,opt,name=max_rounds,json=maxRounds,proto3" json:"max_rounds,omitempty"` + Pass bool `protobuf:"varint,3,opt,name=pass,proto3" json:"pass,omitempty"` + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` + Round uint32 `protobuf:"varint,5,opt,name=round,proto3" json:"round,omitempty"` +} + +func (x *EvalDetail) Reset() { + *x = EvalDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EvalDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EvalDetail) ProtoMessage() {} + +func (x *EvalDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EvalDetail.ProtoReflect.Descriptor instead. +func (*EvalDetail) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{15} +} + +func (x *EvalDetail) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *EvalDetail) GetMaxRounds() uint32 { + if x != nil { + return x.MaxRounds + } + return 0 +} + +func (x *EvalDetail) GetPass() bool { + if x != nil { + return x.Pass + } + return false +} + +func (x *EvalDetail) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *EvalDetail) GetRound() uint32 { + if x != nil { + return x.Round + } + return 0 +} + +type BudgetWarning struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ContextTokens uint64 `protobuf:"varint,1,opt,name=context_tokens,json=contextTokens,proto3" json:"context_tokens,omitempty"` + TokenBudget uint64 `protobuf:"varint,2,opt,name=token_budget,json=tokenBudget,proto3" json:"token_budget,omitempty"` +} + +func (x *BudgetWarning) Reset() { + *x = BudgetWarning{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BudgetWarning) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BudgetWarning) ProtoMessage() {} + +func (x *BudgetWarning) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BudgetWarning.ProtoReflect.Descriptor instead. +func (*BudgetWarning) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{16} +} + +func (x *BudgetWarning) GetContextTokens() uint64 { + if x != nil { + return x.ContextTokens + } + return 0 +} + +func (x *BudgetWarning) GetTokenBudget() uint64 { + if x != nil { + return x.TokenBudget + } + return 0 +} + +type LLMRequestDetail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` + Messages uint32 `protobuf:"varint,2,opt,name=messages,proto3" json:"messages,omitempty"` + MaxTokens uint32 `protobuf:"varint,3,opt,name=max_tokens,json=maxTokens,proto3" json:"max_tokens,omitempty"` + Stream bool `protobuf:"varint,4,opt,name=stream,proto3" json:"stream,omitempty"` +} + +func (x *LLMRequestDetail) Reset() { + *x = LLMRequestDetail{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMRequestDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMRequestDetail) ProtoMessage() {} + +func (x *LLMRequestDetail) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMRequestDetail.ProtoReflect.Descriptor instead. +func (*LLMRequestDetail) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{17} +} + +func (x *LLMRequestDetail) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *LLMRequestDetail) GetMessages() uint32 { + if x != nil { + return x.Messages + } + return 0 +} + +func (x *LLMRequestDetail) GetMaxTokens() uint32 { + if x != nil { + return x.MaxTokens + } + return 0 +} + +func (x *LLMRequestDetail) GetStream() bool { + if x != nil { + return x.Stream + } + return false +} + +type WebMessageMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + Params *structpb.Struct `protobuf:"bytes,3,opt,name=params,proto3" json:"params,omitempty"` +} + +func (x *WebMessageMetadata) Reset() { + *x = WebMessageMetadata{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_agent_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WebMessageMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebMessageMetadata) ProtoMessage() {} + +func (x *WebMessageMetadata) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_agent_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WebMessageMetadata.ProtoReflect.Descriptor instead. +func (*WebMessageMetadata) Descriptor() ([]byte, []int) { + return file_aiscan_types_agent_proto_rawDescGZIP(), []int{18} +} + +func (x *WebMessageMetadata) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *WebMessageMetadata) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *WebMessageMetadata) GetParams() *structpb.Struct { + if x != nil { + return x.Params + } + return nil +} + +var File_aiscan_types_agent_proto protoreflect.FileDescriptor + +var file_aiscan_types_agent_proto_rawDesc = []byte{ + 0x0a, 0x18, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x1a, 0x12, 0x61, 0x6f, 0x70, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1a, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9e, 0x02, 0x0a, 0x04, 0x56, 0x69, 0x65, 0x77, + 0x12, 0x25, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x6c, 0x6c, 0x6f, + 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x28, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x12, 0x25, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0f, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, + 0x73, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x64, 0x65, + 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x6f, 0x64, 0x65, + 0x55, 0x72, 0x69, 0x12, 0x3d, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, 0x06, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, + 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x53, 0x70, 0x65, 0x63, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x75, 0x73, 0x79, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x04, 0x62, 0x75, 0x73, 0x79, 0x22, 0x66, 0x0a, 0x0a, 0x4c, 0x6f, 0x63, 0x61, + 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x70, 0x69, 0x64, 0x12, 0x1e, 0x0a, 0x0a, + 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0a, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, + 0x62, 0x75, 0x73, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x62, 0x75, 0x73, 0x79, + 0x22, 0x13, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x40, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x69, 0x65, 0x77, 0x52, + 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x18, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x4c, + 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x22, 0x4b, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x06, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x6f, 0x63, 0x61, + 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x19, + 0x0a, 0x17, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x4a, 0x0a, 0x18, 0x4c, 0x61, 0x75, + 0x6e, 0x63, 0x68, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x05, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x22, 0x2b, 0x0a, 0x15, 0x53, 0x74, 0x6f, 0x70, 0x4c, 0x6f, 0x63, + 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x22, 0x18, 0x0a, 0x16, 0x53, 0x74, 0x6f, 0x70, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, + 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x59, 0x0a, 0x0a, + 0x52, 0x75, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x76, + 0x61, 0x6c, 0x5f, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0c, 0x65, 0x76, 0x61, 0x6c, 0x43, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, 0x12, + 0x26, 0x0a, 0x0f, 0x65, 0x76, 0x61, 0x6c, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, + 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x65, 0x76, 0x61, 0x6c, 0x4d, 0x61, + 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x22, 0x47, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x22, 0x0a, 0x0c, + 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x22, 0x92, 0x01, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x44, 0x65, 0x74, 0x61, + 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x6b, 0x65, 0x70, 0x74, + 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x0c, 0x6b, 0x65, 0x70, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x21, 0x0a, + 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x41, 0x66, 0x74, 0x65, 0x72, + 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x62, 0x65, 0x66, 0x6f, 0x72, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, + 0x65, 0x66, 0x6f, 0x72, 0x65, 0x22, 0xbd, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x67, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x6d, + 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x75, 0x6e, 0x5f, 0x6d, 0x6f, + 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x75, 0x6e, 0x4d, 0x6f, 0x64, + 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x73, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x61, 0x73, 0x6b, 0x22, 0x48, 0x0a, 0x0b, 0x45, 0x76, 0x61, 0x6c, 0x43, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x72, 0x69, 0x74, 0x65, 0x72, 0x69, 0x61, + 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x22, + 0x83, 0x01, 0x0a, 0x0a, 0x45, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, + 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x6f, 0x75, 0x6e, + 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x52, 0x6f, 0x75, + 0x6e, 0x64, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x04, 0x70, 0x61, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, + 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, + 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x59, 0x0a, 0x0d, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x57, + 0x61, 0x72, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x21, 0x0a, + 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, + 0x22, 0x7b, 0x0a, 0x10, 0x4c, 0x4c, 0x4d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, + 0x74, 0x61, 0x69, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x22, 0x74, 0x0a, + 0x12, 0x57, 0x65, 0x62, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, + 0x64, 0x65, 0x12, 0x2f, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x06, 0x70, 0x61, 0x72, + 0x61, 0x6d, 0x73, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, + 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x3b, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_types_agent_proto_rawDescOnce sync.Once + file_aiscan_types_agent_proto_rawDescData = file_aiscan_types_agent_proto_rawDesc +) + +func file_aiscan_types_agent_proto_rawDescGZIP() []byte { + file_aiscan_types_agent_proto_rawDescOnce.Do(func() { + file_aiscan_types_agent_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_agent_proto_rawDescData) + }) + return file_aiscan_types_agent_proto_rawDescData +} + +var file_aiscan_types_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_aiscan_types_agent_proto_goTypes = []interface{}{ + (*View)(nil), // 0: aiscan.agent.View + (*LocalAgent)(nil), // 1: aiscan.agent.LocalAgent + (*ListAgentsRequest)(nil), // 2: aiscan.agent.ListAgentsRequest + (*ListAgentsResponse)(nil), // 3: aiscan.agent.ListAgentsResponse + (*ListLocalAgentsRequest)(nil), // 4: aiscan.agent.ListLocalAgentsRequest + (*ListLocalAgentsResponse)(nil), // 5: aiscan.agent.ListLocalAgentsResponse + (*LaunchLocalAgentRequest)(nil), // 6: aiscan.agent.LaunchLocalAgentRequest + (*LaunchLocalAgentResponse)(nil), // 7: aiscan.agent.LaunchLocalAgentResponse + (*StopLocalAgentRequest)(nil), // 8: aiscan.agent.StopLocalAgentRequest + (*StopLocalAgentResponse)(nil), // 9: aiscan.agent.StopLocalAgentResponse + (*RunOptions)(nil), // 10: aiscan.agent.RunOptions + (*CommandDetail)(nil), // 11: aiscan.agent.CommandDetail + (*CompactDetail)(nil), // 12: aiscan.agent.CompactDetail + (*DelegationDetail)(nil), // 13: aiscan.agent.DelegationDetail + (*EvalControl)(nil), // 14: aiscan.agent.EvalControl + (*EvalDetail)(nil), // 15: aiscan.agent.EvalDetail + (*BudgetWarning)(nil), // 16: aiscan.agent.BudgetWarning + (*LLMRequestDetail)(nil), // 17: aiscan.agent.LLMRequestDetail + (*WebMessageMetadata)(nil), // 18: aiscan.agent.WebMessageMetadata + (*aop.AgentHello)(nil), // 19: aop.AgentHello + (*aop.AgentStatus)(nil), // 20: aop.AgentStatus + (*aop.AgentStats)(nil), // 21: aop.AgentStats + (*timestamppb.Timestamp)(nil), // 22: google.protobuf.Timestamp + (*command.Spec)(nil), // 23: aiscan.command.Spec + (*structpb.Struct)(nil), // 24: google.protobuf.Struct +} +var file_aiscan_types_agent_proto_depIdxs = []int32{ + 19, // 0: aiscan.agent.View.hello:type_name -> aop.AgentHello + 20, // 1: aiscan.agent.View.status:type_name -> aop.AgentStatus + 21, // 2: aiscan.agent.View.stats:type_name -> aop.AgentStats + 22, // 3: aiscan.agent.View.connected_at:type_name -> google.protobuf.Timestamp + 23, // 4: aiscan.agent.View.commands:type_name -> aiscan.command.Spec + 0, // 5: aiscan.agent.ListAgentsResponse.agents:type_name -> aiscan.agent.View + 1, // 6: aiscan.agent.ListLocalAgentsResponse.agents:type_name -> aiscan.agent.LocalAgent + 1, // 7: aiscan.agent.LaunchLocalAgentResponse.agent:type_name -> aiscan.agent.LocalAgent + 24, // 8: aiscan.agent.WebMessageMetadata.params:type_name -> google.protobuf.Struct + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name +} + +func init() { file_aiscan_types_agent_proto_init() } +func file_aiscan_types_agent_proto_init() { + if File_aiscan_types_agent_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_types_agent_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*View); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LocalAgent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListAgentsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListAgentsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListLocalAgentsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListLocalAgentsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LaunchLocalAgentRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LaunchLocalAgentResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StopLocalAgentRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StopLocalAgentResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RunOptions); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CompactDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DelegationDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EvalControl); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EvalDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BudgetWarning); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMRequestDetail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_agent_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WebMessageMetadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_types_agent_proto_rawDesc, + NumEnums: 0, + NumMessages: 19, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_types_agent_proto_goTypes, + DependencyIndexes: file_aiscan_types_agent_proto_depIdxs, + MessageInfos: file_aiscan_types_agent_proto_msgTypes, + }.Build() + File_aiscan_types_agent_proto = out.File + file_aiscan_types_agent_proto_rawDesc = nil + file_aiscan_types_agent_proto_goTypes = nil + file_aiscan_types_agent_proto_depIdxs = nil +} diff --git a/pkg/types/chat/chat.pb.go b/pkg/types/chat/chat.pb.go new file mode 100644 index 00000000..9ac7b6c6 --- /dev/null +++ b/pkg/types/chat/chat.pb.go @@ -0,0 +1,1103 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/types/chat.proto + +package chat + +import ( + aop "github.com/chainreactors/aiscan/aop" + command "github.com/chainreactors/aiscan/pkg/types/command" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SessionRecord struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session *aop.Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + AgentName string `protobuf:"bytes,2,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` + ScanIds []string `protobuf:"bytes,3,rep,name=scan_ids,json=scanIds,proto3" json:"scan_ids,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` +} + +func (x *SessionRecord) Reset() { + *x = SessionRecord{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SessionRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRecord) ProtoMessage() {} + +func (x *SessionRecord) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRecord.ProtoReflect.Descriptor instead. +func (*SessionRecord) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{0} +} + +func (x *SessionRecord) GetSession() *aop.Session { + if x != nil { + return x.Session + } + return nil +} + +func (x *SessionRecord) GetAgentName() string { + if x != nil { + return x.AgentName + } + return "" +} + +func (x *SessionRecord) GetScanIds() []string { + if x != nil { + return x.ScanIds + } + return nil +} + +func (x *SessionRecord) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *SessionRecord) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +type ListSessionsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AfterCursor string `protobuf:"bytes,1,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + IncludeClosed bool `protobuf:"varint,3,opt,name=include_closed,json=includeClosed,proto3" json:"include_closed,omitempty"` +} + +func (x *ListSessionsRequest) Reset() { + *x = ListSessionsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsRequest) ProtoMessage() {} + +func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListSessionsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{1} +} + +func (x *ListSessionsRequest) GetAfterCursor() string { + if x != nil { + return x.AfterCursor + } + return "" +} + +func (x *ListSessionsRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSessionsRequest) GetIncludeClosed() bool { + if x != nil { + return x.IncludeClosed + } + return false +} + +type ListSessionsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sessions []*SessionRecord `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` +} + +func (x *ListSessionsResponse) Reset() { + *x = ListSessionsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListSessionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsResponse) ProtoMessage() {} + +func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. +func (*ListSessionsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{2} +} + +func (x *ListSessionsResponse) GetSessions() []*SessionRecord { + if x != nil { + return x.Sessions + } + return nil +} + +func (x *ListSessionsResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +type GetSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` +} + +func (x *GetSessionRequest) Reset() { + *x = GetSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionRequest) ProtoMessage() {} + +func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. +func (*GetSessionRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{3} +} + +func (x *GetSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type GetSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session *SessionRecord `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *GetSessionResponse) Reset() { + *x = GetSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionResponse) ProtoMessage() {} + +func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. +func (*GetSessionResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{4} +} + +func (x *GetSessionResponse) GetSession() *SessionRecord { + if x != nil { + return x.Session + } + return nil +} + +type ResetSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + NewSessionId string `protobuf:"bytes,3,opt,name=new_session_id,json=newSessionId,proto3" json:"new_session_id,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` +} + +func (x *ResetSessionRequest) Reset() { + *x = ResetSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResetSessionRequest) ProtoMessage() {} + +func (x *ResetSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResetSessionRequest.ProtoReflect.Descriptor instead. +func (*ResetSessionRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{5} +} + +func (x *ResetSessionRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ResetSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ResetSessionRequest) GetNewSessionId() string { + if x != nil { + return x.NewSessionId + } + return "" +} + +func (x *ResetSessionRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +type ResetSessionReceipt struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Previous *aop.Session `protobuf:"bytes,1,opt,name=previous,proto3" json:"previous,omitempty"` + Current *SessionRecord `protobuf:"bytes,2,opt,name=current,proto3" json:"current,omitempty"` +} + +func (x *ResetSessionReceipt) Reset() { + *x = ResetSessionReceipt{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResetSessionReceipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResetSessionReceipt) ProtoMessage() {} + +func (x *ResetSessionReceipt) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResetSessionReceipt.ProtoReflect.Descriptor instead. +func (*ResetSessionReceipt) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{6} +} + +func (x *ResetSessionReceipt) GetPrevious() *aop.Session { + if x != nil { + return x.Previous + } + return nil +} + +func (x *ResetSessionReceipt) GetCurrent() *SessionRecord { + if x != nil { + return x.Current + } + return nil +} + +type ResetSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *ResetSessionResponse_Accepted + // *ResetSessionResponse_Rejected + Outcome isResetSessionResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *ResetSessionResponse) Reset() { + *x = ResetSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResetSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResetSessionResponse) ProtoMessage() {} + +func (x *ResetSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResetSessionResponse.ProtoReflect.Descriptor instead. +func (*ResetSessionResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{7} +} + +func (x *ResetSessionResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *ResetSessionResponse) GetOutcome() isResetSessionResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *ResetSessionResponse) GetAccepted() *ResetSessionReceipt { + if x, ok := x.GetOutcome().(*ResetSessionResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *ResetSessionResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*ResetSessionResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isResetSessionResponse_Outcome interface { + isResetSessionResponse_Outcome() +} + +type ResetSessionResponse_Accepted struct { + Accepted *ResetSessionReceipt `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type ResetSessionResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*ResetSessionResponse_Accepted) isResetSessionResponse_Outcome() {} + +func (*ResetSessionResponse_Rejected) isResetSessionResponse_Outcome() {} + +type DeleteSessionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` +} + +func (x *DeleteSessionRequest) Reset() { + *x = DeleteSessionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSessionRequest) ProtoMessage() {} + +func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSessionRequest.ProtoReflect.Descriptor instead. +func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{8} +} + +func (x *DeleteSessionRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *DeleteSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type DeleteSessionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are assignable to Outcome: + // + // *DeleteSessionResponse_Accepted + // *DeleteSessionResponse_Rejected + Outcome isDeleteSessionResponse_Outcome `protobuf_oneof:"outcome"` +} + +func (x *DeleteSessionResponse) Reset() { + *x = DeleteSessionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSessionResponse) ProtoMessage() {} + +func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSessionResponse.ProtoReflect.Descriptor instead. +func (*DeleteSessionResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteSessionResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (m *DeleteSessionResponse) GetOutcome() isDeleteSessionResponse_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *DeleteSessionResponse) GetAccepted() *aop.Session { + if x, ok := x.GetOutcome().(*DeleteSessionResponse_Accepted); ok { + return x.Accepted + } + return nil +} + +func (x *DeleteSessionResponse) GetRejected() *aop.Rejection { + if x, ok := x.GetOutcome().(*DeleteSessionResponse_Rejected); ok { + return x.Rejected + } + return nil +} + +type isDeleteSessionResponse_Outcome interface { + isDeleteSessionResponse_Outcome() +} + +type DeleteSessionResponse_Accepted struct { + Accepted *aop.Session `protobuf:"bytes,2,opt,name=accepted,proto3,oneof"` +} + +type DeleteSessionResponse_Rejected struct { + Rejected *aop.Rejection `protobuf:"bytes,3,opt,name=rejected,proto3,oneof"` +} + +func (*DeleteSessionResponse_Accepted) isDeleteSessionResponse_Outcome() {} + +func (*DeleteSessionResponse_Rejected) isDeleteSessionResponse_Outcome() {} + +type ListCommandsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` +} + +func (x *ListCommandsRequest) Reset() { + *x = ListCommandsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListCommandsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCommandsRequest) ProtoMessage() {} + +func (x *ListCommandsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCommandsRequest.ProtoReflect.Descriptor instead. +func (*ListCommandsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{10} +} + +func (x *ListCommandsRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type ListCommandsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Commands []*command.Spec `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` +} + +func (x *ListCommandsResponse) Reset() { + *x = ListCommandsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_chat_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListCommandsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCommandsResponse) ProtoMessage() {} + +func (x *ListCommandsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_chat_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCommandsResponse.ProtoReflect.Descriptor instead. +func (*ListCommandsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_chat_proto_rawDescGZIP(), []int{11} +} + +func (x *ListCommandsResponse) GetCommands() []*command.Spec { + if x != nil { + return x.Commands + } + return nil +} + +var File_aiscan_types_chat_proto protoreflect.FileDescriptor + +var file_aiscan_types_chat_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, + 0x68, 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1a, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, + 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x22, 0xe7, 0x01, 0x0a, 0x0d, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x26, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, + 0x0a, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, + 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, + 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, 0x75, 0x0a, + 0x13, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x63, 0x75, + 0x72, 0x73, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x66, 0x74, 0x65, + 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x25, 0x0a, + 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x43, 0x6c, + 0x6f, 0x73, 0x65, 0x64, 0x22, 0x6f, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x36, 0x0a, 0x08, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x08, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, + 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, + 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0x32, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x4a, 0x0a, 0x12, 0x47, 0x65, 0x74, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x34, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8f, 0x01, 0x0a, 0x13, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, + 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x6e, + 0x65, 0x77, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6e, 0x65, 0x77, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x22, 0x75, 0x0a, 0x13, 0x52, 0x65, 0x73, 0x65, 0x74, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x12, 0x28, + 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, + 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x12, 0x34, 0x0a, 0x07, 0x63, 0x75, 0x72, 0x72, + 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x22, 0xae, + 0x01, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x3e, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, + 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, + 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, + 0x54, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x9b, 0x01, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2a, + 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0c, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x48, 0x00, + 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, + 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, + 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, + 0x6f, 0x6d, 0x65, 0x22, 0x34, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x48, 0x0a, 0x14, 0x4c, 0x69, 0x73, + 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x30, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x53, 0x70, 0x65, 0x63, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x73, 0x42, 0x35, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, + 0x2f, 0x63, 0x68, 0x61, 0x74, 0x3b, 0x63, 0x68, 0x61, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_aiscan_types_chat_proto_rawDescOnce sync.Once + file_aiscan_types_chat_proto_rawDescData = file_aiscan_types_chat_proto_rawDesc +) + +func file_aiscan_types_chat_proto_rawDescGZIP() []byte { + file_aiscan_types_chat_proto_rawDescOnce.Do(func() { + file_aiscan_types_chat_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_chat_proto_rawDescData) + }) + return file_aiscan_types_chat_proto_rawDescData +} + +var file_aiscan_types_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_aiscan_types_chat_proto_goTypes = []interface{}{ + (*SessionRecord)(nil), // 0: aiscan.chat.SessionRecord + (*ListSessionsRequest)(nil), // 1: aiscan.chat.ListSessionsRequest + (*ListSessionsResponse)(nil), // 2: aiscan.chat.ListSessionsResponse + (*GetSessionRequest)(nil), // 3: aiscan.chat.GetSessionRequest + (*GetSessionResponse)(nil), // 4: aiscan.chat.GetSessionResponse + (*ResetSessionRequest)(nil), // 5: aiscan.chat.ResetSessionRequest + (*ResetSessionReceipt)(nil), // 6: aiscan.chat.ResetSessionReceipt + (*ResetSessionResponse)(nil), // 7: aiscan.chat.ResetSessionResponse + (*DeleteSessionRequest)(nil), // 8: aiscan.chat.DeleteSessionRequest + (*DeleteSessionResponse)(nil), // 9: aiscan.chat.DeleteSessionResponse + (*ListCommandsRequest)(nil), // 10: aiscan.chat.ListCommandsRequest + (*ListCommandsResponse)(nil), // 11: aiscan.chat.ListCommandsResponse + (*aop.Session)(nil), // 12: aop.Session + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp + (*aop.Rejection)(nil), // 14: aop.Rejection + (*command.Spec)(nil), // 15: aiscan.command.Spec +} +var file_aiscan_types_chat_proto_depIdxs = []int32{ + 12, // 0: aiscan.chat.SessionRecord.session:type_name -> aop.Session + 13, // 1: aiscan.chat.SessionRecord.created_at:type_name -> google.protobuf.Timestamp + 13, // 2: aiscan.chat.SessionRecord.updated_at:type_name -> google.protobuf.Timestamp + 0, // 3: aiscan.chat.ListSessionsResponse.sessions:type_name -> aiscan.chat.SessionRecord + 0, // 4: aiscan.chat.GetSessionResponse.session:type_name -> aiscan.chat.SessionRecord + 12, // 5: aiscan.chat.ResetSessionReceipt.previous:type_name -> aop.Session + 0, // 6: aiscan.chat.ResetSessionReceipt.current:type_name -> aiscan.chat.SessionRecord + 6, // 7: aiscan.chat.ResetSessionResponse.accepted:type_name -> aiscan.chat.ResetSessionReceipt + 14, // 8: aiscan.chat.ResetSessionResponse.rejected:type_name -> aop.Rejection + 12, // 9: aiscan.chat.DeleteSessionResponse.accepted:type_name -> aop.Session + 14, // 10: aiscan.chat.DeleteSessionResponse.rejected:type_name -> aop.Rejection + 15, // 11: aiscan.chat.ListCommandsResponse.commands:type_name -> aiscan.command.Spec + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name +} + +func init() { file_aiscan_types_chat_proto_init() } +func file_aiscan_types_chat_proto_init() { + if File_aiscan_types_chat_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_types_chat_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SessionRecord); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListSessionsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListSessionsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResetSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResetSessionReceipt); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResetSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteSessionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListCommandsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_chat_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListCommandsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aiscan_types_chat_proto_msgTypes[7].OneofWrappers = []interface{}{ + (*ResetSessionResponse_Accepted)(nil), + (*ResetSessionResponse_Rejected)(nil), + } + file_aiscan_types_chat_proto_msgTypes[9].OneofWrappers = []interface{}{ + (*DeleteSessionResponse_Accepted)(nil), + (*DeleteSessionResponse_Rejected)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_types_chat_proto_rawDesc, + NumEnums: 0, + NumMessages: 12, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_types_chat_proto_goTypes, + DependencyIndexes: file_aiscan_types_chat_proto_depIdxs, + MessageInfos: file_aiscan_types_chat_proto_msgTypes, + }.Build() + File_aiscan_types_chat_proto = out.File + file_aiscan_types_chat_proto_rawDesc = nil + file_aiscan_types_chat_proto_goTypes = nil + file_aiscan_types_chat_proto_depIdxs = nil +} diff --git a/pkg/types/command/command.pb.go b/pkg/types/command/command.pb.go new file mode 100644 index 00000000..9a4f6e3d --- /dev/null +++ b/pkg/types/command/command.pb.go @@ -0,0 +1,612 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/types/command.proto + +package command + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Spec struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Aliases []string `protobuf:"bytes,2,rep,name=aliases,proto3" json:"aliases,omitempty"` + Usage string `protobuf:"bytes,3,opt,name=usage,proto3" json:"usage,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` +} + +func (x *Spec) Reset() { + *x = Spec{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_command_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Spec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Spec) ProtoMessage() {} + +func (x *Spec) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_command_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Spec.ProtoReflect.Descriptor instead. +func (*Spec) Descriptor() ([]byte, []int) { + return file_aiscan_types_command_proto_rawDescGZIP(), []int{0} +} + +func (x *Spec) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Spec) GetAliases() []string { + if x != nil { + return x.Aliases + } + return nil +} + +func (x *Spec) GetUsage() string { + if x != nil { + return x.Usage + } + return "" +} + +func (x *Spec) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type Catalog struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Commands []*Spec `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` +} + +func (x *Catalog) Reset() { + *x = Catalog{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_command_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Catalog) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Catalog) ProtoMessage() {} + +func (x *Catalog) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_command_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Catalog.ProtoReflect.Descriptor instead. +func (*Catalog) Descriptor() ([]byte, []int) { + return file_aiscan_types_command_proto_rawDescGZIP(), []int{1} +} + +func (x *Catalog) GetCommands() []*Spec { + if x != nil { + return x.Commands + } + return nil +} + +type Request struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Line string `protobuf:"bytes,2,opt,name=line,proto3" json:"line,omitempty"` +} + +func (x *Request) Reset() { + *x = Request{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_command_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Request) ProtoMessage() {} + +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_command_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_aiscan_types_command_proto_rawDescGZIP(), []int{2} +} + +func (x *Request) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Request) GetLine() string { + if x != nil { + return x.Line + } + return "" +} + +type Result struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *Result) Reset() { + *x = Result{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_command_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Result) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Result) ProtoMessage() {} + +func (x *Result) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_command_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Result.ProtoReflect.Descriptor instead. +func (*Result) Descriptor() ([]byte, []int) { + return file_aiscan_types_command_proto_rawDescGZIP(), []int{3} +} + +func (x *Result) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *Result) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +type Receipt struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` +} + +func (x *Receipt) Reset() { + *x = Receipt{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_command_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Receipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Receipt) ProtoMessage() {} + +func (x *Receipt) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_command_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Receipt.ProtoReflect.Descriptor instead. +func (*Receipt) Descriptor() ([]byte, []int) { + return file_aiscan_types_command_proto_rawDescGZIP(), []int{4} +} + +func (x *Receipt) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *Receipt) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Receipt) GetState() string { + if x != nil { + return x.State + } + return "" +} + +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_Request + // *ProtocolMessage_Result + // *ProtocolMessage_Catalog + // *ProtocolMessage_Receipt + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_command_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_command_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aiscan_types_command_proto_rawDescGZIP(), []int{5} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetRequest() *Request { + if x, ok := x.GetMessage().(*ProtocolMessage_Request); ok { + return x.Request + } + return nil +} + +func (x *ProtocolMessage) GetResult() *Result { + if x, ok := x.GetMessage().(*ProtocolMessage_Result); ok { + return x.Result + } + return nil +} + +func (x *ProtocolMessage) GetCatalog() *Catalog { + if x, ok := x.GetMessage().(*ProtocolMessage_Catalog); ok { + return x.Catalog + } + return nil +} + +func (x *ProtocolMessage) GetReceipt() *Receipt { + if x, ok := x.GetMessage().(*ProtocolMessage_Receipt); ok { + return x.Receipt + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_Request struct { + Request *Request `protobuf:"bytes,10,opt,name=request,proto3,oneof"` +} + +type ProtocolMessage_Result struct { + Result *Result `protobuf:"bytes,11,opt,name=result,proto3,oneof"` +} + +type ProtocolMessage_Catalog struct { + Catalog *Catalog `protobuf:"bytes,12,opt,name=catalog,proto3,oneof"` +} + +type ProtocolMessage_Receipt struct { + Receipt *Receipt `protobuf:"bytes,13,opt,name=receipt,proto3,oneof"` +} + +func (*ProtocolMessage_Request) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Result) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Catalog) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Receipt) isProtocolMessage_Message() {} + +var File_aiscan_types_command_proto protoreflect.FileDescriptor + +var file_aiscan_types_command_proto_rawDesc = []byte{ + 0x0a, 0x1a, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x22, 0x6c, 0x0a, 0x04, + 0x53, 0x70, 0x65, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x69, 0x61, + 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x61, 0x6c, 0x69, 0x61, 0x73, + 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x75, 0x73, 0x61, 0x67, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, + 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x3b, 0x0a, 0x07, 0x43, 0x61, + 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x12, 0x30, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x53, 0x70, 0x65, 0x63, 0x52, 0x08, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x22, 0x3c, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x22, 0x3b, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, + 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, + 0x70, 0x65, 0x22, 0x61, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x12, 0x21, 0x0a, + 0x0c, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, + 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, + 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0xed, 0x01, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x33, 0x0a, 0x07, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x30, + 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x12, 0x33, 0x0a, 0x07, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x2e, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x07, 0x63, 0x61, + 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x12, 0x33, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, + 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x48, + 0x00, 0x52, 0x07, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, + 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x79, 0x70, + 0x65, 0x73, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x3b, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_types_command_proto_rawDescOnce sync.Once + file_aiscan_types_command_proto_rawDescData = file_aiscan_types_command_proto_rawDesc +) + +func file_aiscan_types_command_proto_rawDescGZIP() []byte { + file_aiscan_types_command_proto_rawDescOnce.Do(func() { + file_aiscan_types_command_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_command_proto_rawDescData) + }) + return file_aiscan_types_command_proto_rawDescData +} + +var file_aiscan_types_command_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_aiscan_types_command_proto_goTypes = []interface{}{ + (*Spec)(nil), // 0: aiscan.command.Spec + (*Catalog)(nil), // 1: aiscan.command.Catalog + (*Request)(nil), // 2: aiscan.command.Request + (*Result)(nil), // 3: aiscan.command.Result + (*Receipt)(nil), // 4: aiscan.command.Receipt + (*ProtocolMessage)(nil), // 5: aiscan.command.ProtocolMessage +} +var file_aiscan_types_command_proto_depIdxs = []int32{ + 0, // 0: aiscan.command.Catalog.commands:type_name -> aiscan.command.Spec + 2, // 1: aiscan.command.ProtocolMessage.request:type_name -> aiscan.command.Request + 3, // 2: aiscan.command.ProtocolMessage.result:type_name -> aiscan.command.Result + 1, // 3: aiscan.command.ProtocolMessage.catalog:type_name -> aiscan.command.Catalog + 4, // 4: aiscan.command.ProtocolMessage.receipt:type_name -> aiscan.command.Receipt + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_aiscan_types_command_proto_init() } +func file_aiscan_types_command_proto_init() { + if File_aiscan_types_command_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_types_command_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Spec); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_command_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Catalog); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_command_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Request); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_command_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Result); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_command_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Receipt); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_command_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aiscan_types_command_proto_msgTypes[5].OneofWrappers = []interface{}{ + (*ProtocolMessage_Request)(nil), + (*ProtocolMessage_Result)(nil), + (*ProtocolMessage_Catalog)(nil), + (*ProtocolMessage_Receipt)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_types_command_proto_rawDesc, + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_types_command_proto_goTypes, + DependencyIndexes: file_aiscan_types_command_proto_depIdxs, + MessageInfos: file_aiscan_types_command_proto_msgTypes, + }.Build() + File_aiscan_types_command_proto = out.File + file_aiscan_types_command_proto_rawDesc = nil + file_aiscan_types_command_proto_goTypes = nil + file_aiscan_types_command_proto_depIdxs = nil +} diff --git a/pkg/types/config/config.pb.go b/pkg/types/config/config.pb.go new file mode 100644 index 00000000..47599f35 --- /dev/null +++ b/pkg/types/config/config.pb.go @@ -0,0 +1,2566 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/types/config.proto + +package config + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DistributeConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Llm *LLMConfig `protobuf:"bytes,1,opt,name=llm,proto3" json:"llm,omitempty"` + Cyberhub *CyberhubConfig `protobuf:"bytes,2,opt,name=cyberhub,proto3" json:"cyberhub,omitempty"` + Recon *ReconConfig `protobuf:"bytes,3,opt,name=recon,proto3" json:"recon,omitempty"` + Scan *ScanConfig `protobuf:"bytes,4,opt,name=scan,proto3" json:"scan,omitempty"` + Search *SearchConfig `protobuf:"bytes,5,opt,name=search,proto3" json:"search,omitempty"` + Ioa *IOAConfig `protobuf:"bytes,6,opt,name=ioa,proto3" json:"ioa,omitempty"` + Agent *AgentConfig `protobuf:"bytes,7,opt,name=agent,proto3" json:"agent,omitempty"` +} + +func (x *DistributeConfig) Reset() { + *x = DistributeConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DistributeConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DistributeConfig) ProtoMessage() {} + +func (x *DistributeConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DistributeConfig.ProtoReflect.Descriptor instead. +func (*DistributeConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{0} +} + +func (x *DistributeConfig) GetLlm() *LLMConfig { + if x != nil { + return x.Llm + } + return nil +} + +func (x *DistributeConfig) GetCyberhub() *CyberhubConfig { + if x != nil { + return x.Cyberhub + } + return nil +} + +func (x *DistributeConfig) GetRecon() *ReconConfig { + if x != nil { + return x.Recon + } + return nil +} + +func (x *DistributeConfig) GetScan() *ScanConfig { + if x != nil { + return x.Scan + } + return nil +} + +func (x *DistributeConfig) GetSearch() *SearchConfig { + if x != nil { + return x.Search + } + return nil +} + +func (x *DistributeConfig) GetIoa() *IOAConfig { + if x != nil { + return x.Ioa + } + return nil +} + +func (x *DistributeConfig) GetAgent() *AgentConfig { + if x != nil { + return x.Agent + } + return nil +} + +type LLMConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ActiveProfile string `protobuf:"bytes,1,opt,name=active_profile,json=activeProfile,proto3" json:"active_profile,omitempty"` + Providers []*LLMProviderConfig `protobuf:"bytes,2,rep,name=providers,proto3" json:"providers,omitempty"` +} + +func (x *LLMConfig) Reset() { + *x = LLMConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMConfig) ProtoMessage() {} + +func (x *LLMConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMConfig.ProtoReflect.Descriptor instead. +func (*LLMConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{1} +} + +func (x *LLMConfig) GetActiveProfile() string { + if x != nil { + return x.ActiveProfile + } + return "" +} + +func (x *LLMConfig) GetProviders() []*LLMProviderConfig { + if x != nil { + return x.Providers + } + return nil +} + +type LLMProviderConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` + BaseUrl string `protobuf:"bytes,4,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + ApiKey string `protobuf:"bytes,5,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` + Model string `protobuf:"bytes,6,opt,name=model,proto3" json:"model,omitempty"` + Proxy string `protobuf:"bytes,7,opt,name=proxy,proto3" json:"proxy,omitempty"` + MaxTokens int32 `protobuf:"varint,8,opt,name=max_tokens,json=maxTokens,proto3" json:"max_tokens,omitempty"` + ContextWindow int32 `protobuf:"varint,9,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` +} + +func (x *LLMProviderConfig) Reset() { + *x = LLMProviderConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMProviderConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMProviderConfig) ProtoMessage() {} + +func (x *LLMProviderConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMProviderConfig.ProtoReflect.Descriptor instead. +func (*LLMProviderConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{2} +} + +func (x *LLMProviderConfig) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *LLMProviderConfig) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *LLMProviderConfig) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *LLMProviderConfig) GetBaseUrl() string { + if x != nil { + return x.BaseUrl + } + return "" +} + +func (x *LLMProviderConfig) GetApiKey() string { + if x != nil { + return x.ApiKey + } + return "" +} + +func (x *LLMProviderConfig) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *LLMProviderConfig) GetProxy() string { + if x != nil { + return x.Proxy + } + return "" +} + +func (x *LLMProviderConfig) GetMaxTokens() int32 { + if x != nil { + return x.MaxTokens + } + return 0 +} + +func (x *LLMProviderConfig) GetContextWindow() int32 { + if x != nil { + return x.ContextWindow + } + return 0 +} + +type CyberhubConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + Mode string `protobuf:"bytes,3,opt,name=mode,proto3" json:"mode,omitempty"` + Proxy string `protobuf:"bytes,4,opt,name=proxy,proto3" json:"proxy,omitempty"` +} + +func (x *CyberhubConfig) Reset() { + *x = CyberhubConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CyberhubConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CyberhubConfig) ProtoMessage() {} + +func (x *CyberhubConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CyberhubConfig.ProtoReflect.Descriptor instead. +func (*CyberhubConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{3} +} + +func (x *CyberhubConfig) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *CyberhubConfig) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *CyberhubConfig) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *CyberhubConfig) GetProxy() string { + if x != nil { + return x.Proxy + } + return "" +} + +type ReconConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + FofaEmail string `protobuf:"bytes,1,opt,name=fofa_email,json=fofaEmail,proto3" json:"fofa_email,omitempty"` + FofaKey string `protobuf:"bytes,2,opt,name=fofa_key,json=fofaKey,proto3" json:"fofa_key,omitempty"` + HunterToken string `protobuf:"bytes,3,opt,name=hunter_token,json=hunterToken,proto3" json:"hunter_token,omitempty"` + HunterApiKey string `protobuf:"bytes,4,opt,name=hunter_api_key,json=hunterApiKey,proto3" json:"hunter_api_key,omitempty"` + Proxy string `protobuf:"bytes,5,opt,name=proxy,proto3" json:"proxy,omitempty"` + Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` +} + +func (x *ReconConfig) Reset() { + *x = ReconConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReconConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReconConfig) ProtoMessage() {} + +func (x *ReconConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReconConfig.ProtoReflect.Descriptor instead. +func (*ReconConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{4} +} + +func (x *ReconConfig) GetFofaEmail() string { + if x != nil { + return x.FofaEmail + } + return "" +} + +func (x *ReconConfig) GetFofaKey() string { + if x != nil { + return x.FofaKey + } + return "" +} + +func (x *ReconConfig) GetHunterToken() string { + if x != nil { + return x.HunterToken + } + return "" +} + +func (x *ReconConfig) GetHunterApiKey() string { + if x != nil { + return x.HunterApiKey + } + return "" +} + +func (x *ReconConfig) GetProxy() string { + if x != nil { + return x.Proxy + } + return "" +} + +func (x *ReconConfig) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type ScanConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Verify string `protobuf:"bytes,1,opt,name=verify,proto3" json:"verify,omitempty"` +} + +func (x *ScanConfig) Reset() { + *x = ScanConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanConfig) ProtoMessage() {} + +func (x *ScanConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanConfig.ProtoReflect.Descriptor instead. +func (*ScanConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{5} +} + +func (x *ScanConfig) GetVerify() string { + if x != nil { + return x.Verify + } + return "" +} + +type SearchConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TavilyKeys string `protobuf:"bytes,1,opt,name=tavily_keys,json=tavilyKeys,proto3" json:"tavily_keys,omitempty"` +} + +func (x *SearchConfig) Reset() { + *x = SearchConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SearchConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchConfig) ProtoMessage() {} + +func (x *SearchConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchConfig.ProtoReflect.Descriptor instead. +func (*SearchConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{6} +} + +func (x *SearchConfig) GetTavilyKeys() string { + if x != nil { + return x.TavilyKeys + } + return "" +} + +type IOAConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + NodeName string `protobuf:"bytes,3,opt,name=node_name,json=nodeName,proto3" json:"node_name,omitempty"` + Space string `protobuf:"bytes,4,opt,name=space,proto3" json:"space,omitempty"` +} + +func (x *IOAConfig) Reset() { + *x = IOAConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IOAConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IOAConfig) ProtoMessage() {} + +func (x *IOAConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IOAConfig.ProtoReflect.Descriptor instead. +func (*IOAConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{7} +} + +func (x *IOAConfig) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *IOAConfig) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *IOAConfig) GetNodeName() string { + if x != nil { + return x.NodeName + } + return "" +} + +func (x *IOAConfig) GetSpace() string { + if x != nil { + return x.Space + } + return "" +} + +type AgentConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Tools []string `protobuf:"bytes,1,rep,name=tools,proto3" json:"tools,omitempty"` + Timeout int32 `protobuf:"varint,2,opt,name=timeout,proto3" json:"timeout,omitempty"` + SaveSession bool `protobuf:"varint,3,opt,name=save_session,json=saveSession,proto3" json:"save_session,omitempty"` +} + +func (x *AgentConfig) Reset() { + *x = AgentConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentConfig) ProtoMessage() {} + +func (x *AgentConfig) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentConfig.ProtoReflect.Descriptor instead. +func (*AgentConfig) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{8} +} + +func (x *AgentConfig) GetTools() []string { + if x != nil { + return x.Tools + } + return nil +} + +func (x *AgentConfig) GetTimeout() int32 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *AgentConfig) GetSaveSession() bool { + if x != nil { + return x.SaveSession + } + return false +} + +type LLMProviderView struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` + BaseUrl string `protobuf:"bytes,4,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + ApiKeyConfigured bool `protobuf:"varint,5,opt,name=api_key_configured,json=apiKeyConfigured,proto3" json:"api_key_configured,omitempty"` + Model string `protobuf:"bytes,6,opt,name=model,proto3" json:"model,omitempty"` + Proxy string `protobuf:"bytes,7,opt,name=proxy,proto3" json:"proxy,omitempty"` + MaxTokens int32 `protobuf:"varint,8,opt,name=max_tokens,json=maxTokens,proto3" json:"max_tokens,omitempty"` + ContextWindow int32 `protobuf:"varint,9,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` +} + +func (x *LLMProviderView) Reset() { + *x = LLMProviderView{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMProviderView) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMProviderView) ProtoMessage() {} + +func (x *LLMProviderView) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMProviderView.ProtoReflect.Descriptor instead. +func (*LLMProviderView) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{9} +} + +func (x *LLMProviderView) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *LLMProviderView) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *LLMProviderView) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *LLMProviderView) GetBaseUrl() string { + if x != nil { + return x.BaseUrl + } + return "" +} + +func (x *LLMProviderView) GetApiKeyConfigured() bool { + if x != nil { + return x.ApiKeyConfigured + } + return false +} + +func (x *LLMProviderView) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *LLMProviderView) GetProxy() string { + if x != nil { + return x.Proxy + } + return "" +} + +func (x *LLMProviderView) GetMaxTokens() int32 { + if x != nil { + return x.MaxTokens + } + return 0 +} + +func (x *LLMProviderView) GetContextWindow() int32 { + if x != nil { + return x.ContextWindow + } + return 0 +} + +type LLMView struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ActiveProfile string `protobuf:"bytes,1,opt,name=active_profile,json=activeProfile,proto3" json:"active_profile,omitempty"` + Active *LLMProviderView `protobuf:"bytes,2,opt,name=active,proto3" json:"active,omitempty"` + Providers []*LLMProviderView `protobuf:"bytes,3,rep,name=providers,proto3" json:"providers,omitempty"` +} + +func (x *LLMView) Reset() { + *x = LLMView{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMView) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMView) ProtoMessage() {} + +func (x *LLMView) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMView.ProtoReflect.Descriptor instead. +func (*LLMView) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{10} +} + +func (x *LLMView) GetActiveProfile() string { + if x != nil { + return x.ActiveProfile + } + return "" +} + +func (x *LLMView) GetActive() *LLMProviderView { + if x != nil { + return x.Active + } + return nil +} + +func (x *LLMView) GetProviders() []*LLMProviderView { + if x != nil { + return x.Providers + } + return nil +} + +type CyberhubView struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + KeyConfigured bool `protobuf:"varint,2,opt,name=key_configured,json=keyConfigured,proto3" json:"key_configured,omitempty"` + Mode string `protobuf:"bytes,3,opt,name=mode,proto3" json:"mode,omitempty"` + Proxy string `protobuf:"bytes,4,opt,name=proxy,proto3" json:"proxy,omitempty"` +} + +func (x *CyberhubView) Reset() { + *x = CyberhubView{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CyberhubView) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CyberhubView) ProtoMessage() {} + +func (x *CyberhubView) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CyberhubView.ProtoReflect.Descriptor instead. +func (*CyberhubView) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{11} +} + +func (x *CyberhubView) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *CyberhubView) GetKeyConfigured() bool { + if x != nil { + return x.KeyConfigured + } + return false +} + +func (x *CyberhubView) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *CyberhubView) GetProxy() string { + if x != nil { + return x.Proxy + } + return "" +} + +type ReconView struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + FofaEmail string `protobuf:"bytes,1,opt,name=fofa_email,json=fofaEmail,proto3" json:"fofa_email,omitempty"` + FofaKeyConfigured bool `protobuf:"varint,2,opt,name=fofa_key_configured,json=fofaKeyConfigured,proto3" json:"fofa_key_configured,omitempty"` + HunterTokenConfigured bool `protobuf:"varint,3,opt,name=hunter_token_configured,json=hunterTokenConfigured,proto3" json:"hunter_token_configured,omitempty"` + HunterApiKeyConfigured bool `protobuf:"varint,4,opt,name=hunter_api_key_configured,json=hunterApiKeyConfigured,proto3" json:"hunter_api_key_configured,omitempty"` + Proxy string `protobuf:"bytes,5,opt,name=proxy,proto3" json:"proxy,omitempty"` + Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` +} + +func (x *ReconView) Reset() { + *x = ReconView{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ReconView) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReconView) ProtoMessage() {} + +func (x *ReconView) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReconView.ProtoReflect.Descriptor instead. +func (*ReconView) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{12} +} + +func (x *ReconView) GetFofaEmail() string { + if x != nil { + return x.FofaEmail + } + return "" +} + +func (x *ReconView) GetFofaKeyConfigured() bool { + if x != nil { + return x.FofaKeyConfigured + } + return false +} + +func (x *ReconView) GetHunterTokenConfigured() bool { + if x != nil { + return x.HunterTokenConfigured + } + return false +} + +func (x *ReconView) GetHunterApiKeyConfigured() bool { + if x != nil { + return x.HunterApiKeyConfigured + } + return false +} + +func (x *ReconView) GetProxy() string { + if x != nil { + return x.Proxy + } + return "" +} + +func (x *ReconView) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type SearchView struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TavilyKeysConfigured bool `protobuf:"varint,1,opt,name=tavily_keys_configured,json=tavilyKeysConfigured,proto3" json:"tavily_keys_configured,omitempty"` +} + +func (x *SearchView) Reset() { + *x = SearchView{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SearchView) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchView) ProtoMessage() {} + +func (x *SearchView) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchView.ProtoReflect.Descriptor instead. +func (*SearchView) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{13} +} + +func (x *SearchView) GetTavilyKeysConfigured() bool { + if x != nil { + return x.TavilyKeysConfigured + } + return false +} + +type IOAView struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + TokenConfigured bool `protobuf:"varint,2,opt,name=token_configured,json=tokenConfigured,proto3" json:"token_configured,omitempty"` + NodeName string `protobuf:"bytes,3,opt,name=node_name,json=nodeName,proto3" json:"node_name,omitempty"` + Space string `protobuf:"bytes,4,opt,name=space,proto3" json:"space,omitempty"` +} + +func (x *IOAView) Reset() { + *x = IOAView{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IOAView) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IOAView) ProtoMessage() {} + +func (x *IOAView) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IOAView.ProtoReflect.Descriptor instead. +func (*IOAView) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{14} +} + +func (x *IOAView) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *IOAView) GetTokenConfigured() bool { + if x != nil { + return x.TokenConfigured + } + return false +} + +func (x *IOAView) GetNodeName() string { + if x != nil { + return x.NodeName + } + return "" +} + +func (x *IOAView) GetSpace() string { + if x != nil { + return x.Space + } + return "" +} + +type ConfigView struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Loaded bool `protobuf:"varint,2,opt,name=loaded,proto3" json:"loaded,omitempty"` + Llm *LLMView `protobuf:"bytes,3,opt,name=llm,proto3" json:"llm,omitempty"` + Cyberhub *CyberhubView `protobuf:"bytes,4,opt,name=cyberhub,proto3" json:"cyberhub,omitempty"` + Recon *ReconView `protobuf:"bytes,5,opt,name=recon,proto3" json:"recon,omitempty"` + Scan *ScanConfig `protobuf:"bytes,6,opt,name=scan,proto3" json:"scan,omitempty"` + Search *SearchView `protobuf:"bytes,7,opt,name=search,proto3" json:"search,omitempty"` + Ioa *IOAView `protobuf:"bytes,8,opt,name=ioa,proto3" json:"ioa,omitempty"` + Agent *AgentConfig `protobuf:"bytes,9,opt,name=agent,proto3" json:"agent,omitempty"` +} + +func (x *ConfigView) Reset() { + *x = ConfigView{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ConfigView) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigView) ProtoMessage() {} + +func (x *ConfigView) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigView.ProtoReflect.Descriptor instead. +func (*ConfigView) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{15} +} + +func (x *ConfigView) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ConfigView) GetLoaded() bool { + if x != nil { + return x.Loaded + } + return false +} + +func (x *ConfigView) GetLlm() *LLMView { + if x != nil { + return x.Llm + } + return nil +} + +func (x *ConfigView) GetCyberhub() *CyberhubView { + if x != nil { + return x.Cyberhub + } + return nil +} + +func (x *ConfigView) GetRecon() *ReconView { + if x != nil { + return x.Recon + } + return nil +} + +func (x *ConfigView) GetScan() *ScanConfig { + if x != nil { + return x.Scan + } + return nil +} + +func (x *ConfigView) GetSearch() *SearchView { + if x != nil { + return x.Search + } + return nil +} + +func (x *ConfigView) GetIoa() *IOAView { + if x != nil { + return x.Ioa + } + return nil +} + +func (x *ConfigView) GetAgent() *AgentConfig { + if x != nil { + return x.Agent + } + return nil +} + +type GetConfigResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config *ConfigView `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` +} + +func (x *GetConfigResponse) Reset() { + *x = GetConfigResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConfigResponse) ProtoMessage() {} + +func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. +func (*GetConfigResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{16} +} + +func (x *GetConfigResponse) GetConfig() *ConfigView { + if x != nil { + return x.Config + } + return nil +} + +type UpdateConfigRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config *DistributeConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` +} + +func (x *UpdateConfigRequest) Reset() { + *x = UpdateConfigRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConfigRequest) ProtoMessage() {} + +func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. +func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{17} +} + +func (x *UpdateConfigRequest) GetConfig() *DistributeConfig { + if x != nil { + return x.Config + } + return nil +} + +type UpdateConfigResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config *ConfigView `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` +} + +func (x *UpdateConfigResponse) Reset() { + *x = UpdateConfigResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConfigResponse) ProtoMessage() {} + +func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. +func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{18} +} + +func (x *UpdateConfigResponse) GetConfig() *ConfigView { + if x != nil { + return x.Config + } + return nil +} + +type ActivateProfileRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ProfileId string `protobuf:"bytes,1,opt,name=profile_id,json=profileId,proto3" json:"profile_id,omitempty"` +} + +func (x *ActivateProfileRequest) Reset() { + *x = ActivateProfileRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ActivateProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActivateProfileRequest) ProtoMessage() {} + +func (x *ActivateProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActivateProfileRequest.ProtoReflect.Descriptor instead. +func (*ActivateProfileRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{19} +} + +func (x *ActivateProfileRequest) GetProfileId() string { + if x != nil { + return x.ProfileId + } + return "" +} + +type ActivateProfileResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config *ConfigView `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` +} + +func (x *ActivateProfileResponse) Reset() { + *x = ActivateProfileResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ActivateProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActivateProfileResponse) ProtoMessage() {} + +func (x *ActivateProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActivateProfileResponse.ProtoReflect.Descriptor instead. +func (*ActivateProfileResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{20} +} + +func (x *ActivateProfileResponse) GetConfig() *ConfigView { + if x != nil { + return x.Config + } + return nil +} + +type LLMProbeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ProfileId string `protobuf:"bytes,1,opt,name=profile_id,json=profileId,proto3" json:"profile_id,omitempty"` + Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` + BaseUrl string `protobuf:"bytes,3,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + ApiKey string `protobuf:"bytes,4,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` + Model string `protobuf:"bytes,5,opt,name=model,proto3" json:"model,omitempty"` + Proxy string `protobuf:"bytes,6,opt,name=proxy,proto3" json:"proxy,omitempty"` +} + +func (x *LLMProbeRequest) Reset() { + *x = LLMProbeRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMProbeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMProbeRequest) ProtoMessage() {} + +func (x *LLMProbeRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[21] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMProbeRequest.ProtoReflect.Descriptor instead. +func (*LLMProbeRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{21} +} + +func (x *LLMProbeRequest) GetProfileId() string { + if x != nil { + return x.ProfileId + } + return "" +} + +func (x *LLMProbeRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *LLMProbeRequest) GetBaseUrl() string { + if x != nil { + return x.BaseUrl + } + return "" +} + +func (x *LLMProbeRequest) GetApiKey() string { + if x != nil { + return x.ApiKey + } + return "" +} + +func (x *LLMProbeRequest) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *LLMProbeRequest) GetProxy() string { + if x != nil { + return x.Proxy + } + return "" +} + +type LLMProbeResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` + Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"` + LatencyMs int64 `protobuf:"varint,4,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` + Reply string `protobuf:"bytes,5,opt,name=reply,proto3" json:"reply,omitempty"` + Error string `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *LLMProbeResult) Reset() { + *x = LLMProbeResult{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LLMProbeResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LLMProbeResult) ProtoMessage() {} + +func (x *LLMProbeResult) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LLMProbeResult.ProtoReflect.Descriptor instead. +func (*LLMProbeResult) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{22} +} + +func (x *LLMProbeResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *LLMProbeResult) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *LLMProbeResult) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *LLMProbeResult) GetLatencyMs() int64 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +func (x *LLMProbeResult) GetReply() string { + if x != nil { + return x.Reply + } + return "" +} + +func (x *LLMProbeResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ListModelsResult struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Supported bool `protobuf:"varint,2,opt,name=supported,proto3" json:"supported,omitempty"` + Models []string `protobuf:"bytes,3,rep,name=models,proto3" json:"models,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ListModelsResult) Reset() { + *x = ListModelsResult{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListModelsResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListModelsResult) ProtoMessage() {} + +func (x *ListModelsResult) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[23] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListModelsResult.ProtoReflect.Descriptor instead. +func (*ListModelsResult) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{23} +} + +func (x *ListModelsResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *ListModelsResult) GetSupported() bool { + if x != nil { + return x.Supported + } + return false +} + +func (x *ListModelsResult) GetModels() []string { + if x != nil { + return x.Models + } + return nil +} + +func (x *ListModelsResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type TestConnectionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Section string `protobuf:"bytes,1,opt,name=section,proto3" json:"section,omitempty"` + Config *DistributeConfig `protobuf:"bytes,2,opt,name=config,proto3" json:"config,omitempty"` +} + +func (x *TestConnectionRequest) Reset() { + *x = TestConnectionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TestConnectionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConnectionRequest) ProtoMessage() {} + +func (x *TestConnectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[24] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConnectionRequest.ProtoReflect.Descriptor instead. +func (*TestConnectionRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{24} +} + +func (x *TestConnectionRequest) GetSection() string { + if x != nil { + return x.Section + } + return "" +} + +func (x *TestConnectionRequest) GetConfig() *DistributeConfig { + if x != nil { + return x.Config + } + return nil +} + +type ConnectionCheck struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Ok bool `protobuf:"varint,2,opt,name=ok,proto3" json:"ok,omitempty"` + LatencyMs int64 `protobuf:"varint,3,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` + Detail string `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ConnectionCheck) Reset() { + *x = ConnectionCheck{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ConnectionCheck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectionCheck) ProtoMessage() {} + +func (x *ConnectionCheck) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[25] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectionCheck.ProtoReflect.Descriptor instead. +func (*ConnectionCheck) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{25} +} + +func (x *ConnectionCheck) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ConnectionCheck) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *ConnectionCheck) GetLatencyMs() int64 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +func (x *ConnectionCheck) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +func (x *ConnectionCheck) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type TestConnectionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Checks []*ConnectionCheck `protobuf:"bytes,1,rep,name=checks,proto3" json:"checks,omitempty"` +} + +func (x *TestConnectionResponse) Reset() { + *x = TestConnectionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_config_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TestConnectionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConnectionResponse) ProtoMessage() {} + +func (x *TestConnectionResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_config_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConnectionResponse.ProtoReflect.Descriptor instead. +func (*TestConnectionResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_config_proto_rawDescGZIP(), []int{26} +} + +func (x *TestConnectionResponse) GetChecks() []*ConnectionCheck { + if x != nil { + return x.Checks + } + return nil +} + +var File_aiscan_types_config_proto protoreflect.FileDescriptor + +var file_aiscan_types_config_proto_rawDesc = []byte{ + 0x0a, 0x19, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xed, 0x02, 0x0a, 0x10, 0x44, + 0x69, 0x73, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x2a, 0x0a, 0x03, 0x6c, 0x6c, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4c, 0x4c, 0x4d, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x03, 0x6c, 0x6c, 0x6d, 0x12, 0x39, 0x0a, 0x08, 0x63, + 0x79, 0x62, 0x65, 0x72, 0x68, 0x75, 0x62, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x43, 0x79, + 0x62, 0x65, 0x72, 0x68, 0x75, 0x62, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x08, 0x63, 0x79, + 0x62, 0x65, 0x72, 0x68, 0x75, 0x62, 0x12, 0x30, 0x0a, 0x05, 0x72, 0x65, 0x63, 0x6f, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x05, 0x72, 0x65, 0x63, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x04, 0x73, 0x63, 0x61, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x04, 0x73, 0x63, 0x61, 0x6e, 0x12, 0x33, 0x0a, 0x06, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x2a, 0x0a, 0x03, + 0x69, 0x6f, 0x61, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x49, 0x4f, 0x41, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x03, 0x69, 0x6f, 0x61, 0x12, 0x30, 0x0a, 0x05, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x22, 0x72, 0x0a, 0x09, 0x4c, 0x4c, + 0x4d, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x63, 0x74, 0x69, 0x76, + 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0d, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3e, + 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x20, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x2e, 0x4c, 0x4c, 0x4d, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x22, 0xf9, + 0x01, 0x0a, 0x11, 0x4c, 0x4c, 0x4d, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x12, 0x19, 0x0a, 0x08, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x75, 0x72, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x61, 0x73, 0x65, 0x55, 0x72, 0x6c, 0x12, + 0x17, 0x0a, 0x07, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x14, + 0x0a, 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, + 0x72, 0x6f, 0x78, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x77, + 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x22, 0x5e, 0x0a, 0x0e, 0x43, 0x79, + 0x62, 0x65, 0x72, 0x68, 0x75, 0x62, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, + 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x22, 0xbc, 0x01, 0x0a, 0x0b, 0x52, + 0x65, 0x63, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1d, 0x0a, 0x0a, 0x66, 0x6f, + 0x66, 0x61, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x66, 0x6f, 0x66, 0x61, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x6f, 0x66, + 0x61, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x6f, 0x66, + 0x61, 0x4b, 0x65, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x75, 0x6e, 0x74, + 0x65, 0x72, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x68, 0x75, 0x6e, 0x74, 0x65, + 0x72, 0x5f, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x68, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x24, 0x0a, 0x0a, 0x53, 0x63, 0x61, + 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x22, + 0x2f, 0x0a, 0x0c, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x1f, 0x0a, 0x0b, 0x74, 0x61, 0x76, 0x69, 0x6c, 0x79, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x61, 0x76, 0x69, 0x6c, 0x79, 0x4b, 0x65, 0x79, 0x73, + 0x22, 0x66, 0x0a, 0x09, 0x49, 0x4f, 0x41, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, + 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, + 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x4e, 0x61, + 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x73, 0x70, 0x61, 0x63, 0x65, 0x22, 0x60, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6f, 0x6c, 0x73, 0x12, 0x18, 0x0a, + 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, + 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x61, 0x76, 0x65, 0x5f, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x73, + 0x61, 0x76, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8c, 0x02, 0x0a, 0x0f, 0x4c, + 0x4c, 0x4d, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x56, 0x69, 0x65, 0x77, 0x12, 0x0e, + 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x19, + 0x0a, 0x08, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x62, 0x61, 0x73, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x2c, 0x0a, 0x12, 0x61, 0x70, 0x69, + 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x14, 0x0a, + 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x74, + 0x65, 0x78, 0x74, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x22, 0xa6, 0x01, 0x0a, 0x07, 0x4c, 0x4c, + 0x4d, 0x56, 0x69, 0x65, 0x77, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, + 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, + 0x63, 0x74, 0x69, 0x76, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x36, 0x0a, 0x06, + 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4c, 0x4c, 0x4d, + 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x56, 0x69, 0x65, 0x77, 0x52, 0x06, 0x61, 0x63, + 0x74, 0x69, 0x76, 0x65, 0x12, 0x3c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4c, 0x4c, 0x4d, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x56, 0x69, 0x65, 0x77, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x73, 0x22, 0x71, 0x0a, 0x0c, 0x43, 0x79, 0x62, 0x65, 0x72, 0x68, 0x75, 0x62, 0x56, 0x69, + 0x65, 0x77, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x75, 0x72, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x6b, 0x65, 0x79, 0x5f, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x6b, 0x65, + 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6d, + 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, + 0x14, 0x0a, 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x70, 0x72, 0x6f, 0x78, 0x79, 0x22, 0xf9, 0x01, 0x0a, 0x09, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x56, + 0x69, 0x65, 0x77, 0x12, 0x1d, 0x0a, 0x0a, 0x66, 0x6f, 0x66, 0x61, 0x5f, 0x65, 0x6d, 0x61, 0x69, + 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x66, 0x6f, 0x66, 0x61, 0x45, 0x6d, 0x61, + 0x69, 0x6c, 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x6f, 0x66, 0x61, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x11, 0x66, 0x6f, 0x66, 0x61, 0x4b, 0x65, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, + 0x65, 0x64, 0x12, 0x36, 0x0a, 0x17, 0x68, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x15, 0x68, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x12, 0x39, 0x0a, 0x19, 0x68, 0x75, + 0x6e, 0x74, 0x65, 0x72, 0x5f, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x68, + 0x75, 0x6e, 0x74, 0x65, 0x72, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x75, 0x72, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, + 0x69, 0x6d, 0x69, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, + 0x74, 0x22, 0x42, 0x0a, 0x0a, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x56, 0x69, 0x65, 0x77, 0x12, + 0x34, 0x0a, 0x16, 0x74, 0x61, 0x76, 0x69, 0x6c, 0x79, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x5f, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x14, 0x74, 0x61, 0x76, 0x69, 0x6c, 0x79, 0x4b, 0x65, 0x79, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x75, 0x72, 0x65, 0x64, 0x22, 0x79, 0x0a, 0x07, 0x49, 0x4f, 0x41, 0x56, 0x69, 0x65, 0x77, + 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, + 0x72, 0x6c, 0x12, 0x29, 0x0a, 0x10, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x12, 0x1b, 0x0a, + 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x22, 0x89, 0x03, 0x0a, 0x0a, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x56, 0x69, 0x65, 0x77, 0x12, + 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, + 0x61, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x03, 0x6c, + 0x6c, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4c, 0x4c, 0x4d, 0x56, 0x69, 0x65, 0x77, + 0x52, 0x03, 0x6c, 0x6c, 0x6d, 0x12, 0x37, 0x0a, 0x08, 0x63, 0x79, 0x62, 0x65, 0x72, 0x68, 0x75, + 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x43, 0x79, 0x62, 0x65, 0x72, 0x68, 0x75, 0x62, + 0x56, 0x69, 0x65, 0x77, 0x52, 0x08, 0x63, 0x79, 0x62, 0x65, 0x72, 0x68, 0x75, 0x62, 0x12, 0x2e, + 0x0a, 0x05, 0x72, 0x65, 0x63, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x52, 0x65, + 0x63, 0x6f, 0x6e, 0x56, 0x69, 0x65, 0x77, 0x52, 0x05, 0x72, 0x65, 0x63, 0x6f, 0x6e, 0x12, 0x2d, + 0x0a, 0x04, 0x73, 0x63, 0x61, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x53, 0x63, 0x61, + 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x73, 0x63, 0x61, 0x6e, 0x12, 0x31, 0x0a, + 0x06, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x56, 0x69, 0x65, 0x77, 0x52, 0x06, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x12, 0x28, 0x0a, 0x03, 0x69, 0x6f, 0x61, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x49, 0x4f, + 0x41, 0x56, 0x69, 0x65, 0x77, 0x52, 0x03, 0x69, 0x6f, 0x61, 0x12, 0x30, 0x0a, 0x05, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x22, 0x46, 0x0a, 0x11, + 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x31, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x56, 0x69, 0x65, 0x77, 0x52, 0x06, 0x63, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x22, 0x4e, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x37, 0x0a, 0x06, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x44, 0x69, 0x73, 0x74, + 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x22, 0x49, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, + 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x56, 0x69, 0x65, 0x77, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, + 0x37, 0x0a, 0x16, 0x41, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, + 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, + 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x49, 0x64, 0x22, 0x4c, 0x0a, 0x17, 0x41, 0x63, 0x74, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x56, 0x69, 0x65, 0x77, 0x52, 0x06, + 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xac, 0x01, 0x0a, 0x0f, 0x4c, 0x4c, 0x4d, 0x50, 0x72, + 0x6f, 0x62, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x19, 0x0a, 0x08, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x75, 0x72, + 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x61, 0x73, 0x65, 0x55, 0x72, 0x6c, + 0x12, 0x17, 0x0a, 0x07, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, + 0x14, 0x0a, 0x05, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x70, 0x72, 0x6f, 0x78, 0x79, 0x22, 0x9d, 0x01, 0x0a, 0x0e, 0x4c, 0x4c, 0x4d, 0x50, 0x72, 0x6f, + 0x62, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x61, + 0x74, 0x65, 0x6e, 0x63, 0x79, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, + 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x4d, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x70, + 0x6c, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x70, 0x6c, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x6e, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x6f, 0x64, + 0x65, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x75, 0x70, + 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x75, + 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x6f, 0x64, 0x65, 0x6c, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x12, + 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x6a, 0x0a, 0x15, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, + 0x0a, 0x07, 0x73, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x73, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x37, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x44, 0x69, 0x73, 0x74, 0x72, 0x69, 0x62, + 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x22, 0x82, 0x01, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x61, 0x74, + 0x65, 0x6e, 0x63, 0x79, 0x5f, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x6c, + 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x65, 0x74, 0x61, + 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, + 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x50, 0x0a, 0x16, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x36, 0x0a, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x52, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x3b, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_types_config_proto_rawDescOnce sync.Once + file_aiscan_types_config_proto_rawDescData = file_aiscan_types_config_proto_rawDesc +) + +func file_aiscan_types_config_proto_rawDescGZIP() []byte { + file_aiscan_types_config_proto_rawDescOnce.Do(func() { + file_aiscan_types_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_config_proto_rawDescData) + }) + return file_aiscan_types_config_proto_rawDescData +} + +var file_aiscan_types_config_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_aiscan_types_config_proto_goTypes = []interface{}{ + (*DistributeConfig)(nil), // 0: aiscan.config.DistributeConfig + (*LLMConfig)(nil), // 1: aiscan.config.LLMConfig + (*LLMProviderConfig)(nil), // 2: aiscan.config.LLMProviderConfig + (*CyberhubConfig)(nil), // 3: aiscan.config.CyberhubConfig + (*ReconConfig)(nil), // 4: aiscan.config.ReconConfig + (*ScanConfig)(nil), // 5: aiscan.config.ScanConfig + (*SearchConfig)(nil), // 6: aiscan.config.SearchConfig + (*IOAConfig)(nil), // 7: aiscan.config.IOAConfig + (*AgentConfig)(nil), // 8: aiscan.config.AgentConfig + (*LLMProviderView)(nil), // 9: aiscan.config.LLMProviderView + (*LLMView)(nil), // 10: aiscan.config.LLMView + (*CyberhubView)(nil), // 11: aiscan.config.CyberhubView + (*ReconView)(nil), // 12: aiscan.config.ReconView + (*SearchView)(nil), // 13: aiscan.config.SearchView + (*IOAView)(nil), // 14: aiscan.config.IOAView + (*ConfigView)(nil), // 15: aiscan.config.ConfigView + (*GetConfigResponse)(nil), // 16: aiscan.config.GetConfigResponse + (*UpdateConfigRequest)(nil), // 17: aiscan.config.UpdateConfigRequest + (*UpdateConfigResponse)(nil), // 18: aiscan.config.UpdateConfigResponse + (*ActivateProfileRequest)(nil), // 19: aiscan.config.ActivateProfileRequest + (*ActivateProfileResponse)(nil), // 20: aiscan.config.ActivateProfileResponse + (*LLMProbeRequest)(nil), // 21: aiscan.config.LLMProbeRequest + (*LLMProbeResult)(nil), // 22: aiscan.config.LLMProbeResult + (*ListModelsResult)(nil), // 23: aiscan.config.ListModelsResult + (*TestConnectionRequest)(nil), // 24: aiscan.config.TestConnectionRequest + (*ConnectionCheck)(nil), // 25: aiscan.config.ConnectionCheck + (*TestConnectionResponse)(nil), // 26: aiscan.config.TestConnectionResponse +} +var file_aiscan_types_config_proto_depIdxs = []int32{ + 1, // 0: aiscan.config.DistributeConfig.llm:type_name -> aiscan.config.LLMConfig + 3, // 1: aiscan.config.DistributeConfig.cyberhub:type_name -> aiscan.config.CyberhubConfig + 4, // 2: aiscan.config.DistributeConfig.recon:type_name -> aiscan.config.ReconConfig + 5, // 3: aiscan.config.DistributeConfig.scan:type_name -> aiscan.config.ScanConfig + 6, // 4: aiscan.config.DistributeConfig.search:type_name -> aiscan.config.SearchConfig + 7, // 5: aiscan.config.DistributeConfig.ioa:type_name -> aiscan.config.IOAConfig + 8, // 6: aiscan.config.DistributeConfig.agent:type_name -> aiscan.config.AgentConfig + 2, // 7: aiscan.config.LLMConfig.providers:type_name -> aiscan.config.LLMProviderConfig + 9, // 8: aiscan.config.LLMView.active:type_name -> aiscan.config.LLMProviderView + 9, // 9: aiscan.config.LLMView.providers:type_name -> aiscan.config.LLMProviderView + 10, // 10: aiscan.config.ConfigView.llm:type_name -> aiscan.config.LLMView + 11, // 11: aiscan.config.ConfigView.cyberhub:type_name -> aiscan.config.CyberhubView + 12, // 12: aiscan.config.ConfigView.recon:type_name -> aiscan.config.ReconView + 5, // 13: aiscan.config.ConfigView.scan:type_name -> aiscan.config.ScanConfig + 13, // 14: aiscan.config.ConfigView.search:type_name -> aiscan.config.SearchView + 14, // 15: aiscan.config.ConfigView.ioa:type_name -> aiscan.config.IOAView + 8, // 16: aiscan.config.ConfigView.agent:type_name -> aiscan.config.AgentConfig + 15, // 17: aiscan.config.GetConfigResponse.config:type_name -> aiscan.config.ConfigView + 0, // 18: aiscan.config.UpdateConfigRequest.config:type_name -> aiscan.config.DistributeConfig + 15, // 19: aiscan.config.UpdateConfigResponse.config:type_name -> aiscan.config.ConfigView + 15, // 20: aiscan.config.ActivateProfileResponse.config:type_name -> aiscan.config.ConfigView + 0, // 21: aiscan.config.TestConnectionRequest.config:type_name -> aiscan.config.DistributeConfig + 25, // 22: aiscan.config.TestConnectionResponse.checks:type_name -> aiscan.config.ConnectionCheck + 23, // [23:23] is the sub-list for method output_type + 23, // [23:23] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name +} + +func init() { file_aiscan_types_config_proto_init() } +func file_aiscan_types_config_proto_init() { + if File_aiscan_types_config_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_types_config_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DistributeConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMProviderConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CyberhubConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReconConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SearchConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IOAConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AgentConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMProviderView); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMView); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CyberhubView); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ReconView); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SearchView); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IOAView); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ConfigView); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetConfigResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateConfigRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateConfigResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ActivateProfileRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ActivateProfileResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMProbeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LLMProbeResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListModelsResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TestConnectionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ConnectionCheck); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_config_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*TestConnectionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_types_config_proto_rawDesc, + NumEnums: 0, + NumMessages: 27, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_types_config_proto_goTypes, + DependencyIndexes: file_aiscan_types_config_proto_depIdxs, + MessageInfos: file_aiscan_types_config_proto_msgTypes, + }.Build() + File_aiscan_types_config_proto = out.File + file_aiscan_types_config_proto_rawDesc = nil + file_aiscan_types_config_proto_goTypes = nil + file_aiscan_types_config_proto_depIdxs = nil +} diff --git a/aop/aiscan/extensions/extensions.go b/pkg/types/extensions/extensions.go similarity index 56% rename from aop/aiscan/extensions/extensions.go rename to pkg/types/extensions/extensions.go index cfd09223..51d924ca 100644 --- a/aop/aiscan/extensions/extensions.go +++ b/pkg/types/extensions/extensions.go @@ -1,21 +1,12 @@ // Package extensions contains AIScan-owned typed AOP extension helpers. // // Stable AOP payloads live in the root aop package. Product-specific metadata -// is namespaced here so runtime and transport packages do not need handwritten -// JSON envelopes or duplicate DTOs. +// is carried as typed Any values owned by AIScan protobuf packages. package extensions import ( "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" -) - -const ( - CommandNamespace = "command" - CompactNamespace = "compact" - DelegationNamespace = "delegation" - EvalNamespace = "eval" - WebNamespace = "io.chainreactors.aiscan.web" + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" ) const ( @@ -35,69 +26,69 @@ const ( DelegationRunForeground = "foreground" ) -type CommandDetail = transport.CommandDetail -type CompactDetail = transport.CompactDetail -type DelegationDetail = transport.DelegationDetail -type EvalControl = transport.EvalControl -type EvalDetail = transport.EvalDetail -type WebMessageExtension = transport.WebMessageExtension +type CommandDetail = agentpb.CommandDetail +type CompactDetail = agentpb.CompactDetail +type DelegationDetail = agentpb.DelegationDetail +type EvalControl = agentpb.EvalControl +type EvalDetail = agentpb.EvalDetail +type WebMessageExtension = agentpb.WebMessageMetadata func GetCommandDetail(event *aop.Event) (CommandDetail, bool, error) { value := new(CommandDetail) - ok, err := aop.ProtoExtension(event, CommandNamespace, value) + ok, err := aop.FindTypedExtension(event, value) return *value, ok, err } func SetCommandDetail(event *aop.Event, value CommandDetail) error { - return aop.SetProtoExtension(event, CommandNamespace, &value) + return aop.SetTypedExtension(event, &value) } func GetCompactDetail(event *aop.Event) (CompactDetail, bool, error) { value := new(CompactDetail) - ok, err := aop.ProtoExtension(event, CompactNamespace, value) + ok, err := aop.FindTypedExtension(event, value) return *value, ok, err } func SetCompactDetail(event *aop.Event, value CompactDetail) error { - return aop.SetProtoExtension(event, CompactNamespace, &value) + return aop.SetTypedExtension(event, &value) } func GetDelegation(event *aop.Event) (DelegationDetail, bool, error) { value := new(DelegationDetail) - ok, err := aop.ProtoExtension(event, DelegationNamespace, value) + ok, err := aop.FindTypedExtension(event, value) return *value, ok, err } func SetDelegation(event *aop.Event, value DelegationDetail) error { - return aop.SetProtoExtension(event, DelegationNamespace, &value) + return aop.SetTypedExtension(event, &value) } func GetEvalControl(event *aop.Event) (EvalControl, bool, error) { value := new(EvalControl) - ok, err := aop.ProtoExtension(event, EvalNamespace, value) + ok, err := aop.FindTypedExtension(event, value) return *value, ok, err } func SetEvalControl(event *aop.Event, value EvalControl) error { - return aop.SetProtoExtension(event, EvalNamespace, &value) + return aop.SetTypedExtension(event, &value) } func GetEvalDetail(event *aop.Event) (EvalDetail, bool, error) { value := new(EvalDetail) - ok, err := aop.ProtoExtension(event, EvalNamespace, value) + ok, err := aop.FindTypedExtension(event, value) return *value, ok, err } func SetEvalDetail(event *aop.Event, value EvalDetail) error { - return aop.SetProtoExtension(event, EvalNamespace, &value) + return aop.SetTypedExtension(event, &value) } func GetWebMessage(event *aop.Event) (WebMessageExtension, bool, error) { value := new(WebMessageExtension) - ok, err := aop.ProtoExtension(event, WebNamespace, value) + ok, err := aop.FindTypedExtension(event, value) return *value, ok, err } func SetWebMessage(event *aop.Event, value WebMessageExtension) error { - return aop.SetProtoExtension(event, WebNamespace, &value) + return aop.SetTypedExtension(event, &value) } diff --git a/pkg/types/reload/reload.pb.go b/pkg/types/reload/reload.pb.go new file mode 100644 index 00000000..7ce44bc5 --- /dev/null +++ b/pkg/types/reload/reload.pb.go @@ -0,0 +1,351 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/types/reload.proto + +package reload + +import ( + config "github.com/chainreactors/aiscan/pkg/types/config" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Request struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config *config.DistributeConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` +} + +func (x *Request) Reset() { + *x = Request{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_reload_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Request) ProtoMessage() {} + +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_reload_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_aiscan_types_reload_proto_rawDescGZIP(), []int{0} +} + +func (x *Request) GetConfig() *config.DistributeConfig { + if x != nil { + return x.Config + } + return nil +} + +type Result struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` + Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *Result) Reset() { + *x = Result{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_reload_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Result) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Result) ProtoMessage() {} + +func (x *Result) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_reload_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Result.ProtoReflect.Descriptor instead. +func (*Result) Descriptor() ([]byte, []int) { + return file_aiscan_types_reload_proto_rawDescGZIP(), []int{1} +} + +func (x *Result) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *Result) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *Result) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *Result) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ProtocolMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Message: + // + // *ProtocolMessage_Request + // *ProtocolMessage_Result + Message isProtocolMessage_Message `protobuf_oneof:"message"` +} + +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_reload_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtocolMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtocolMessage) ProtoMessage() {} + +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_reload_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aiscan_types_reload_proto_rawDescGZIP(), []int{2} +} + +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetRequest() *Request { + if x, ok := x.GetMessage().(*ProtocolMessage_Request); ok { + return x.Request + } + return nil +} + +func (x *ProtocolMessage) GetResult() *Result { + if x, ok := x.GetMessage().(*ProtocolMessage_Result); ok { + return x.Result + } + return nil +} + +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_Request struct { + Request *Request `protobuf:"bytes,10,opt,name=request,proto3,oneof"` +} + +type ProtocolMessage_Result struct { + Result *Result `protobuf:"bytes,11,opt,name=result,proto3,oneof"` +} + +func (*ProtocolMessage_Request) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Result) isProtocolMessage_Message() {} + +var File_aiscan_types_reload_proto protoreflect.FileDescriptor + +var file_aiscan_types_reload_proto_rawDesc = []byte{ + 0x0a, 0x19, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x72, + 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x72, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x1a, 0x19, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x42, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x37, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x2e, 0x44, 0x69, 0x73, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x60, 0x0a, 0x06, 0x52, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x02, 0x6f, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, + 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x81, 0x01, 0x0a, 0x0f, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x32, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x72, 0x65, 0x6c, 0x6f, 0x61, 0x64, + 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x72, 0x65, 0x6c, + 0x6f, 0x61, 0x64, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, + 0x73, 0x75, 0x6c, 0x74, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, + 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, + 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, + 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x72, 0x65, 0x6c, + 0x6f, 0x61, 0x64, 0x3b, 0x72, 0x65, 0x6c, 0x6f, 0x61, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_aiscan_types_reload_proto_rawDescOnce sync.Once + file_aiscan_types_reload_proto_rawDescData = file_aiscan_types_reload_proto_rawDesc +) + +func file_aiscan_types_reload_proto_rawDescGZIP() []byte { + file_aiscan_types_reload_proto_rawDescOnce.Do(func() { + file_aiscan_types_reload_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_reload_proto_rawDescData) + }) + return file_aiscan_types_reload_proto_rawDescData +} + +var file_aiscan_types_reload_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_aiscan_types_reload_proto_goTypes = []interface{}{ + (*Request)(nil), // 0: aiscan.reload.Request + (*Result)(nil), // 1: aiscan.reload.Result + (*ProtocolMessage)(nil), // 2: aiscan.reload.ProtocolMessage + (*config.DistributeConfig)(nil), // 3: aiscan.config.DistributeConfig +} +var file_aiscan_types_reload_proto_depIdxs = []int32{ + 3, // 0: aiscan.reload.Request.config:type_name -> aiscan.config.DistributeConfig + 0, // 1: aiscan.reload.ProtocolMessage.request:type_name -> aiscan.reload.Request + 1, // 2: aiscan.reload.ProtocolMessage.result:type_name -> aiscan.reload.Result + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_aiscan_types_reload_proto_init() } +func file_aiscan_types_reload_proto_init() { + if File_aiscan_types_reload_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_types_reload_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Request); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_reload_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Result); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_reload_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_aiscan_types_reload_proto_msgTypes[2].OneofWrappers = []interface{}{ + (*ProtocolMessage_Request)(nil), + (*ProtocolMessage_Result)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_types_reload_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_types_reload_proto_goTypes, + DependencyIndexes: file_aiscan_types_reload_proto_depIdxs, + MessageInfos: file_aiscan_types_reload_proto_msgTypes, + }.Build() + File_aiscan_types_reload_proto = out.File + file_aiscan_types_reload_proto_rawDesc = nil + file_aiscan_types_reload_proto_goTypes = nil + file_aiscan_types_reload_proto_depIdxs = nil +} diff --git a/aop/aiscan/scan/scan.pb.go b/pkg/types/scan/scan.pb.go similarity index 59% rename from aop/aiscan/scan/scan.pb.go rename to pkg/types/scan/scan.pb.go index 462e05ec..d7393793 100644 --- a/aop/aiscan/scan/scan.pb.go +++ b/pkg/types/scan/scan.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.34.1 // protoc v6.33.0 -// source: aiscan/scan/scan.proto +// source: aiscan/types/scan.proto package scan @@ -64,11 +64,11 @@ func (x ScanStatus) String() string { } func (ScanStatus) Descriptor() protoreflect.EnumDescriptor { - return file_aiscan_scan_scan_proto_enumTypes[0].Descriptor() + return file_aiscan_types_scan_proto_enumTypes[0].Descriptor() } func (ScanStatus) Type() protoreflect.EnumType { - return &file_aiscan_scan_scan_proto_enumTypes[0] + return &file_aiscan_types_scan_proto_enumTypes[0] } func (x ScanStatus) Number() protoreflect.EnumNumber { @@ -77,7 +77,7 @@ func (x ScanStatus) Number() protoreflect.EnumNumber { // Deprecated: Use ScanStatus.Descriptor instead. func (ScanStatus) EnumDescriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{0} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{0} } type ScanOptions struct { @@ -93,7 +93,7 @@ type ScanOptions struct { func (x *ScanOptions) Reset() { *x = ScanOptions{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[0] + mi := &file_aiscan_types_scan_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -106,7 +106,7 @@ func (x *ScanOptions) String() string { func (*ScanOptions) ProtoMessage() {} func (x *ScanOptions) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[0] + mi := &file_aiscan_types_scan_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -119,7 +119,7 @@ func (x *ScanOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use ScanOptions.ProtoReflect.Descriptor instead. func (*ScanOptions) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{0} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{0} } func (x *ScanOptions) GetVerify() bool { @@ -155,7 +155,6 @@ type Scan struct { Status ScanStatus `protobuf:"varint,5,opt,name=status,proto3,enum=aiscan.scan.ScanStatus" json:"status,omitempty"` Progress string `protobuf:"bytes,6,opt,name=progress,proto3" json:"progress,omitempty"` Report string `protobuf:"bytes,7,opt,name=report,proto3" json:"report,omitempty"` - Result *aop.EncodedValue `protobuf:"bytes,8,opt,name=result,proto3" json:"result,omitempty"` Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` CreatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` @@ -164,7 +163,7 @@ type Scan struct { func (x *Scan) Reset() { *x = Scan{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[1] + mi := &file_aiscan_types_scan_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -177,7 +176,7 @@ func (x *Scan) String() string { func (*Scan) ProtoMessage() {} func (x *Scan) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[1] + mi := &file_aiscan_types_scan_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -190,7 +189,7 @@ func (x *Scan) ProtoReflect() protoreflect.Message { // Deprecated: Use Scan.ProtoReflect.Descriptor instead. func (*Scan) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{1} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{1} } func (x *Scan) GetId() string { @@ -242,13 +241,6 @@ func (x *Scan) GetReport() string { return "" } -func (x *Scan) GetResult() *aop.EncodedValue { - if x != nil { - return x.Result - } - return nil -} - func (x *Scan) GetError() string { if x != nil { return x.Error @@ -284,7 +276,7 @@ type SubmitScanRequest struct { func (x *SubmitScanRequest) Reset() { *x = SubmitScanRequest{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[2] + mi := &file_aiscan_types_scan_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -297,7 +289,7 @@ func (x *SubmitScanRequest) String() string { func (*SubmitScanRequest) ProtoMessage() {} func (x *SubmitScanRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[2] + mi := &file_aiscan_types_scan_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -310,7 +302,7 @@ func (x *SubmitScanRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitScanRequest.ProtoReflect.Descriptor instead. func (*SubmitScanRequest) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{2} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{2} } func (x *SubmitScanRequest) GetRequestId() string { @@ -357,7 +349,7 @@ type SubmitScanResponse struct { func (x *SubmitScanResponse) Reset() { *x = SubmitScanResponse{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[3] + mi := &file_aiscan_types_scan_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -370,7 +362,7 @@ func (x *SubmitScanResponse) String() string { func (*SubmitScanResponse) ProtoMessage() {} func (x *SubmitScanResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[3] + mi := &file_aiscan_types_scan_proto_msgTypes[3] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -383,7 +375,7 @@ func (x *SubmitScanResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitScanResponse.ProtoReflect.Descriptor instead. func (*SubmitScanResponse) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{3} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{3} } func (x *SubmitScanResponse) GetRequestId() string { @@ -441,7 +433,7 @@ type GetScanRequest struct { func (x *GetScanRequest) Reset() { *x = GetScanRequest{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[4] + mi := &file_aiscan_types_scan_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -454,7 +446,7 @@ func (x *GetScanRequest) String() string { func (*GetScanRequest) ProtoMessage() {} func (x *GetScanRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[4] + mi := &file_aiscan_types_scan_proto_msgTypes[4] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -467,7 +459,7 @@ func (x *GetScanRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScanRequest.ProtoReflect.Descriptor instead. func (*GetScanRequest) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{4} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{4} } func (x *GetScanRequest) GetScanId() string { @@ -488,7 +480,7 @@ type GetScanResponse struct { func (x *GetScanResponse) Reset() { *x = GetScanResponse{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[5] + mi := &file_aiscan_types_scan_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -501,7 +493,7 @@ func (x *GetScanResponse) String() string { func (*GetScanResponse) ProtoMessage() {} func (x *GetScanResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[5] + mi := &file_aiscan_types_scan_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -514,7 +506,7 @@ func (x *GetScanResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScanResponse.ProtoReflect.Descriptor instead. func (*GetScanResponse) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{5} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{5} } func (x *GetScanResponse) GetScan() *Scan { @@ -533,7 +525,7 @@ type ListScansRequest struct { func (x *ListScansRequest) Reset() { *x = ListScansRequest{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[6] + mi := &file_aiscan_types_scan_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -546,7 +538,7 @@ func (x *ListScansRequest) String() string { func (*ListScansRequest) ProtoMessage() {} func (x *ListScansRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[6] + mi := &file_aiscan_types_scan_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -559,7 +551,7 @@ func (x *ListScansRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListScansRequest.ProtoReflect.Descriptor instead. func (*ListScansRequest) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{6} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{6} } type ListScansResponse struct { @@ -573,7 +565,7 @@ type ListScansResponse struct { func (x *ListScansResponse) Reset() { *x = ListScansResponse{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[7] + mi := &file_aiscan_types_scan_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -586,7 +578,7 @@ func (x *ListScansResponse) String() string { func (*ListScansResponse) ProtoMessage() {} func (x *ListScansResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[7] + mi := &file_aiscan_types_scan_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -599,7 +591,7 @@ func (x *ListScansResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListScansResponse.ProtoReflect.Descriptor instead. func (*ListScansResponse) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{7} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{7} } func (x *ListScansResponse) GetScans() []*Scan { @@ -621,7 +613,7 @@ type CancelScanRequest struct { func (x *CancelScanRequest) Reset() { *x = CancelScanRequest{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[8] + mi := &file_aiscan_types_scan_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -634,7 +626,7 @@ func (x *CancelScanRequest) String() string { func (*CancelScanRequest) ProtoMessage() {} func (x *CancelScanRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[8] + mi := &file_aiscan_types_scan_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -647,7 +639,7 @@ func (x *CancelScanRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelScanRequest.ProtoReflect.Descriptor instead. func (*CancelScanRequest) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{8} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{8} } func (x *CancelScanRequest) GetRequestId() string { @@ -680,7 +672,7 @@ type CancelScanResponse struct { func (x *CancelScanResponse) Reset() { *x = CancelScanResponse{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[9] + mi := &file_aiscan_types_scan_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -693,7 +685,7 @@ func (x *CancelScanResponse) String() string { func (*CancelScanResponse) ProtoMessage() {} func (x *CancelScanResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[9] + mi := &file_aiscan_types_scan_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -706,7 +698,7 @@ func (x *CancelScanResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelScanResponse.ProtoReflect.Descriptor instead. func (*CancelScanResponse) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{9} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{9} } func (x *CancelScanResponse) GetRequestId() string { @@ -764,7 +756,7 @@ type WatchScanEventsRequest struct { func (x *WatchScanEventsRequest) Reset() { *x = WatchScanEventsRequest{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[10] + mi := &file_aiscan_types_scan_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -777,7 +769,7 @@ func (x *WatchScanEventsRequest) String() string { func (*WatchScanEventsRequest) ProtoMessage() {} func (x *WatchScanEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[10] + mi := &file_aiscan_types_scan_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -790,7 +782,7 @@ func (x *WatchScanEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchScanEventsRequest.ProtoReflect.Descriptor instead. func (*WatchScanEventsRequest) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{10} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{10} } func (x *WatchScanEventsRequest) GetScanId() string { @@ -811,7 +803,7 @@ type ScanProgress struct { func (x *ScanProgress) Reset() { *x = ScanProgress{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[11] + mi := &file_aiscan_types_scan_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -824,7 +816,7 @@ func (x *ScanProgress) String() string { func (*ScanProgress) ProtoMessage() {} func (x *ScanProgress) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[11] + mi := &file_aiscan_types_scan_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -837,7 +829,7 @@ func (x *ScanProgress) ProtoReflect() protoreflect.Message { // Deprecated: Use ScanProgress.ProtoReflect.Descriptor instead. func (*ScanProgress) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{11} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{11} } func (x *ScanProgress) GetData() string { @@ -858,7 +850,7 @@ type ScanStats struct { func (x *ScanStats) Reset() { *x = ScanStats{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[12] + mi := &file_aiscan_types_scan_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -871,7 +863,7 @@ func (x *ScanStats) String() string { func (*ScanStats) ProtoMessage() {} func (x *ScanStats) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[12] + mi := &file_aiscan_types_scan_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -884,7 +876,7 @@ func (x *ScanStats) ProtoReflect() protoreflect.Message { // Deprecated: Use ScanStats.ProtoReflect.Descriptor instead. func (*ScanStats) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{12} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{12} } func (x *ScanStats) GetValues() map[string]uint64 { @@ -898,14 +890,12 @@ type ScanCompleted struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - - Result *aop.EncodedValue `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` } func (x *ScanCompleted) Reset() { *x = ScanCompleted{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[13] + mi := &file_aiscan_types_scan_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -918,7 +908,7 @@ func (x *ScanCompleted) String() string { func (*ScanCompleted) ProtoMessage() {} func (x *ScanCompleted) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[13] + mi := &file_aiscan_types_scan_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -931,14 +921,7 @@ func (x *ScanCompleted) ProtoReflect() protoreflect.Message { // Deprecated: Use ScanCompleted.ProtoReflect.Descriptor instead. func (*ScanCompleted) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{13} -} - -func (x *ScanCompleted) GetResult() *aop.EncodedValue { - if x != nil { - return x.Result - } - return nil + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{13} } type ScanFailed struct { @@ -953,7 +936,7 @@ type ScanFailed struct { func (x *ScanFailed) Reset() { *x = ScanFailed{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[14] + mi := &file_aiscan_types_scan_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -966,7 +949,7 @@ func (x *ScanFailed) String() string { func (*ScanFailed) ProtoMessage() {} func (x *ScanFailed) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[14] + mi := &file_aiscan_types_scan_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -979,7 +962,7 @@ func (x *ScanFailed) ProtoReflect() protoreflect.Message { // Deprecated: Use ScanFailed.ProtoReflect.Descriptor instead. func (*ScanFailed) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{14} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{14} } func (x *ScanFailed) GetMessage() string { @@ -996,6 +979,54 @@ func (x *ScanFailed) GetCanceled() bool { return false } +// SessionBinding attaches an AIScan Scan to an AOP Session at open time. +type SessionBinding struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScanId string `protobuf:"bytes,1,opt,name=scan_id,json=scanId,proto3" json:"scan_id,omitempty"` +} + +func (x *SessionBinding) Reset() { + *x = SessionBinding{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_scan_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SessionBinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionBinding) ProtoMessage() {} + +func (x *SessionBinding) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_scan_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionBinding.ProtoReflect.Descriptor instead. +func (*SessionBinding) Descriptor() ([]byte, []int) { + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{15} +} + +func (x *SessionBinding) GetScanId() string { + if x != nil { + return x.ScanId + } + return "" +} + // SessionScanEvent links a completed scan into an AOP session timeline without // reintroducing a parallel web-only domain event envelope. type SessionScanEvent struct { @@ -1010,7 +1041,7 @@ type SessionScanEvent struct { func (x *SessionScanEvent) Reset() { *x = SessionScanEvent{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[15] + mi := &file_aiscan_types_scan_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1023,7 +1054,7 @@ func (x *SessionScanEvent) String() string { func (*SessionScanEvent) ProtoMessage() {} func (x *SessionScanEvent) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[15] + mi := &file_aiscan_types_scan_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1036,7 +1067,7 @@ func (x *SessionScanEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionScanEvent.ProtoReflect.Descriptor instead. func (*SessionScanEvent) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{15} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{16} } func (x *SessionScanEvent) GetScanId() string { @@ -1075,7 +1106,7 @@ type ScanEvent struct { func (x *ScanEvent) Reset() { *x = ScanEvent{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[16] + mi := &file_aiscan_types_scan_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1088,7 +1119,7 @@ func (x *ScanEvent) String() string { func (*ScanEvent) ProtoMessage() {} func (x *ScanEvent) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[16] + mi := &file_aiscan_types_scan_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1101,7 +1132,7 @@ func (x *ScanEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ScanEvent.ProtoReflect.Descriptor instead. func (*ScanEvent) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{16} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{17} } func (x *ScanEvent) GetScanId() string { @@ -1214,31 +1245,37 @@ func (*ScanEvent_Completed) isScanEvent_Payload() {} func (*ScanEvent_Failed) isScanEvent_Payload() {} -type WatchScanEventsResponse struct { +// ProtocolMessage carries AIScan scan runtime semantics over the shared AOP +// WebSocket. Scan management remains on ScanService. +type ProtocolMessage struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Event *ScanEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + // Types that are assignable to Message: + // + // *ProtocolMessage_WatchEventsRequest + // *ProtocolMessage_Event + Message isProtocolMessage_Message `protobuf_oneof:"message"` } -func (x *WatchScanEventsResponse) Reset() { - *x = WatchScanEventsResponse{} +func (x *ProtocolMessage) Reset() { + *x = ProtocolMessage{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[17] + mi := &file_aiscan_types_scan_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *WatchScanEventsResponse) String() string { +func (x *ProtocolMessage) String() string { return protoimpl.X.MessageStringOf(x) } -func (*WatchScanEventsResponse) ProtoMessage() {} +func (*ProtocolMessage) ProtoMessage() {} -func (x *WatchScanEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[17] +func (x *ProtocolMessage) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_scan_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1249,18 +1286,48 @@ func (x *WatchScanEventsResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use WatchScanEventsResponse.ProtoReflect.Descriptor instead. -func (*WatchScanEventsResponse) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{17} +// Deprecated: Use ProtocolMessage.ProtoReflect.Descriptor instead. +func (*ProtocolMessage) Descriptor() ([]byte, []int) { + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{18} } -func (x *WatchScanEventsResponse) GetEvent() *ScanEvent { - if x != nil { +func (m *ProtocolMessage) GetMessage() isProtocolMessage_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *ProtocolMessage) GetWatchEventsRequest() *WatchScanEventsRequest { + if x, ok := x.GetMessage().(*ProtocolMessage_WatchEventsRequest); ok { + return x.WatchEventsRequest + } + return nil +} + +func (x *ProtocolMessage) GetEvent() *ScanEvent { + if x, ok := x.GetMessage().(*ProtocolMessage_Event); ok { return x.Event } return nil } +type isProtocolMessage_Message interface { + isProtocolMessage_Message() +} + +type ProtocolMessage_WatchEventsRequest struct { + WatchEventsRequest *WatchScanEventsRequest `protobuf:"bytes,10,opt,name=watch_events_request,json=watchEventsRequest,proto3,oneof"` +} + +type ProtocolMessage_Event struct { + Event *ScanEvent `protobuf:"bytes,11,opt,name=event,proto3,oneof"` +} + +func (*ProtocolMessage_WatchEventsRequest) isProtocolMessage_Message() {} + +func (*ProtocolMessage_Event) isProtocolMessage_Message() {} + type GetScanReportRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1273,7 +1340,7 @@ type GetScanReportRequest struct { func (x *GetScanReportRequest) Reset() { *x = GetScanReportRequest{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[18] + mi := &file_aiscan_types_scan_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1286,7 +1353,7 @@ func (x *GetScanReportRequest) String() string { func (*GetScanReportRequest) ProtoMessage() {} func (x *GetScanReportRequest) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[18] + mi := &file_aiscan_types_scan_proto_msgTypes[19] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1299,7 +1366,7 @@ func (x *GetScanReportRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScanReportRequest.ProtoReflect.Descriptor instead. func (*GetScanReportRequest) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{18} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{19} } func (x *GetScanReportRequest) GetScanId() string { @@ -1328,7 +1395,7 @@ type GetScanReportResponse struct { func (x *GetScanReportResponse) Reset() { *x = GetScanReportResponse{} if protoimpl.UnsafeEnabled { - mi := &file_aiscan_scan_scan_proto_msgTypes[19] + mi := &file_aiscan_types_scan_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1341,7 +1408,7 @@ func (x *GetScanReportResponse) String() string { func (*GetScanReportResponse) ProtoMessage() {} func (x *GetScanReportResponse) ProtoReflect() protoreflect.Message { - mi := &file_aiscan_scan_scan_proto_msgTypes[19] + mi := &file_aiscan_types_scan_proto_msgTypes[20] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1354,7 +1421,7 @@ func (x *GetScanReportResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetScanReportResponse.ProtoReflect.Descriptor instead. func (*GetScanReportResponse) Descriptor() ([]byte, []int) { - return file_aiscan_scan_scan_proto_rawDescGZIP(), []int{19} + return file_aiscan_types_scan_proto_rawDescGZIP(), []int{20} } func (x *GetScanReportResponse) GetMarkdown() string { @@ -1371,13 +1438,12 @@ func (x *GetScanReportResponse) GetMediaType() string { return "" } -var File_aiscan_scan_scan_proto protoreflect.FileDescriptor +var File_aiscan_types_scan_proto protoreflect.FileDescriptor -var file_aiscan_scan_scan_proto_rawDesc = []byte{ - 0x0a, 0x16, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x0f, 0x61, 0x6f, 0x70, 0x2f, 0x76, 0x61, 0x6c, 0x75, 0x65, +var file_aiscan_types_scan_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x1a, 0x0e, 0x61, 0x6f, 0x70, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x51, 0x0a, 0x0b, 0x53, 0x63, 0x61, 0x6e, 0x4f, @@ -1385,7 +1451,7 @@ var file_aiscan_scan_scan_proto_rawDesc = []byte{ 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6e, 0x69, 0x70, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x6e, 0x69, 0x70, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x65, 0x70, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x65, 0x70, 0x22, 0x92, 0x03, 0x0a, 0x04, 0x53, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x65, 0x70, 0x22, 0xed, 0x02, 0x0a, 0x04, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6d, @@ -1399,274 +1465,233 @@ var file_aiscan_scan_scan_proto_rawDesc = []byte{ 0x61, 0x74, 0x75, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, - 0x6e, 0x63, 0x6f, 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x72, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x52, 0x06, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x39, + 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, + 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x64, 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, - 0x61, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, - 0x92, 0x01, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, - 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, - 0x12, 0x32, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x18, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x53, 0x63, 0x61, 0x6e, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x9d, 0x01, 0x0a, 0x12, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, - 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x08, 0x61, 0x63, - 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, - 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x48, - 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, - 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, - 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, - 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, - 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x29, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, - 0x38, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x73, 0x63, 0x61, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, - 0x63, 0x61, 0x6e, 0x52, 0x04, 0x73, 0x63, 0x61, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x4c, 0x69, 0x73, - 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x3c, 0x0a, - 0x11, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x73, 0x63, 0x61, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x53, 0x63, 0x61, 0x6e, 0x52, 0x05, 0x73, 0x63, 0x61, 0x6e, 0x73, 0x22, 0x4b, 0x0a, 0x11, 0x43, - 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x74, 0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, 0x22, 0x92, 0x01, 0x0a, 0x11, 0x53, + 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, - 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, 0x9d, 0x01, 0x0a, 0x12, 0x43, 0x61, 0x6e, - 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2f, - 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, - 0x63, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, - 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, - 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x31, 0x0a, 0x16, 0x57, 0x61, 0x74, 0x63, - 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, 0x22, 0x0a, 0x0c, 0x53, - 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x64, - 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, - 0x82, 0x01, 0x0a, 0x09, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x3a, 0x0a, - 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, - 0x53, 0x74, 0x61, 0x74, 0x73, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x56, 0x61, 0x6c, - 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3a, 0x0a, 0x0d, 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x45, 0x6e, 0x63, 0x6f, - 0x64, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6f, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x4f, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, + 0x9d, 0x01, 0x0a, 0x12, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x52, + 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, + 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, + 0x29, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, 0x38, 0x0a, 0x0f, 0x47, 0x65, + 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, + 0x04, 0x73, 0x63, 0x61, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x04, + 0x73, 0x63, 0x61, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x61, 0x6e, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x3c, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, + 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, + 0x05, 0x73, 0x63, 0x61, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x52, + 0x05, 0x73, 0x63, 0x61, 0x6e, 0x73, 0x22, 0x4b, 0x0a, 0x11, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, + 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, + 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, + 0x6e, 0x49, 0x64, 0x22, 0x9d, 0x01, 0x0a, 0x12, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, + 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x08, 0x61, 0x63, 0x63, + 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x48, 0x00, + 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x2c, 0x0a, 0x08, 0x72, 0x65, + 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, + 0x6f, 0x70, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x08, + 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, + 0x6f, 0x6d, 0x65, 0x22, 0x31, 0x0a, 0x16, 0x57, 0x61, 0x74, 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, + 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, 0x22, 0x0a, 0x0c, 0x53, 0x63, 0x61, 0x6e, 0x50, 0x72, + 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x82, 0x01, 0x0a, 0x09, 0x53, + 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x73, + 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0x0f, 0x0a, 0x0d, 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x42, 0x0a, 0x0a, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x61, 0x6e, 0x63, - 0x65, 0x6c, 0x65, 0x64, 0x22, 0x5c, 0x0a, 0x10, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, - 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, - 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x22, 0xc2, 0x03, 0x0a, 0x09, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x71, - 0x75, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x08, 0x73, 0x65, 0x71, - 0x75, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, - 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x41, 0x74, - 0x12, 0x2f, 0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, - 0x74, 0x12, 0x31, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x37, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, - 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, - 0x73, 0x48, 0x00, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2e, 0x0a, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, + 0x65, 0x6c, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x42, + 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x22, + 0x5c, 0x0a, 0x10, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x61, + 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xc2, 0x03, + 0x0a, 0x09, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, + 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, + 0x61, 0x6e, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x65, + 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x52, 0x09, 0x65, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x2f, 0x0a, 0x08, 0x73, + 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, + 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, + 0x48, 0x00, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, 0x31, 0x0a, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x53, - 0x74, 0x61, 0x74, 0x73, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x12, 0x3a, 0x0a, - 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1a, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, - 0x63, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x09, - 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x06, 0x66, 0x61, 0x69, - 0x6c, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x61, 0x69, 0x6c, - 0x65, 0x64, 0x48, 0x00, 0x52, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, - 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x47, 0x0a, 0x17, 0x57, 0x61, 0x74, 0x63, 0x68, - 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, - 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, - 0x22, 0x4b, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, - 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x22, 0x52, 0x0a, - 0x15, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x61, 0x72, 0x6b, 0x64, 0x6f, - 0x77, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x61, 0x72, 0x6b, 0x64, 0x6f, - 0x77, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, - 0x65, 0x2a, 0xa7, 0x01, 0x0a, 0x0a, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x12, 0x1b, 0x0a, 0x17, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, - 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x16, 0x0a, - 0x12, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x51, 0x55, 0x45, - 0x55, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, - 0x41, 0x54, 0x55, 0x53, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x19, - 0x0a, 0x15, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x4f, - 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x43, 0x41, - 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, - 0x04, 0x12, 0x18, 0x0a, 0x14, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, - 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x32, 0xf5, 0x03, 0x0a, 0x0b, - 0x53, 0x63, 0x61, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4d, 0x0a, 0x0a, 0x53, - 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, - 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, 0x63, - 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x53, 0x63, - 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x07, 0x47, 0x65, - 0x74, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x1b, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, - 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x4a, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x73, 0x12, 0x1d, 0x2e, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, - 0x53, 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, - 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, - 0x63, 0x61, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x0a, - 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x63, 0x61, 0x6e, 0x12, 0x1e, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, - 0x63, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x69, 0x73, - 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, - 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0f, 0x57, - 0x61, 0x74, 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x23, - 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x57, 0x61, 0x74, - 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, - 0x6e, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x56, 0x0a, 0x0d, 0x47, - 0x65, 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x21, 0x2e, 0x61, - 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x63, - 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x22, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x47, 0x65, - 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, - 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x61, 0x6f, 0x70, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, - 0x6e, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x3b, 0x73, 0x63, 0x61, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x37, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x08, + 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2e, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, + 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x48, + 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x09, 0x63, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x69, + 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x43, 0x6f, + 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6c, + 0x65, 0x74, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x18, 0x0f, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x48, 0x00, 0x52, + 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, + 0x61, 0x64, 0x22, 0xa5, 0x01, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x57, 0x0a, 0x14, 0x77, 0x61, 0x74, 0x63, 0x68, 0x5f, + 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x0a, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, + 0x61, 0x6e, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x53, 0x63, 0x61, 0x6e, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x12, 0x77, 0x61, 0x74, + 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x2e, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, + 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x53, 0x63, 0x61, + 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x42, + 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x4b, 0x0a, 0x14, 0x47, 0x65, + 0x74, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x61, 0x6e, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6c, + 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, + 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x22, 0x52, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x53, 0x63, + 0x61, 0x6e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x61, 0x72, 0x6b, 0x64, 0x6f, 0x77, 0x6e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x6d, 0x61, 0x72, 0x6b, 0x64, 0x6f, 0x77, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, + 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x2a, 0xa7, 0x01, 0x0a, 0x0a, + 0x53, 0x63, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1b, 0x0a, 0x17, 0x53, 0x43, + 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x43, 0x41, 0x4e, 0x5f, + 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x51, 0x55, 0x45, 0x55, 0x45, 0x44, 0x10, 0x01, 0x12, + 0x17, 0x0a, 0x13, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x52, + 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x19, 0x0a, 0x15, 0x53, 0x43, 0x41, 0x4e, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x4f, 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, + 0x44, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, + 0x55, 0x53, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x18, 0x0a, 0x14, 0x53, + 0x43, 0x41, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, + 0x4c, 0x45, 0x44, 0x10, 0x05, 0x42, 0x35, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, + 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x79, 0x70, + 0x65, 0x73, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x3b, 0x73, 0x63, 0x61, 0x6e, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, } var ( - file_aiscan_scan_scan_proto_rawDescOnce sync.Once - file_aiscan_scan_scan_proto_rawDescData = file_aiscan_scan_scan_proto_rawDesc + file_aiscan_types_scan_proto_rawDescOnce sync.Once + file_aiscan_types_scan_proto_rawDescData = file_aiscan_types_scan_proto_rawDesc ) -func file_aiscan_scan_scan_proto_rawDescGZIP() []byte { - file_aiscan_scan_scan_proto_rawDescOnce.Do(func() { - file_aiscan_scan_scan_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_scan_scan_proto_rawDescData) +func file_aiscan_types_scan_proto_rawDescGZIP() []byte { + file_aiscan_types_scan_proto_rawDescOnce.Do(func() { + file_aiscan_types_scan_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_scan_proto_rawDescData) }) - return file_aiscan_scan_scan_proto_rawDescData -} - -var file_aiscan_scan_scan_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_aiscan_scan_scan_proto_msgTypes = make([]protoimpl.MessageInfo, 21) -var file_aiscan_scan_scan_proto_goTypes = []interface{}{ - (ScanStatus)(0), // 0: aiscan.scan.ScanStatus - (*ScanOptions)(nil), // 1: aiscan.scan.ScanOptions - (*Scan)(nil), // 2: aiscan.scan.Scan - (*SubmitScanRequest)(nil), // 3: aiscan.scan.SubmitScanRequest - (*SubmitScanResponse)(nil), // 4: aiscan.scan.SubmitScanResponse - (*GetScanRequest)(nil), // 5: aiscan.scan.GetScanRequest - (*GetScanResponse)(nil), // 6: aiscan.scan.GetScanResponse - (*ListScansRequest)(nil), // 7: aiscan.scan.ListScansRequest - (*ListScansResponse)(nil), // 8: aiscan.scan.ListScansResponse - (*CancelScanRequest)(nil), // 9: aiscan.scan.CancelScanRequest - (*CancelScanResponse)(nil), // 10: aiscan.scan.CancelScanResponse - (*WatchScanEventsRequest)(nil), // 11: aiscan.scan.WatchScanEventsRequest - (*ScanProgress)(nil), // 12: aiscan.scan.ScanProgress - (*ScanStats)(nil), // 13: aiscan.scan.ScanStats - (*ScanCompleted)(nil), // 14: aiscan.scan.ScanCompleted - (*ScanFailed)(nil), // 15: aiscan.scan.ScanFailed - (*SessionScanEvent)(nil), // 16: aiscan.scan.SessionScanEvent - (*ScanEvent)(nil), // 17: aiscan.scan.ScanEvent - (*WatchScanEventsResponse)(nil), // 18: aiscan.scan.WatchScanEventsResponse - (*GetScanReportRequest)(nil), // 19: aiscan.scan.GetScanReportRequest - (*GetScanReportResponse)(nil), // 20: aiscan.scan.GetScanReportResponse - nil, // 21: aiscan.scan.ScanStats.ValuesEntry - (*aop.EncodedValue)(nil), // 22: aop.EncodedValue - (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp - (*aop.Rejection)(nil), // 24: aop.Rejection -} -var file_aiscan_scan_scan_proto_depIdxs = []int32{ + return file_aiscan_types_scan_proto_rawDescData +} + +var file_aiscan_types_scan_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_aiscan_types_scan_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_aiscan_types_scan_proto_goTypes = []interface{}{ + (ScanStatus)(0), // 0: aiscan.scan.ScanStatus + (*ScanOptions)(nil), // 1: aiscan.scan.ScanOptions + (*Scan)(nil), // 2: aiscan.scan.Scan + (*SubmitScanRequest)(nil), // 3: aiscan.scan.SubmitScanRequest + (*SubmitScanResponse)(nil), // 4: aiscan.scan.SubmitScanResponse + (*GetScanRequest)(nil), // 5: aiscan.scan.GetScanRequest + (*GetScanResponse)(nil), // 6: aiscan.scan.GetScanResponse + (*ListScansRequest)(nil), // 7: aiscan.scan.ListScansRequest + (*ListScansResponse)(nil), // 8: aiscan.scan.ListScansResponse + (*CancelScanRequest)(nil), // 9: aiscan.scan.CancelScanRequest + (*CancelScanResponse)(nil), // 10: aiscan.scan.CancelScanResponse + (*WatchScanEventsRequest)(nil), // 11: aiscan.scan.WatchScanEventsRequest + (*ScanProgress)(nil), // 12: aiscan.scan.ScanProgress + (*ScanStats)(nil), // 13: aiscan.scan.ScanStats + (*ScanCompleted)(nil), // 14: aiscan.scan.ScanCompleted + (*ScanFailed)(nil), // 15: aiscan.scan.ScanFailed + (*SessionBinding)(nil), // 16: aiscan.scan.SessionBinding + (*SessionScanEvent)(nil), // 17: aiscan.scan.SessionScanEvent + (*ScanEvent)(nil), // 18: aiscan.scan.ScanEvent + (*ProtocolMessage)(nil), // 19: aiscan.scan.ProtocolMessage + (*GetScanReportRequest)(nil), // 20: aiscan.scan.GetScanReportRequest + (*GetScanReportResponse)(nil), // 21: aiscan.scan.GetScanReportResponse + nil, // 22: aiscan.scan.ScanStats.ValuesEntry + (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp + (*aop.Rejection)(nil), // 24: aop.Rejection +} +var file_aiscan_types_scan_proto_depIdxs = []int32{ 1, // 0: aiscan.scan.Scan.options:type_name -> aiscan.scan.ScanOptions 0, // 1: aiscan.scan.Scan.status:type_name -> aiscan.scan.ScanStatus - 22, // 2: aiscan.scan.Scan.result:type_name -> aop.EncodedValue - 23, // 3: aiscan.scan.Scan.created_at:type_name -> google.protobuf.Timestamp - 23, // 4: aiscan.scan.Scan.updated_at:type_name -> google.protobuf.Timestamp - 1, // 5: aiscan.scan.SubmitScanRequest.options:type_name -> aiscan.scan.ScanOptions - 2, // 6: aiscan.scan.SubmitScanResponse.accepted:type_name -> aiscan.scan.Scan - 24, // 7: aiscan.scan.SubmitScanResponse.rejected:type_name -> aop.Rejection - 2, // 8: aiscan.scan.GetScanResponse.scan:type_name -> aiscan.scan.Scan - 2, // 9: aiscan.scan.ListScansResponse.scans:type_name -> aiscan.scan.Scan - 2, // 10: aiscan.scan.CancelScanResponse.accepted:type_name -> aiscan.scan.Scan - 24, // 11: aiscan.scan.CancelScanResponse.rejected:type_name -> aop.Rejection - 21, // 12: aiscan.scan.ScanStats.values:type_name -> aiscan.scan.ScanStats.ValuesEntry - 22, // 13: aiscan.scan.ScanCompleted.result:type_name -> aop.EncodedValue - 0, // 14: aiscan.scan.SessionScanEvent.status:type_name -> aiscan.scan.ScanStatus - 23, // 15: aiscan.scan.ScanEvent.emitted_at:type_name -> google.protobuf.Timestamp - 2, // 16: aiscan.scan.ScanEvent.snapshot:type_name -> aiscan.scan.Scan - 0, // 17: aiscan.scan.ScanEvent.status:type_name -> aiscan.scan.ScanStatus - 12, // 18: aiscan.scan.ScanEvent.progress:type_name -> aiscan.scan.ScanProgress - 13, // 19: aiscan.scan.ScanEvent.stats:type_name -> aiscan.scan.ScanStats - 14, // 20: aiscan.scan.ScanEvent.completed:type_name -> aiscan.scan.ScanCompleted - 15, // 21: aiscan.scan.ScanEvent.failed:type_name -> aiscan.scan.ScanFailed - 17, // 22: aiscan.scan.WatchScanEventsResponse.event:type_name -> aiscan.scan.ScanEvent - 3, // 23: aiscan.scan.ScanService.SubmitScan:input_type -> aiscan.scan.SubmitScanRequest - 5, // 24: aiscan.scan.ScanService.GetScan:input_type -> aiscan.scan.GetScanRequest - 7, // 25: aiscan.scan.ScanService.ListScans:input_type -> aiscan.scan.ListScansRequest - 9, // 26: aiscan.scan.ScanService.CancelScan:input_type -> aiscan.scan.CancelScanRequest - 11, // 27: aiscan.scan.ScanService.WatchScanEvents:input_type -> aiscan.scan.WatchScanEventsRequest - 19, // 28: aiscan.scan.ScanService.GetScanReport:input_type -> aiscan.scan.GetScanReportRequest - 4, // 29: aiscan.scan.ScanService.SubmitScan:output_type -> aiscan.scan.SubmitScanResponse - 6, // 30: aiscan.scan.ScanService.GetScan:output_type -> aiscan.scan.GetScanResponse - 8, // 31: aiscan.scan.ScanService.ListScans:output_type -> aiscan.scan.ListScansResponse - 10, // 32: aiscan.scan.ScanService.CancelScan:output_type -> aiscan.scan.CancelScanResponse - 18, // 33: aiscan.scan.ScanService.WatchScanEvents:output_type -> aiscan.scan.WatchScanEventsResponse - 20, // 34: aiscan.scan.ScanService.GetScanReport:output_type -> aiscan.scan.GetScanReportResponse - 29, // [29:35] is the sub-list for method output_type - 23, // [23:29] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name -} - -func init() { file_aiscan_scan_scan_proto_init() } -func file_aiscan_scan_scan_proto_init() { - if File_aiscan_scan_scan_proto != nil { + 23, // 2: aiscan.scan.Scan.created_at:type_name -> google.protobuf.Timestamp + 23, // 3: aiscan.scan.Scan.updated_at:type_name -> google.protobuf.Timestamp + 1, // 4: aiscan.scan.SubmitScanRequest.options:type_name -> aiscan.scan.ScanOptions + 2, // 5: aiscan.scan.SubmitScanResponse.accepted:type_name -> aiscan.scan.Scan + 24, // 6: aiscan.scan.SubmitScanResponse.rejected:type_name -> aop.Rejection + 2, // 7: aiscan.scan.GetScanResponse.scan:type_name -> aiscan.scan.Scan + 2, // 8: aiscan.scan.ListScansResponse.scans:type_name -> aiscan.scan.Scan + 2, // 9: aiscan.scan.CancelScanResponse.accepted:type_name -> aiscan.scan.Scan + 24, // 10: aiscan.scan.CancelScanResponse.rejected:type_name -> aop.Rejection + 22, // 11: aiscan.scan.ScanStats.values:type_name -> aiscan.scan.ScanStats.ValuesEntry + 0, // 12: aiscan.scan.SessionScanEvent.status:type_name -> aiscan.scan.ScanStatus + 23, // 13: aiscan.scan.ScanEvent.emitted_at:type_name -> google.protobuf.Timestamp + 2, // 14: aiscan.scan.ScanEvent.snapshot:type_name -> aiscan.scan.Scan + 0, // 15: aiscan.scan.ScanEvent.status:type_name -> aiscan.scan.ScanStatus + 12, // 16: aiscan.scan.ScanEvent.progress:type_name -> aiscan.scan.ScanProgress + 13, // 17: aiscan.scan.ScanEvent.stats:type_name -> aiscan.scan.ScanStats + 14, // 18: aiscan.scan.ScanEvent.completed:type_name -> aiscan.scan.ScanCompleted + 15, // 19: aiscan.scan.ScanEvent.failed:type_name -> aiscan.scan.ScanFailed + 11, // 20: aiscan.scan.ProtocolMessage.watch_events_request:type_name -> aiscan.scan.WatchScanEventsRequest + 18, // 21: aiscan.scan.ProtocolMessage.event:type_name -> aiscan.scan.ScanEvent + 22, // [22:22] is the sub-list for method output_type + 22, // [22:22] is the sub-list for method input_type + 22, // [22:22] is the sub-list for extension type_name + 22, // [22:22] is the sub-list for extension extendee + 0, // [0:22] is the sub-list for field type_name +} + +func init() { file_aiscan_types_scan_proto_init() } +func file_aiscan_types_scan_proto_init() { + if File_aiscan_types_scan_proto != nil { return } if !protoimpl.UnsafeEnabled { - file_aiscan_scan_scan_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ScanOptions); i { case 0: return &v.state @@ -1678,7 +1703,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Scan); i { case 0: return &v.state @@ -1690,7 +1715,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SubmitScanRequest); i { case 0: return &v.state @@ -1702,7 +1727,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SubmitScanResponse); i { case 0: return &v.state @@ -1714,7 +1739,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetScanRequest); i { case 0: return &v.state @@ -1726,7 +1751,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetScanResponse); i { case 0: return &v.state @@ -1738,7 +1763,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ListScansRequest); i { case 0: return &v.state @@ -1750,7 +1775,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ListScansResponse); i { case 0: return &v.state @@ -1762,7 +1787,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CancelScanRequest); i { case 0: return &v.state @@ -1774,7 +1799,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*CancelScanResponse); i { case 0: return &v.state @@ -1786,7 +1811,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*WatchScanEventsRequest); i { case 0: return &v.state @@ -1798,7 +1823,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ScanProgress); i { case 0: return &v.state @@ -1810,7 +1835,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ScanStats); i { case 0: return &v.state @@ -1822,7 +1847,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ScanCompleted); i { case 0: return &v.state @@ -1834,7 +1859,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ScanFailed); i { case 0: return &v.state @@ -1846,7 +1871,19 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SessionBinding); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_scan_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SessionScanEvent); i { case 0: return &v.state @@ -1858,7 +1895,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ScanEvent); i { case 0: return &v.state @@ -1870,8 +1907,8 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*WatchScanEventsResponse); i { + file_aiscan_types_scan_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtocolMessage); i { case 0: return &v.state case 1: @@ -1882,7 +1919,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetScanReportRequest); i { case 0: return &v.state @@ -1894,7 +1931,7 @@ func file_aiscan_scan_scan_proto_init() { return nil } } - file_aiscan_scan_scan_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + file_aiscan_types_scan_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetScanReportResponse); i { case 0: return &v.state @@ -1907,15 +1944,15 @@ func file_aiscan_scan_scan_proto_init() { } } } - file_aiscan_scan_scan_proto_msgTypes[3].OneofWrappers = []interface{}{ + file_aiscan_types_scan_proto_msgTypes[3].OneofWrappers = []interface{}{ (*SubmitScanResponse_Accepted)(nil), (*SubmitScanResponse_Rejected)(nil), } - file_aiscan_scan_scan_proto_msgTypes[9].OneofWrappers = []interface{}{ + file_aiscan_types_scan_proto_msgTypes[9].OneofWrappers = []interface{}{ (*CancelScanResponse_Accepted)(nil), (*CancelScanResponse_Rejected)(nil), } - file_aiscan_scan_scan_proto_msgTypes[16].OneofWrappers = []interface{}{ + file_aiscan_types_scan_proto_msgTypes[17].OneofWrappers = []interface{}{ (*ScanEvent_Snapshot)(nil), (*ScanEvent_Status)(nil), (*ScanEvent_Progress)(nil), @@ -1923,23 +1960,27 @@ func file_aiscan_scan_scan_proto_init() { (*ScanEvent_Completed)(nil), (*ScanEvent_Failed)(nil), } + file_aiscan_types_scan_proto_msgTypes[18].OneofWrappers = []interface{}{ + (*ProtocolMessage_WatchEventsRequest)(nil), + (*ProtocolMessage_Event)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_aiscan_scan_scan_proto_rawDesc, + RawDescriptor: file_aiscan_types_scan_proto_rawDesc, NumEnums: 1, - NumMessages: 21, + NumMessages: 22, NumExtensions: 0, - NumServices: 1, + NumServices: 0, }, - GoTypes: file_aiscan_scan_scan_proto_goTypes, - DependencyIndexes: file_aiscan_scan_scan_proto_depIdxs, - EnumInfos: file_aiscan_scan_scan_proto_enumTypes, - MessageInfos: file_aiscan_scan_scan_proto_msgTypes, + GoTypes: file_aiscan_types_scan_proto_goTypes, + DependencyIndexes: file_aiscan_types_scan_proto_depIdxs, + EnumInfos: file_aiscan_types_scan_proto_enumTypes, + MessageInfos: file_aiscan_types_scan_proto_msgTypes, }.Build() - File_aiscan_scan_scan_proto = out.File - file_aiscan_scan_scan_proto_rawDesc = nil - file_aiscan_scan_scan_proto_goTypes = nil - file_aiscan_scan_scan_proto_depIdxs = nil + File_aiscan_types_scan_proto = out.File + file_aiscan_types_scan_proto_rawDesc = nil + file_aiscan_types_scan_proto_goTypes = nil + file_aiscan_types_scan_proto_depIdxs = nil } diff --git a/pkg/types/sco/sco.pb.go b/pkg/types/sco/sco.pb.go new file mode 100644 index 00000000..ec664aa2 --- /dev/null +++ b/pkg/types/sco/sco.pb.go @@ -0,0 +1,888 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/types/sco.proto + +package sco + +import ( + sco "github.com/chainreactors/aiscan/aop/sco" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ListNodesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + OperationId string `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` + Limit uint32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` +} + +func (x *ListNodesRequest) Reset() { + *x = ListNodesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListNodesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNodesRequest) ProtoMessage() {} + +func (x *ListNodesRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNodesRequest.ProtoReflect.Descriptor instead. +func (*ListNodesRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{0} +} + +func (x *ListNodesRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ListNodesRequest) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *ListNodesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +type ListNodesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Nodes *sco.Nodes `protobuf:"bytes,1,opt,name=nodes,proto3" json:"nodes,omitempty"` +} + +func (x *ListNodesResponse) Reset() { + *x = ListNodesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListNodesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNodesResponse) ProtoMessage() {} + +func (x *ListNodesResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNodesResponse.ProtoReflect.Descriptor instead. +func (*ListNodesResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{1} +} + +func (x *ListNodesResponse) GetNodes() *sco.Nodes { + if x != nil { + return x.Nodes + } + return nil +} + +type GetNodeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *GetNodeRequest) Reset() { + *x = GetNodeRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetNodeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNodeRequest) ProtoMessage() {} + +func (x *GetNodeRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetNodeRequest.ProtoReflect.Descriptor instead. +func (*GetNodeRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{2} +} + +func (x *GetNodeRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetNodeResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Node []byte `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` + MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *GetNodeResponse) Reset() { + *x = GetNodeResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetNodeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNodeResponse) ProtoMessage() {} + +func (x *GetNodeResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetNodeResponse.ProtoReflect.Descriptor instead. +func (*GetNodeResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{3} +} + +func (x *GetNodeResponse) GetNode() []byte { + if x != nil { + return x.Node + } + return nil +} + +func (x *GetNodeResponse) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +type GetStatsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetStatsRequest) Reset() { + *x = GetStatsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatsRequest) ProtoMessage() {} + +func (x *GetStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatsRequest.ProtoReflect.Descriptor instead. +func (*GetStatsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{4} +} + +type GetStatsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Values map[string]uint64 `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` +} + +func (x *GetStatsResponse) Reset() { + *x = GetStatsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatsResponse) ProtoMessage() {} + +func (x *GetStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatsResponse.ProtoReflect.Descriptor instead. +func (*GetStatsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{5} +} + +func (x *GetStatsResponse) GetValues() map[string]uint64 { + if x != nil { + return x.Values + } + return nil +} + +type DeleteNodesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` +} + +func (x *DeleteNodesRequest) Reset() { + *x = DeleteNodesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteNodesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNodesRequest) ProtoMessage() {} + +func (x *DeleteNodesRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteNodesRequest.ProtoReflect.Descriptor instead. +func (*DeleteNodesRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{6} +} + +func (x *DeleteNodesRequest) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +type DeleteNodesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *DeleteNodesResponse) Reset() { + *x = DeleteNodesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteNodesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNodesResponse) ProtoMessage() {} + +func (x *DeleteNodesResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteNodesResponse.ProtoReflect.Descriptor instead. +func (*DeleteNodesResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{7} +} + +type ImportNodesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + Artifact string `protobuf:"bytes,2,opt,name=artifact,proto3" json:"artifact,omitempty"` + OperationId string `protobuf:"bytes,3,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` +} + +func (x *ImportNodesRequest) Reset() { + *x = ImportNodesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ImportNodesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportNodesRequest) ProtoMessage() {} + +func (x *ImportNodesRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ImportNodesRequest.ProtoReflect.Descriptor instead. +func (*ImportNodesRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{8} +} + +func (x *ImportNodesRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *ImportNodesRequest) GetArtifact() string { + if x != nil { + return x.Artifact + } + return "" +} + +func (x *ImportNodesRequest) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +type ImportNodesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Nodes uint64 `protobuf:"varint,1,opt,name=nodes,proto3" json:"nodes,omitempty"` + Duplicates uint64 `protobuf:"varint,2,opt,name=duplicates,proto3" json:"duplicates,omitempty"` + Artifact string `protobuf:"bytes,3,opt,name=artifact,proto3" json:"artifact,omitempty"` +} + +func (x *ImportNodesResponse) Reset() { + *x = ImportNodesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ImportNodesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportNodesResponse) ProtoMessage() {} + +func (x *ImportNodesResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ImportNodesResponse.ProtoReflect.Descriptor instead. +func (*ImportNodesResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{9} +} + +func (x *ImportNodesResponse) GetNodes() uint64 { + if x != nil { + return x.Nodes + } + return 0 +} + +func (x *ImportNodesResponse) GetDuplicates() uint64 { + if x != nil { + return x.Duplicates + } + return 0 +} + +func (x *ImportNodesResponse) GetArtifact() string { + if x != nil { + return x.Artifact + } + return "" +} + +type ListArtifactsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ListArtifactsRequest) Reset() { + *x = ListArtifactsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsRequest) ProtoMessage() {} + +func (x *ListArtifactsRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsRequest.ProtoReflect.Descriptor instead. +func (*ListArtifactsRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{10} +} + +type ListArtifactsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Artifacts []string `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"` +} + +func (x *ListArtifactsResponse) Reset() { + *x = ListArtifactsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_sco_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsResponse) ProtoMessage() {} + +func (x *ListArtifactsResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_sco_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsResponse.ProtoReflect.Descriptor instead. +func (*ListArtifactsResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_sco_proto_rawDescGZIP(), []int{11} +} + +func (x *ListArtifactsResponse) GetArtifacts() []string { + if x != nil { + return x.Artifacts + } + return nil +} + +var File_aiscan_types_sco_proto protoreflect.FileDescriptor + +var file_aiscan_types_sco_proto_rawDesc = []byte{ + 0x0a, 0x16, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x73, + 0x63, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, + 0x2e, 0x73, 0x63, 0x6f, 0x1a, 0x16, 0x61, 0x6f, 0x70, 0x2f, 0x73, 0x63, 0x6f, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x5f, 0x0a, 0x10, + 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x39, 0x0a, + 0x11, 0x4c, 0x69, 0x73, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x24, 0x0a, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x6f, 0x70, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x4e, 0x6f, 0x64, 0x65, + 0x73, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x22, 0x20, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x4e, + 0x6f, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x44, 0x0a, 0x0f, 0x47, 0x65, + 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x6e, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x6e, 0x6f, 0x64, + 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, + 0x22, 0x11, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x22, 0x8f, 0x01, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2e, 0x73, 0x63, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x37, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4e, + 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x6f, + 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x15, + 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x67, 0x0a, 0x12, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4e, + 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, + 0x1a, 0x0a, 0x08, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x6f, + 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x67, + 0x0a, 0x13, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x64, + 0x75, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x0a, 0x64, 0x75, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x61, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x41, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, + 0x35, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x72, 0x74, 0x69, + 0x66, 0x61, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x61, 0x72, 0x74, + 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, + 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x79, + 0x70, 0x65, 0x73, 0x2f, 0x73, 0x63, 0x6f, 0x3b, 0x73, 0x63, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} + +var ( + file_aiscan_types_sco_proto_rawDescOnce sync.Once + file_aiscan_types_sco_proto_rawDescData = file_aiscan_types_sco_proto_rawDesc +) + +func file_aiscan_types_sco_proto_rawDescGZIP() []byte { + file_aiscan_types_sco_proto_rawDescOnce.Do(func() { + file_aiscan_types_sco_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_sco_proto_rawDescData) + }) + return file_aiscan_types_sco_proto_rawDescData +} + +var file_aiscan_types_sco_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_aiscan_types_sco_proto_goTypes = []interface{}{ + (*ListNodesRequest)(nil), // 0: aiscan.sco.ListNodesRequest + (*ListNodesResponse)(nil), // 1: aiscan.sco.ListNodesResponse + (*GetNodeRequest)(nil), // 2: aiscan.sco.GetNodeRequest + (*GetNodeResponse)(nil), // 3: aiscan.sco.GetNodeResponse + (*GetStatsRequest)(nil), // 4: aiscan.sco.GetStatsRequest + (*GetStatsResponse)(nil), // 5: aiscan.sco.GetStatsResponse + (*DeleteNodesRequest)(nil), // 6: aiscan.sco.DeleteNodesRequest + (*DeleteNodesResponse)(nil), // 7: aiscan.sco.DeleteNodesResponse + (*ImportNodesRequest)(nil), // 8: aiscan.sco.ImportNodesRequest + (*ImportNodesResponse)(nil), // 9: aiscan.sco.ImportNodesResponse + (*ListArtifactsRequest)(nil), // 10: aiscan.sco.ListArtifactsRequest + (*ListArtifactsResponse)(nil), // 11: aiscan.sco.ListArtifactsResponse + nil, // 12: aiscan.sco.GetStatsResponse.ValuesEntry + (*sco.Nodes)(nil), // 13: aop.sco.Nodes +} +var file_aiscan_types_sco_proto_depIdxs = []int32{ + 13, // 0: aiscan.sco.ListNodesResponse.nodes:type_name -> aop.sco.Nodes + 12, // 1: aiscan.sco.GetStatsResponse.values:type_name -> aiscan.sco.GetStatsResponse.ValuesEntry + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_aiscan_types_sco_proto_init() } +func file_aiscan_types_sco_proto_init() { + if File_aiscan_types_sco_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_types_sco_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListNodesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListNodesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetNodeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetNodeResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetStatsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetStatsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteNodesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteNodesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ImportNodesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ImportNodesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_sco_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_types_sco_proto_rawDesc, + NumEnums: 0, + NumMessages: 13, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_types_sco_proto_goTypes, + DependencyIndexes: file_aiscan_types_sco_proto_depIdxs, + MessageInfos: file_aiscan_types_sco_proto_msgTypes, + }.Build() + File_aiscan_types_sco_proto = out.File + file_aiscan_types_sco_proto_rawDesc = nil + file_aiscan_types_sco_proto_goTypes = nil + file_aiscan_types_sco_proto_depIdxs = nil +} diff --git a/pkg/types/system/system.pb.go b/pkg/types/system/system.pb.go new file mode 100644 index 00000000..abd7e174 --- /dev/null +++ b/pkg/types/system/system.pb.go @@ -0,0 +1,345 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.1 +// protoc v6.33.0 +// source: aiscan/types/system.proto + +package system + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetStatusRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetStatusRequest) Reset() { + *x = GetStatusRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_system_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatusRequest) ProtoMessage() {} + +func (x *GetStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_system_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatusRequest.ProtoReflect.Descriptor instead. +func (*GetStatusRequest) Descriptor() ([]byte, []int) { + return file_aiscan_types_system_proto_rawDescGZIP(), []int{0} +} + +type Status struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + LlmAvailable bool `protobuf:"varint,2,opt,name=llm_available,json=llmAvailable,proto3" json:"llm_available,omitempty"` + LlmProvider string `protobuf:"bytes,3,opt,name=llm_provider,json=llmProvider,proto3" json:"llm_provider,omitempty"` + LlmModel string `protobuf:"bytes,4,opt,name=llm_model,json=llmModel,proto3" json:"llm_model,omitempty"` + LlmApiKeyConfigured bool `protobuf:"varint,5,opt,name=llm_api_key_configured,json=llmApiKeyConfigured,proto3" json:"llm_api_key_configured,omitempty"` + ConfigPath string `protobuf:"bytes,6,opt,name=config_path,json=configPath,proto3" json:"config_path,omitempty"` + ConfigLoaded bool `protobuf:"varint,7,opt,name=config_loaded,json=configLoaded,proto3" json:"config_loaded,omitempty"` + Agents uint32 `protobuf:"varint,8,opt,name=agents,proto3" json:"agents,omitempty"` + ServerUrl string `protobuf:"bytes,9,opt,name=server_url,json=serverUrl,proto3" json:"server_url,omitempty"` +} + +func (x *Status) Reset() { + *x = Status{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_system_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Status) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Status) ProtoMessage() {} + +func (x *Status) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_system_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Status.ProtoReflect.Descriptor instead. +func (*Status) Descriptor() ([]byte, []int) { + return file_aiscan_types_system_proto_rawDescGZIP(), []int{1} +} + +func (x *Status) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Status) GetLlmAvailable() bool { + if x != nil { + return x.LlmAvailable + } + return false +} + +func (x *Status) GetLlmProvider() string { + if x != nil { + return x.LlmProvider + } + return "" +} + +func (x *Status) GetLlmModel() string { + if x != nil { + return x.LlmModel + } + return "" +} + +func (x *Status) GetLlmApiKeyConfigured() bool { + if x != nil { + return x.LlmApiKeyConfigured + } + return false +} + +func (x *Status) GetConfigPath() string { + if x != nil { + return x.ConfigPath + } + return "" +} + +func (x *Status) GetConfigLoaded() bool { + if x != nil { + return x.ConfigLoaded + } + return false +} + +func (x *Status) GetAgents() uint32 { + if x != nil { + return x.Agents + } + return 0 +} + +func (x *Status) GetServerUrl() string { + if x != nil { + return x.ServerUrl + } + return "" +} + +type GetStatusResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Status *Status `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` +} + +func (x *GetStatusResponse) Reset() { + *x = GetStatusResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_aiscan_types_system_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatusResponse) ProtoMessage() {} + +func (x *GetStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_aiscan_types_system_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatusResponse.ProtoReflect.Descriptor instead. +func (*GetStatusResponse) Descriptor() ([]byte, []int) { + return file_aiscan_types_system_proto_rawDescGZIP(), []int{2} +} + +func (x *GetStatusResponse) GetStatus() *Status { + if x != nil { + return x.Status + } + return nil +} + +var File_aiscan_types_system_proto protoreflect.FileDescriptor + +var file_aiscan_types_system_proto_rawDesc = []byte{ + 0x0a, 0x19, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x73, + 0x79, 0x73, 0x74, 0x65, 0x6d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x61, 0x69, 0x73, + 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x22, 0x12, 0x0a, 0x10, 0x47, 0x65, + 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xb9, + 0x02, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x6c, 0x6c, 0x6d, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, + 0x61, 0x62, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x6c, 0x6c, 0x6d, 0x41, + 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x6c, 0x6c, 0x6d, 0x5f, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x6c, 0x6c, 0x6d, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x6c, + 0x6c, 0x6d, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x6c, 0x6c, 0x6d, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x33, 0x0a, 0x16, 0x6c, 0x6c, 0x6d, 0x5f, + 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, + 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x6c, 0x6c, 0x6d, 0x41, 0x70, 0x69, + 0x4b, 0x65, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x65, 0x64, 0x12, 0x1f, 0x0a, + 0x0b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x50, 0x61, 0x74, 0x68, 0x12, 0x23, + 0x0a, 0x0d, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x4c, 0x6f, 0x61, + 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x73, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x22, 0x42, 0x0a, 0x11, 0x47, 0x65, + 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x2d, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x61, 0x69, 0x73, 0x63, 0x61, 0x6e, 0x2e, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x39, + 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, + 0x69, 0x6e, 0x72, 0x65, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2f, 0x61, 0x69, 0x73, 0x63, 0x61, + 0x6e, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x73, 0x79, 0x73, 0x74, + 0x65, 0x6d, 0x3b, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var ( + file_aiscan_types_system_proto_rawDescOnce sync.Once + file_aiscan_types_system_proto_rawDescData = file_aiscan_types_system_proto_rawDesc +) + +func file_aiscan_types_system_proto_rawDescGZIP() []byte { + file_aiscan_types_system_proto_rawDescOnce.Do(func() { + file_aiscan_types_system_proto_rawDescData = protoimpl.X.CompressGZIP(file_aiscan_types_system_proto_rawDescData) + }) + return file_aiscan_types_system_proto_rawDescData +} + +var file_aiscan_types_system_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_aiscan_types_system_proto_goTypes = []interface{}{ + (*GetStatusRequest)(nil), // 0: aiscan.system.GetStatusRequest + (*Status)(nil), // 1: aiscan.system.Status + (*GetStatusResponse)(nil), // 2: aiscan.system.GetStatusResponse +} +var file_aiscan_types_system_proto_depIdxs = []int32{ + 1, // 0: aiscan.system.GetStatusResponse.status:type_name -> aiscan.system.Status + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_aiscan_types_system_proto_init() } +func file_aiscan_types_system_proto_init() { + if File_aiscan_types_system_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_aiscan_types_system_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetStatusRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_system_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Status); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_aiscan_types_system_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetStatusResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_aiscan_types_system_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_aiscan_types_system_proto_goTypes, + DependencyIndexes: file_aiscan_types_system_proto_depIdxs, + MessageInfos: file_aiscan_types_system_proto_msgTypes, + }.Build() + File_aiscan_types_system_proto = out.File + file_aiscan_types_system_proto_rawDesc = nil + file_aiscan_types_system_proto_goTypes = nil + file_aiscan_types_system_proto_depIdxs = nil +} diff --git a/proto/aiscan/chat/session.proto b/proto/aiscan/chat/session.proto deleted file mode 100644 index 391d0461..00000000 --- a/proto/aiscan/chat/session.proto +++ /dev/null @@ -1,136 +0,0 @@ -syntax = "proto3"; - -package aiscan.chat; - -import "aop/chat.proto"; -import "google/protobuf/timestamp.proto"; - -option go_package = "github.com/chainreactors/aiscan/aop/aiscan/chat;chat"; - -message SessionRecord { - aop.Session session = 1; - string agent_name = 2; - repeated string scan_ids = 3; - google.protobuf.Timestamp created_at = 4; - google.protobuf.Timestamp updated_at = 5; -} - -message ListSessionsRequest { - string after_cursor = 1; - uint32 limit = 2; - bool include_closed = 3; -} - -message ListSessionsResponse { - repeated SessionRecord sessions = 1; - string next_cursor = 2; -} - -message GetSessionRequest { - string session_id = 1; -} - -message GetSessionResponse { - SessionRecord session = 1; -} - -message ResetSessionRequest { - string request_id = 1; - string session_id = 2; - string new_session_id = 3; - string title = 4; -} - -message ResetSessionReceipt { - aop.Session previous = 1; - SessionRecord current = 2; -} - -message ResetSessionResponse { - string request_id = 1; - oneof outcome { - ResetSessionReceipt accepted = 2; - aop.Rejection rejected = 3; - } -} - -message DeleteSessionRequest { - string request_id = 1; - string session_id = 2; -} - -message DeleteSessionResponse { - string request_id = 1; - oneof outcome { - aop.Session accepted = 2; - aop.Rejection rejected = 3; - } -} - -message CommandSpec { - string name = 1; - repeated string aliases = 2; - string usage = 3; - string description = 4; -} - -message ListCommandsRequest { - string session_id = 1; -} - -message ListCommandsResponse { - repeated CommandSpec commands = 1; -} - -message ExecuteCommandRequest { - string request_id = 1; - string session_id = 2; - string line = 3; -} - -message CommandReceipt { - string operation_id = 1; - string session_id = 2; - string state = 3; -} - -message ExecuteCommandResponse { - string request_id = 1; - oneof outcome { - CommandReceipt accepted = 2; - aop.Rejection rejected = 3; - } -} - -message UploadSessionFileRequest { - string request_id = 1; - string session_id = 2; - string filename = 3; - string media_type = 4; - bytes data = 5; -} - -message UploadedFile { - string filename = 1; - string path = 2; - int64 size = 3; - string media_type = 4; -} - -message UploadSessionFileResponse { - string request_id = 1; - oneof outcome { - UploadedFile accepted = 2; - aop.Rejection rejected = 3; - } -} - -service SessionService { - rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); - rpc GetSession(GetSessionRequest) returns (GetSessionResponse); - rpc ResetSession(ResetSessionRequest) returns (ResetSessionResponse); - rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse); - rpc ListCommands(ListCommandsRequest) returns (ListCommandsResponse); - rpc ExecuteCommand(ExecuteCommandRequest) returns (ExecuteCommandResponse); - rpc UploadSessionFile(UploadSessionFileRequest) returns (UploadSessionFileResponse); -} diff --git a/proto/aiscan/rpc/agent.proto b/proto/aiscan/rpc/agent.proto new file mode 100644 index 00000000..0db51573 --- /dev/null +++ b/proto/aiscan/rpc/agent.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package aiscan.rpc.agent; + +import "aiscan/types/agent.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/rpc/agent;agent"; + +service AgentService { + rpc ListAgents(aiscan.agent.ListAgentsRequest) returns (aiscan.agent.ListAgentsResponse); + rpc ListLocalAgents(aiscan.agent.ListLocalAgentsRequest) returns (aiscan.agent.ListLocalAgentsResponse); + rpc LaunchLocalAgent(aiscan.agent.LaunchLocalAgentRequest) returns (aiscan.agent.LaunchLocalAgentResponse); + rpc StopLocalAgent(aiscan.agent.StopLocalAgentRequest) returns (aiscan.agent.StopLocalAgentResponse); +} diff --git a/proto/aiscan/rpc/chat.proto b/proto/aiscan/rpc/chat.proto new file mode 100644 index 00000000..db7e57bb --- /dev/null +++ b/proto/aiscan/rpc/chat.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package aiscan.rpc.chat; + +import "aop/chat.proto"; +import "aiscan/types/chat.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/rpc/chat;chat"; + +service SessionService { + rpc ListSessions(aiscan.chat.ListSessionsRequest) returns (aiscan.chat.ListSessionsResponse); + rpc GetSession(aiscan.chat.GetSessionRequest) returns (aiscan.chat.GetSessionResponse); + rpc ResetSession(aiscan.chat.ResetSessionRequest) returns (aiscan.chat.ResetSessionResponse); + rpc DeleteSession(aiscan.chat.DeleteSessionRequest) returns (aiscan.chat.DeleteSessionResponse); + rpc ListCommands(aiscan.chat.ListCommandsRequest) returns (aiscan.chat.ListCommandsResponse); + rpc ListEvents(aop.ListEventsRequest) returns (aop.ListEventsResponse); +} diff --git a/proto/aiscan/rpc/config.proto b/proto/aiscan/rpc/config.proto new file mode 100644 index 00000000..e398d8ef --- /dev/null +++ b/proto/aiscan/rpc/config.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package aiscan.rpc.config; + +import "aiscan/types/config.proto"; +import "google/protobuf/empty.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/rpc/config;config"; + +service ConfigService { + rpc GetConfig(google.protobuf.Empty) returns (aiscan.config.GetConfigResponse); + rpc UpdateConfig(aiscan.config.UpdateConfigRequest) returns (aiscan.config.UpdateConfigResponse); + rpc ActivateProfile(aiscan.config.ActivateProfileRequest) returns (aiscan.config.ActivateProfileResponse); + rpc TestLLM(aiscan.config.LLMProbeRequest) returns (aiscan.config.LLMProbeResult); + rpc ListModels(aiscan.config.LLMProbeRequest) returns (aiscan.config.ListModelsResult); + rpc TestConnection(aiscan.config.TestConnectionRequest) returns (aiscan.config.TestConnectionResponse); +} diff --git a/proto/aiscan/rpc/scan.proto b/proto/aiscan/rpc/scan.proto new file mode 100644 index 00000000..22605381 --- /dev/null +++ b/proto/aiscan/rpc/scan.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package aiscan.rpc.scan; + +import "aiscan/types/scan.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/rpc/scan;scan"; + +service ScanService { + rpc SubmitScan(aiscan.scan.SubmitScanRequest) returns (aiscan.scan.SubmitScanResponse); + rpc GetScan(aiscan.scan.GetScanRequest) returns (aiscan.scan.GetScanResponse); + rpc ListScans(aiscan.scan.ListScansRequest) returns (aiscan.scan.ListScansResponse); + rpc CancelScan(aiscan.scan.CancelScanRequest) returns (aiscan.scan.CancelScanResponse); + rpc GetScanReport(aiscan.scan.GetScanReportRequest) returns (aiscan.scan.GetScanReportResponse); +} diff --git a/proto/aiscan/rpc/sco.proto b/proto/aiscan/rpc/sco.proto new file mode 100644 index 00000000..89bed290 --- /dev/null +++ b/proto/aiscan/rpc/sco.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package aiscan.rpc.sco; + +import "aiscan/types/sco.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/rpc/sco;sco"; + +service SCOService { + rpc ListNodes(aiscan.sco.ListNodesRequest) returns (aiscan.sco.ListNodesResponse); + rpc GetNode(aiscan.sco.GetNodeRequest) returns (aiscan.sco.GetNodeResponse); + rpc GetStats(aiscan.sco.GetStatsRequest) returns (aiscan.sco.GetStatsResponse); + rpc DeleteNodes(aiscan.sco.DeleteNodesRequest) returns (aiscan.sco.DeleteNodesResponse); + rpc ImportNodes(aiscan.sco.ImportNodesRequest) returns (aiscan.sco.ImportNodesResponse); + rpc ListArtifacts(aiscan.sco.ListArtifactsRequest) returns (aiscan.sco.ListArtifactsResponse); +} diff --git a/proto/aiscan/rpc/system.proto b/proto/aiscan/rpc/system.proto new file mode 100644 index 00000000..f32580a2 --- /dev/null +++ b/proto/aiscan/rpc/system.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package aiscan.rpc.system; + +import "aiscan/types/system.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/rpc/system;system"; + +service SystemService { + rpc GetStatus(aiscan.system.GetStatusRequest) returns (aiscan.system.GetStatusResponse); +} diff --git a/proto/aiscan/transport/agent.proto b/proto/aiscan/transport/agent.proto deleted file mode 100644 index e1e2328f..00000000 --- a/proto/aiscan/transport/agent.proto +++ /dev/null @@ -1,90 +0,0 @@ -syntax = "proto3"; - -package aiscan.transport; - -import "aiscan/transport/operation.proto"; -import "aiscan/transport/telemetry.proto"; -import "aiscan/transport/terminal.proto"; -import "aop/chat.proto"; -import "aop/content.proto"; -import "aop/event.proto"; -import "aop/value.proto"; - -option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; - -message AgentHello { - string agent_id = 1; - string name = 2; - string authority = 3; - repeated string commands = 4; - repeated CommandSpec command_menu = 5; - repeated ToolDefinition tools = 6; - AgentRuntimeInfo runtime = 7; - AgentStatus status = 8; - AgentStats stats = 9; -} - -message ConnectionAccepted { - string agent_id = 1; - string name = 2; - repeated string capabilities = 3; -} - -message ToolCallRequest { - string task_id = 1; - string session_id = 2; - string turn_id = 3; - aop.ToolCall call = 4; -} - -message AgentFrame { - string frame_id = 1; - string correlation_id = 2; - oneof payload { - AgentHello hello = 10; - aop.OpenSessionResponse open_session = 11; - aop.RunTurnResponse run_turn = 12; - aop.CancelTurnResponse cancel_turn = 13; - aop.CloseSessionResponse close_session = 14; - aop.Event event = 15; - CommandResult command_result = 16; - FileResult file_result = 17; - ExecOutput exec_output = 18; - ExecResult exec_result = 19; - OperationError operation_error = 20; - AgentStatus status = 21; - AgentStats stats = 22; - ConfigReloadResult config_reload = 23; - TerminalFrame terminal = 24; - ToolTelemetry tool_telemetry = 25; - ScoNodes sco_nodes = 26; - } -} - -message ServerFrame { - string frame_id = 1; - string correlation_id = 2; - oneof payload { - ConnectionAccepted accepted = 10; - aop.OpenSessionRequest open_session = 11; - aop.RunTurnRequest run_turn = 12; - aop.CancelTurnRequest cancel_turn = 13; - aop.CloseSessionRequest close_session = 14; - CommandRequest command = 15; - ToolCallRequest tool_call = 16; - FileReadRequest file_read = 17; - FileWriteRequest file_write = 18; - FileListRequest file_list = 19; - FileMkdirRequest file_mkdir = 20; - FileUploadRequest file_upload = 21; - ExecRequest exec = 22; - CancelOperation cancel_operation = 23; - ReloadConfig reload_config = 24; - TerminalFrame terminal = 25; - aop.Extension extension = 26; - } -} - -service AgentTransportService { - rpc Connect(stream AgentFrame) returns (stream ServerFrame); -} diff --git a/proto/aiscan/transport/extensions.proto b/proto/aiscan/transport/extensions.proto deleted file mode 100644 index 2e13502e..00000000 --- a/proto/aiscan/transport/extensions.proto +++ /dev/null @@ -1,59 +0,0 @@ -syntax = "proto3"; - -package aiscan.transport; - -import "google/protobuf/struct.proto"; - -option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; - -message CommandDetail { - string line = 1; - string presentation = 2; -} - -message CompactDetail { - string error = 1; - uint64 kept_messages = 2; - uint64 tokens_after = 3; - uint64 tokens_before = 4; -} - -message DelegationDetail { - string agent_id = 1; - string agent_name = 2; - string agent_type = 3; - string context_mode = 4; - string run_mode = 5; - string task = 6; -} - -message EvalControl { - string criteria = 1; - uint32 max_rounds = 2; -} - -message EvalDetail { - string error = 1; - uint32 max_rounds = 2; - bool pass = 3; - string reason = 4; - uint32 round = 5; -} - -message BudgetWarning { - uint64 context_tokens = 1; - uint64 token_budget = 2; -} - -message LLMRequestDetail { - string model = 1; - uint32 messages = 2; - uint32 max_tokens = 3; - bool stream = 4; -} - -message WebMessageExtension { - string agent_id = 1; - bytes metadata = 2; - google.protobuf.Struct params = 3; -} diff --git a/proto/aiscan/transport/operation.proto b/proto/aiscan/transport/operation.proto deleted file mode 100644 index 096fecc8..00000000 --- a/proto/aiscan/transport/operation.proto +++ /dev/null @@ -1,115 +0,0 @@ -syntax = "proto3"; - -package aiscan.transport; - -option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; - -message CommandRequest { - string task_id = 1; - string session_id = 2; - string line = 3; -} - -// RunOptions carries AIScan-only turn behavior in the -// io.chainreactors.aiscan.run AOP extension. -message RunOptions { - string eval_criteria = 1; - uint32 eval_max_rounds = 2; -} - -message CommandResult { - string task_id = 1; - bytes result = 2; - string media_type = 3; -} - -message FileReadRequest { - string task_id = 1; - string path = 2; -} - -message FileWriteRequest { - string task_id = 1; - string path = 2; - bytes data = 3; -} - -message FileListRequest { - string task_id = 1; - string path = 2; -} - -message FileMkdirRequest { - string task_id = 1; - string path = 2; -} - -message FileUploadRequest { - string task_id = 1; - string session_id = 2; - string filename = 3; - string media_type = 4; - bytes data = 5; -} - -message FileEntry { - string name = 1; - bool is_directory = 2; - int64 size = 3; -} - -message FileResult { - string task_id = 1; - string path = 2; - string filename = 3; - int64 size = 4; - bytes data = 5; - repeated FileEntry entries = 6; -} - -enum ExecStream { - EXEC_STREAM_UNSPECIFIED = 0; - EXEC_STREAM_STDOUT = 1; - EXEC_STREAM_STDERR = 2; -} - -message ExecRequest { - string task_id = 1; - string command = 2; - string cwd = 3; - uint32 timeout_seconds = 4; - map env = 5; -} - -message ExecOutput { - string task_id = 1; - ExecStream stream = 2; - bytes data = 3; -} - -message ExecResult { - string task_id = 1; - int32 exit_code = 2; - string state = 3; - string kill_cause = 4; -} - -message CancelOperation { - string task_id = 1; -} - -message OperationError { - string task_id = 1; - string code = 2; - string message = 3; - bool retryable = 4; -} - -message ReloadConfig {} - -message ConfigReloadResult { - bool ok = 1; - string provider = 2; - string model = 3; - string error = 4; -} diff --git a/proto/aiscan/transport/telemetry.proto b/proto/aiscan/transport/telemetry.proto deleted file mode 100644 index bc02a553..00000000 --- a/proto/aiscan/transport/telemetry.proto +++ /dev/null @@ -1,69 +0,0 @@ -syntax = "proto3"; - -package aiscan.transport; - -import "aop/value.proto"; -import "google/protobuf/timestamp.proto"; - -option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; - -message AgentRuntimeInfo { - string hostname = 1; - string username = 2; - string working_dir = 3; - string os = 4; - string arch = 5; - int32 pid = 6; - repeated string capabilities = 7; - aop.EncodedValue metadata = 8; -} - -message AgentStatus { - string provider = 1; - string model = 2; - string space = 3; - bool bound = 4; - string config_error = 5; -} - -message AgentStats { - uint64 turns = 1; - uint64 tool_calls = 2; - uint64 running_tools = 3; - uint64 input_tokens = 4; - uint64 output_tokens = 5; - uint64 total_tokens = 6; - uint64 cache_read_tokens = 7; - uint64 cache_write_tokens = 8; - uint64 assets = 9; - uint64 loots = 10; - string last_event = 11; -} - -message ToolDefinition { - string type = 1; - string name = 2; - string description = 3; - aop.EncodedValue input_schema = 4; -} - -message CommandSpec { - string name = 1; - repeated string aliases = 2; - string usage = 3; - string description = 4; -} - -message ToolTelemetry { - string tool = 1; - string kind = 2; - string target = 3; - aop.EncodedValue data = 4; - string call_id = 5; - google.protobuf.Timestamp timestamp = 6; -} - -message ScoNodes { - string call_id = 1; - repeated bytes nodes = 2; -} diff --git a/proto/aiscan/transport/terminal.proto b/proto/aiscan/transport/terminal.proto deleted file mode 100644 index b675a9f2..00000000 --- a/proto/aiscan/transport/terminal.proto +++ /dev/null @@ -1,44 +0,0 @@ -syntax = "proto3"; - -package aiscan.transport; - -import "google/protobuf/timestamp.proto"; - -option go_package = "github.com/chainreactors/aiscan/aop/aiscan/transport;transport"; - -message TerminalInfo { - string id = 1; - string kind = 2; - string name = 3; - string command = 4; - int32 pid = 5; - google.protobuf.Timestamp started_at = 6; - google.protobuf.Timestamp last_activity_at = 7; - google.protobuf.Timestamp ended_at = 8; - int64 activity_seq = 9; - int64 output_bytes = 10; - int32 exit_code = 11; - string state = 12; - string kill_cause = 13; -} - -message TerminalFrame { - string type = 1; - string stream_id = 2; - string session_id = 3; - string kind = 4; - string name = 5; - string command = 6; - repeated string args = 7; - bytes data = 8; - int32 cols = 9; - int32 rows = 10; - int32 bytes = 11; - int64 offset = 12; - bool singleton = 13; - string error = 14; - string state = 15; - int32 exit_code = 16; - TerminalInfo session = 17; - repeated TerminalInfo sessions = 18; -} diff --git a/proto/aiscan/types/agent.proto b/proto/aiscan/types/agent.proto new file mode 100644 index 00000000..e0d91f7d --- /dev/null +++ b/proto/aiscan/types/agent.proto @@ -0,0 +1,93 @@ +syntax = "proto3"; + +package aiscan.agent; + +import "aop/protocol.proto"; +import "aiscan/types/command.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/types/agent;agent"; + +message View { + aop.AgentHello hello = 1; + aop.AgentStatus status = 2; + aop.AgentStats stats = 3; + string node_uri = 4; + google.protobuf.Timestamp connected_at = 5; + repeated aiscan.command.Spec commands = 6; + bool busy = 7; +} + +message LocalAgent { + string name = 1; + int32 pid = 2; + bool registered = 3; + bool busy = 4; +} + +message ListAgentsRequest {} +message ListAgentsResponse { repeated View agents = 1; } +message ListLocalAgentsRequest {} +message ListLocalAgentsResponse { repeated LocalAgent agents = 1; } +message LaunchLocalAgentRequest {} +message LaunchLocalAgentResponse { LocalAgent agent = 1; } +message StopLocalAgentRequest { string name = 1; } +message StopLocalAgentResponse {} + +message RunOptions { + string eval_criteria = 1; + uint32 eval_max_rounds = 2; +} + +message CommandDetail { + string line = 1; + string presentation = 2; +} + +message CompactDetail { + string error = 1; + uint64 kept_messages = 2; + uint64 tokens_after = 3; + uint64 tokens_before = 4; +} + +message DelegationDetail { + string agent_id = 1; + string agent_name = 2; + string agent_type = 3; + string context_mode = 4; + string run_mode = 5; + string task = 6; +} + +message EvalControl { + string criteria = 1; + uint32 max_rounds = 2; +} + +message EvalDetail { + string error = 1; + uint32 max_rounds = 2; + bool pass = 3; + string reason = 4; + uint32 round = 5; +} + +message BudgetWarning { + uint64 context_tokens = 1; + uint64 token_budget = 2; +} + +message LLMRequestDetail { + string model = 1; + uint32 messages = 2; + uint32 max_tokens = 3; + bool stream = 4; +} + +message WebMessageMetadata { + string agent_id = 1; + string code = 2; + google.protobuf.Struct params = 3; +} diff --git a/proto/aiscan/types/chat.proto b/proto/aiscan/types/chat.proto new file mode 100644 index 00000000..8f3f68af --- /dev/null +++ b/proto/aiscan/types/chat.proto @@ -0,0 +1,77 @@ +syntax = "proto3"; + +package aiscan.chat; + +import "aop/chat.proto"; +import "aiscan/types/command.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/types/chat;chat"; + +message SessionRecord { + aop.Session session = 1; + string agent_name = 2; + repeated string scan_ids = 3; + google.protobuf.Timestamp created_at = 4; + google.protobuf.Timestamp updated_at = 5; +} + +message ListSessionsRequest { + string after_cursor = 1; + uint32 limit = 2; + bool include_closed = 3; +} + +message ListSessionsResponse { + repeated SessionRecord sessions = 1; + string next_cursor = 2; +} + +message GetSessionRequest { + string session_id = 1; +} + +message GetSessionResponse { + SessionRecord session = 1; +} + +message ResetSessionRequest { + string request_id = 1; + string session_id = 2; + string new_session_id = 3; + string title = 4; +} + +message ResetSessionReceipt { + aop.Session previous = 1; + SessionRecord current = 2; +} + +message ResetSessionResponse { + string request_id = 1; + oneof outcome { + ResetSessionReceipt accepted = 2; + aop.Rejection rejected = 3; + } +} + +message DeleteSessionRequest { + string request_id = 1; + string session_id = 2; +} + +message DeleteSessionResponse { + string request_id = 1; + oneof outcome { + aop.Session accepted = 2; + aop.Rejection rejected = 3; + } +} + +message ListCommandsRequest { + string session_id = 1; +} + +message ListCommandsResponse { + repeated aiscan.command.Spec commands = 1; +} diff --git a/proto/aiscan/types/command.proto b/proto/aiscan/types/command.proto new file mode 100644 index 00000000..2e8f314c --- /dev/null +++ b/proto/aiscan/types/command.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package aiscan.command; + +option go_package = "github.com/chainreactors/aiscan/pkg/types/command;command"; + +message Spec { + string name = 1; + repeated string aliases = 2; + string usage = 3; + string description = 4; +} + +message Catalog { repeated Spec commands = 1; } + +message Request { + string session_id = 1; + string line = 2; +} + +message Result { + bytes data = 1; + string media_type = 2; +} + +message Receipt { + string operation_id = 1; + string session_id = 2; + string state = 3; +} + +message ProtocolMessage { + oneof message { + Request request = 10; + Result result = 11; + Catalog catalog = 12; + Receipt receipt = 13; + } +} diff --git a/proto/aiscan/types/config.proto b/proto/aiscan/types/config.proto new file mode 100644 index 00000000..941bb038 --- /dev/null +++ b/proto/aiscan/types/config.proto @@ -0,0 +1,170 @@ +syntax = "proto3"; + +package aiscan.config; + +option go_package = "github.com/chainreactors/aiscan/pkg/types/config;config"; + +message DistributeConfig { + LLMConfig llm = 1; + CyberhubConfig cyberhub = 2; + ReconConfig recon = 3; + ScanConfig scan = 4; + SearchConfig search = 5; + IOAConfig ioa = 6; + AgentConfig agent = 7; +} + +message LLMConfig { + string active_profile = 1; + repeated LLMProviderConfig providers = 2; +} + +message LLMProviderConfig { + string id = 1; + string name = 2; + string provider = 3; + string base_url = 4; + string api_key = 5; + string model = 6; + string proxy = 7; + int32 max_tokens = 8; + int32 context_window = 9; +} + +message CyberhubConfig { + string url = 1; + string key = 2; + string mode = 3; + string proxy = 4; +} + +message ReconConfig { + string fofa_email = 1; + string fofa_key = 2; + string hunter_token = 3; + string hunter_api_key = 4; + string proxy = 5; + int32 limit = 6; +} + +message ScanConfig { + string verify = 1; +} + +message SearchConfig { + string tavily_keys = 1; +} + +message IOAConfig { + string url = 1; + string token = 2; + string node_name = 3; + string space = 4; +} + +message AgentConfig { + repeated string tools = 1; + int32 timeout = 2; + bool save_session = 3; +} + +message LLMProviderView { + string id = 1; + string name = 2; + string provider = 3; + string base_url = 4; + bool api_key_configured = 5; + string model = 6; + string proxy = 7; + int32 max_tokens = 8; + int32 context_window = 9; +} + +message LLMView { + string active_profile = 1; + LLMProviderView active = 2; + repeated LLMProviderView providers = 3; +} + +message CyberhubView { + string url = 1; + bool key_configured = 2; + string mode = 3; + string proxy = 4; +} + +message ReconView { + string fofa_email = 1; + bool fofa_key_configured = 2; + bool hunter_token_configured = 3; + bool hunter_api_key_configured = 4; + string proxy = 5; + int32 limit = 6; +} + +message SearchView { bool tavily_keys_configured = 1; } + +message IOAView { + string url = 1; + bool token_configured = 2; + string node_name = 3; + string space = 4; +} + +message ConfigView { + string path = 1; + bool loaded = 2; + LLMView llm = 3; + CyberhubView cyberhub = 4; + ReconView recon = 5; + ScanConfig scan = 6; + SearchView search = 7; + IOAView ioa = 8; + AgentConfig agent = 9; +} + +message GetConfigResponse { ConfigView config = 1; } +message UpdateConfigRequest { DistributeConfig config = 1; } +message UpdateConfigResponse { ConfigView config = 1; } +message ActivateProfileRequest { string profile_id = 1; } +message ActivateProfileResponse { ConfigView config = 1; } + +message LLMProbeRequest { + string profile_id = 1; + string provider = 2; + string base_url = 3; + string api_key = 4; + string model = 5; + string proxy = 6; +} + +message LLMProbeResult { + bool ok = 1; + string provider = 2; + string model = 3; + int64 latency_ms = 4; + string reply = 5; + string error = 6; +} + +message ListModelsResult { + bool ok = 1; + bool supported = 2; + repeated string models = 3; + string error = 4; +} + +message TestConnectionRequest { + string section = 1; + DistributeConfig config = 2; +} + +message ConnectionCheck { + string name = 1; + bool ok = 2; + int64 latency_ms = 3; + string detail = 4; + string error = 5; +} + +message TestConnectionResponse { repeated ConnectionCheck checks = 1; } diff --git a/proto/aiscan/types/reload.proto b/proto/aiscan/types/reload.proto new file mode 100644 index 00000000..37f7e86b --- /dev/null +++ b/proto/aiscan/types/reload.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package aiscan.reload; + +import "aiscan/types/config.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/types/reload;reload"; + +message Request { aiscan.config.DistributeConfig config = 1; } + +message Result { + bool ok = 1; + string provider = 2; + string model = 3; + string error = 4; +} + +message ProtocolMessage { + oneof message { + Request request = 10; + Result result = 11; + } +} diff --git a/proto/aiscan/scan/scan.proto b/proto/aiscan/types/scan.proto similarity index 76% rename from proto/aiscan/scan/scan.proto rename to proto/aiscan/types/scan.proto index 28bbe636..00991b85 100644 --- a/proto/aiscan/scan/scan.proto +++ b/proto/aiscan/types/scan.proto @@ -3,10 +3,9 @@ syntax = "proto3"; package aiscan.scan; import "aop/chat.proto"; -import "aop/value.proto"; import "google/protobuf/timestamp.proto"; -option go_package = "github.com/chainreactors/aiscan/aop/aiscan/scan;scan"; +option go_package = "github.com/chainreactors/aiscan/pkg/types/scan;scan"; enum ScanStatus { SCAN_STATUS_UNSPECIFIED = 0; @@ -31,7 +30,7 @@ message Scan { ScanStatus status = 5; string progress = 6; string report = 7; - aop.EncodedValue result = 8; + reserved 8; string error = 9; google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp updated_at = 11; @@ -91,15 +90,18 @@ message ScanStats { map values = 1; } -message ScanCompleted { - aop.EncodedValue result = 1; -} +message ScanCompleted {} message ScanFailed { string message = 1; bool canceled = 2; } +// SessionBinding attaches an AIScan Scan to an AOP Session at open time. +message SessionBinding { + string scan_id = 1; +} + // SessionScanEvent links a completed scan into an AOP session timeline without // reintroducing a parallel web-only domain event envelope. message SessionScanEvent { @@ -121,8 +123,13 @@ message ScanEvent { } } -message WatchScanEventsResponse { - ScanEvent event = 1; +// ProtocolMessage carries AIScan scan runtime semantics over the shared AOP +// WebSocket. Scan management remains on ScanService. +message ProtocolMessage { + oneof message { + WatchScanEventsRequest watch_events_request = 10; + ScanEvent event = 11; + } } message GetScanReportRequest { @@ -134,12 +141,3 @@ message GetScanReportResponse { string markdown = 1; string media_type = 2; } - -service ScanService { - rpc SubmitScan(SubmitScanRequest) returns (SubmitScanResponse); - rpc GetScan(GetScanRequest) returns (GetScanResponse); - rpc ListScans(ListScansRequest) returns (ListScansResponse); - rpc CancelScan(CancelScanRequest) returns (CancelScanResponse); - rpc WatchScanEvents(WatchScanEventsRequest) returns (stream WatchScanEventsResponse); - rpc GetScanReport(GetScanReportRequest) returns (GetScanReportResponse); -} diff --git a/proto/aiscan/types/sco.proto b/proto/aiscan/types/sco.proto new file mode 100644 index 00000000..056fa358 --- /dev/null +++ b/proto/aiscan/types/sco.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package aiscan.sco; + +import "aop/sco/protocol.proto"; + +option go_package = "github.com/chainreactors/aiscan/pkg/types/sco;sco"; + +message ListNodesRequest { + string type = 1; + string operation_id = 2; + uint32 limit = 3; +} + +message ListNodesResponse { aop.sco.Nodes nodes = 1; } +message GetNodeRequest { string id = 1; } +message GetNodeResponse { bytes node = 1; string media_type = 2; } +message GetStatsRequest {} +message GetStatsResponse { map values = 1; } +message DeleteNodesRequest { string operation_id = 1; } +message DeleteNodesResponse {} +message ImportNodesRequest { + bytes data = 1; + string artifact = 2; + string operation_id = 3; +} +message ImportNodesResponse { + uint64 nodes = 1; + uint64 duplicates = 2; + string artifact = 3; +} +message ListArtifactsRequest {} +message ListArtifactsResponse { repeated string artifacts = 1; } diff --git a/proto/aiscan/types/system.proto b/proto/aiscan/types/system.proto new file mode 100644 index 00000000..d390d857 --- /dev/null +++ b/proto/aiscan/types/system.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package aiscan.system; + +option go_package = "github.com/chainreactors/aiscan/pkg/types/system;system"; + +message GetStatusRequest {} + +message Status { + string version = 1; + bool llm_available = 2; + string llm_provider = 3; + string llm_model = 4; + bool llm_api_key_configured = 5; + string config_path = 6; + bool config_loaded = 7; + uint32 agents = 8; + string server_url = 9; +} + +message GetStatusResponse { Status status = 1; } diff --git a/proto/generate.go b/proto/generate.go deleted file mode 100644 index 95188d7d..00000000 --- a/proto/generate.go +++ /dev/null @@ -1,8 +0,0 @@ -// Package proto owns reproducible protobuf generation for AIScan. -package proto - -//go:generate protoc -I ../web/frontend/cyber-ui/packages/aop/proto -I . --go_out=.. --go_opt=module=github.com/chainreactors/aiscan --go_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop --go-grpc_out=.. --go-grpc_opt=module=github.com/chainreactors/aiscan --go-grpc_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop --connect-go_out=.. --connect-go_opt=module=github.com/chainreactors/aiscan --connect-go_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --connect-go_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --connect-go_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --connect-go_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop ../web/frontend/cyber-ui/packages/aop/proto/aop/value.proto ../web/frontend/cyber-ui/packages/aop/proto/aop/content.proto ../web/frontend/cyber-ui/packages/aop/proto/aop/event.proto ../web/frontend/cyber-ui/packages/aop/proto/aop/chat.proto aiscan/chat/session.proto aiscan/scan/scan.proto - -//go:generate protoc -I ../web/frontend/cyber-ui/packages/aop/proto -I . --go_out=.. --go_opt=module=github.com/chainreactors/aiscan --go_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop --go-grpc_out=.. --go-grpc_opt=module=github.com/chainreactors/aiscan --go-grpc_opt=Maop/value.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/content.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/event.proto=github.com/chainreactors/aiscan/aop --go-grpc_opt=Maop/chat.proto=github.com/chainreactors/aiscan/aop aiscan/transport/operation.proto aiscan/transport/extensions.proto aiscan/transport/terminal.proto aiscan/transport/telemetry.proto aiscan/transport/agent.proto - -//go:generate go run ./internal/generate_ts diff --git a/proto/internal/generate_ts/main.go b/proto/internal/generate_ts/main.go deleted file mode 100644 index 01508175..00000000 --- a/proto/internal/generate_ts/main.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" -) - -func main() { - protoc, err := exec.LookPath("protoc") - if err != nil { - fatal("find protoc", err) - } - pluginName := "protoc-gen-es" - if runtime.GOOS == "windows" { - pluginName += ".cmd" - } - plugin := filepath.Join("..", "web", "frontend", "node_modules", ".bin", pluginName) - plugin, err = filepath.Abs(plugin) - if err != nil { - fatal("resolve protoc-gen-es", err) - } - if _, err := os.Stat(plugin); err != nil { - if plugin, err = exec.LookPath("protoc-gen-es"); err != nil { - fatal("find protoc-gen-es (run npm install in web/frontend)", err) - } - } - - args := []string{ - "-I", "../web/frontend/cyber-ui/packages/aop/proto", - "-I", ".", - "--plugin=protoc-gen-es=" + plugin, - "--es_out=../web/frontend/cyber-ui/packages/aop/src/gen", - "--es_opt=target=ts,import_extension=js", - "../web/frontend/cyber-ui/packages/aop/proto/aop/value.proto", - "../web/frontend/cyber-ui/packages/aop/proto/aop/content.proto", - "../web/frontend/cyber-ui/packages/aop/proto/aop/event.proto", - "../web/frontend/cyber-ui/packages/aop/proto/aop/chat.proto", - "aiscan/chat/session.proto", - "aiscan/scan/scan.proto", - "aiscan/transport/terminal.proto", - } - command := exec.Command(protoc, args...) - command.Stdout = os.Stdout - command.Stderr = os.Stderr - if err := command.Run(); err != nil { - fatal("generate TypeScript protobuf", err) - } -} - -func fatal(action string, err error) { - fmt.Fprintf(os.Stderr, "%s: %v\n", action, err) - os.Exit(1) -} diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui index 486b17e5..19806f1c 160000 --- a/web/frontend/cyber-ui +++ b/web/frontend/cyber-ui @@ -1 +1 @@ -Subproject commit 486b17e5b97af90a16719700991d94dda4d01001 +Subproject commit 19806f1c9a66686251ba9d9c0b6e6c68e4d7be3a From d403178b0072aed9f973ac385d646b36012d7585 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sun, 2 Aug 2026 12:01:44 +0800 Subject: [PATCH 158/348] refactor(scan): remove duplicate report and DTO pipelines --- core/output/format.go | 95 +--- core/output/format_asset.go | 118 ----- core/output/format_markdown.go | 63 --- core/output/format_test.go | 49 -- core/output/record.go | 121 ----- core/output/report.go | 854 ------------------------------ core/output/report_golden_test.go | 115 ---- core/output/sco_sidecar.go | 7 +- core/output/timeline.go | 82 +-- core/output/timeline_test.go | 16 +- core/output/types.go | 77 +-- core/output/writer.go | 57 -- tools/proton/command_test.go | 3 +- tools/scan/aggregate.go | 690 ------------------------ tools/scan/aggregate_test.go | 45 -- tools/scan/collector.go | 15 - tools/scan/command.go | 79 +-- tools/scan/command_test.go | 63 +-- tools/scan/data_bus_test.go | 8 +- tools/scan/jsonl_writer.go | 111 ---- tools/scan/sco.go | 12 +- tools/scan/sco_stub.go | 11 - tools/scan/sco_test.go | 19 +- tools/scan/structured.go | 20 +- tools/search/websearch_tool.go | 10 +- 25 files changed, 114 insertions(+), 2626 deletions(-) delete mode 100644 core/output/format_asset.go delete mode 100644 core/output/format_markdown.go delete mode 100644 core/output/record.go delete mode 100644 core/output/report.go delete mode 100644 core/output/report_golden_test.go delete mode 100644 core/output/writer.go delete mode 100644 tools/scan/aggregate.go delete mode 100644 tools/scan/aggregate_test.go delete mode 100644 tools/scan/jsonl_writer.go delete mode 100644 tools/scan/sco_stub.go diff --git a/core/output/format.go b/core/output/format.go index 8cd3d6af..1489a150 100644 --- a/core/output/format.go +++ b/core/output/format.go @@ -47,95 +47,20 @@ func FirstNonEmpty(values ...string) string { return "" } -func AssetItemDetail(item AssetItem) string { - for _, value := range []string{item.Detail, item.Raw} { - if trimmed := strings.TrimSpace(value); trimmed != "" { - if value == item.Raw { - if parsed := ExtractQuotedMarkdown(value); parsed != "" { - return parsed - } - } - return trimmed - } - } - return "" -} - -func ExtractQuotedMarkdown(raw string) string { - fields := quotedFields(raw) - for i := len(fields) - 1; i >= 0; i-- { - value := strings.TrimSpace(fields[i]) +func CompactStrings(values ...string) []string { + seen := make(map[string]struct{}) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) if value == "" { continue } - if looksLikeMarkdown(value) { - return value - } - } - return "" -} - -func quotedFields(input string) []string { - var values []string - for i := 0; i < len(input); i++ { - if input[i] != '"' { + key := strings.ToLower(value) + if _, ok := seen[key]; ok { continue } - i++ - var sb strings.Builder - for i < len(input) { - ch := input[i] - if ch == '"' { - break - } - if ch == '\\' && i+1 < len(input) { - sb.WriteString(decodeEscapedByte(input[i+1])) - i += 2 - continue - } - sb.WriteByte(ch) - i++ - } - values = append(values, sb.String()) - } - return values -} - -func decodeEscapedByte(ch byte) string { - switch ch { - case 'n': - return "\n" - case 'r': - return "\r" - case 't': - return "\t" - case '"': - return `"` - case '\\': - return `\` - default: - return string(ch) - } -} - -func looksLikeMarkdown(value string) bool { - if strings.Contains(value, "\n") { - return true - } - for _, prefix := range []string{"#", "-", "*", "|", ">", "```"} { - if strings.HasPrefix(strings.TrimSpace(value), prefix) { - return true - } - } - return false -} - -func firstContentLine(value string) string { - for _, line := range strings.Split(value, "\n") { - line = strings.TrimSpace(line) - if line != "" { - return line - } + seen[key] = struct{}{} + out = append(out, value) } - return "" + return out } diff --git a/core/output/format_asset.go b/core/output/format_asset.go deleted file mode 100644 index 4d541f9f..00000000 --- a/core/output/format_asset.go +++ /dev/null @@ -1,118 +0,0 @@ -package output - -import ( - "net/url" - "strconv" - "strings" -) - -// FormatAssetReport renders the terminal asset report. The sitemap tree is the -// terminal report's own feature; everything else comes from the shared -// renderer in report.go. -func FormatAssetReport(result *Result, color bool) string { - return RenderReport(result, ReportOptions{ - Style: StyleANSI, - Color: color, - Sitemap: true, - }) -} - -// --- shared asset helpers --- - -func WebPath(rawURL string) string { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return FirstNonEmpty(rawURL, "/") - } - path := parsed.EscapedPath() - if path == "" { - path = "/" - } - if parsed.RawQuery != "" { - path += "?" + parsed.RawQuery - } - return path -} - -func HasTag(tags []string, tag string) bool { - for _, t := range tags { - if strings.EqualFold(t, tag) { - return true - } - } - return false -} - -func CompactStrings(values ...string) []string { - seen := make(map[string]struct{}) - out := make([]string, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - key := strings.ToLower(value) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, value) - } - return out -} - -func AssetDataString(data map[string]any, key string) string { - if len(data) == 0 { - return "" - } - switch value := data[key].(type) { - case string: - return value - case int: - if value == 0 { - return "" - } - return strconv.Itoa(value) - case float64: - if value == 0 { - return "" - } - return strconv.Itoa(int(value)) - default: - return "" - } -} - -func AssetDataInt(data map[string]any, key string) int { - if len(data) == 0 { - return 0 - } - switch v := data[key].(type) { - case int: - return v - case float64: - return int(v) - default: - return 0 - } -} - -func AssetDataStrings(data map[string]any, key string) []string { - if len(data) == 0 { - return nil - } - switch v := data[key].(type) { - case []string: - return v - case []any: - out := make([]string, 0, len(v)) - for _, item := range v { - if s, ok := item.(string); ok && s != "" { - out = append(out, s) - } - } - return out - default: - return nil - } -} diff --git a/core/output/format_markdown.go b/core/output/format_markdown.go deleted file mode 100644 index 8d76ee90..00000000 --- a/core/output/format_markdown.go +++ /dev/null @@ -1,63 +0,0 @@ -package output - -import ( - "fmt" - "strings" - - "github.com/chainreactors/utils/parsers" -) - -// RecordsToResult converts parsed records into a Result for asset report rendering. -func RecordsToResult(records []Record) *Result { - result := &Result{} - for _, r := range records { - if r.Loot { - d, _ := ParseRecordData[Loot](r) - result.Loots = append(result.Loots, d) - continue - } - switch r.Type { - case TypeGogo: - d, _ := ParseRecordData[parsers.GOGOResult](r) - result.Services = append(result.Services, &d) - case TypeSpray: - d, _ := ParseRecordData[parsers.SprayResult](r) - if d.UrlString == "" { - continue - } - if d.Status > 0 { - result.WebProbes = append(result.WebProbes, &d) - } - case TypeScanEnd: - d, _ := ParseRecordData[ScanEnd](r) - result.Summary = Summary{ - Targets: d.Targets, - Services: d.Services, - Webs: d.Webs, - Loots: d.Loots, - Duration: fmt.Sprintf("%.1fs", d.Duration), - } - } - } - - if result.Summary.Probes == 0 { - result.Summary.Probes = len(result.WebProbes) - } - return result -} - -// RenderRecordFileAsAsset reads a record JSONL file and renders as an asset report. -func RenderRecordFileAsAsset(path string, color bool, aggregate func(*Result) []Asset) (string, *Result, error) { - records, err := ParseRecordFile(path) - if err != nil { - return "", nil, fmt.Errorf("open record file: %w", err) - } - - result := RecordsToResult(records) - if aggregate != nil { - result.Assets = aggregate(result) - } - - out := FormatAssetReport(result, color) - return strings.TrimRight(out, "\n") + "\n", result, nil -} diff --git a/core/output/format_test.go b/core/output/format_test.go index 91afe9ba..6ba63ac4 100644 --- a/core/output/format_test.go +++ b/core/output/format_test.go @@ -12,55 +12,6 @@ func TestStripANSIPrivateModeSequences(t *testing.T) { } } -func TestLootRecordRoundTrip(t *testing.T) { - loot := Loot{ - Kind: LootVuln, - Target: "http://10.0.0.1:8080", - Priority: "high", - Description: "CVE-2024-1234 — Remote Code Execution", - Tags: []string{"high", "CVE-2024-1234"}, - Data: map[string]any{ - "key": "http://10.0.0.1:8080|CVE-2024-1234", - "template_id": "CVE-2024-1234", - "template_name": "Remote Code Execution", - "severity": "high", - }, - } - rec := NewLootRecord(TypeNeutron, loot) - - line := rec.Marshal() - parsed, err := ParseRecord(line) - if err != nil { - t.Fatalf("ParseRecord: %v", err) - } - if parsed.Type != TypeNeutron { - t.Fatalf("type = %s, want neutron", parsed.Type) - } - if !parsed.Loot { - t.Fatal("loot flag not set") - } - - got, err := ParseRecordData[Loot](parsed) - if err != nil { - t.Fatalf("ParseRecordData: %v", err) - } - if got.Kind != LootVuln { - t.Fatalf("kind = %s, want vuln", got.Kind) - } - if got.Target != "http://10.0.0.1:8080" { - t.Fatalf("target = %s", got.Target) - } - if got.Priority != "high" { - t.Fatalf("priority = %s", got.Priority) - } - if got.Description != "CVE-2024-1234 — Remote Code Execution" { - t.Fatalf("description = %s", got.Description) - } - if got.Key() != "vuln|http://10.0.0.1:8080|http://10.0.0.1:8080|CVE-2024-1234" { - t.Fatalf("key = %s", got.Key()) - } -} - func TestLootJSONSchema(t *testing.T) { loot := Loot{ Kind: LootWeakpass, diff --git a/core/output/record.go b/core/output/record.go deleted file mode 100644 index a75c0bf9..00000000 --- a/core/output/record.go +++ /dev/null @@ -1,121 +0,0 @@ -package output - -import ( - "bufio" - "encoding/json" - "fmt" - "io" - "os" - "strings" - "time" -) - -type RecordType string - -const ( - TypeScanStart RecordType = "scan_start" - TypeGogo RecordType = "gogo" - TypeSpray RecordType = "spray" - TypeZombie RecordType = "zombie" - TypeNeutron RecordType = "neutron" - TypeScanEnd RecordType = "scan_end" - - TypeError RecordType = "error" -) - -type Record struct { - Type RecordType `json:"type"` - Timestamp time.Time `json:"ts"` - Loot bool `json:"loot,omitempty"` - Data json.RawMessage `json:"data"` - ID string `json:"id,omitempty"` - ScanID string `json:"scan_id,omitempty"` - SessionID string `json:"session_id,omitempty"` - AgentID string `json:"agent_id,omitempty"` - Source string `json:"source,omitempty"` - Target string `json:"target,omitempty"` - Turn int `json:"turn,omitempty"` - Priority string `json:"priority,omitempty"` - Summary string `json:"summary,omitempty"` - Tags []string `json:"tags,omitempty"` -} - -func NewRecord(t RecordType, data interface{}) Record { - raw, _ := json.Marshal(data) - return Record{ - Type: t, - Timestamp: time.Now(), - Data: raw, - } -} - -func NewLootRecord(t RecordType, data interface{}) Record { - r := NewRecord(t, data) - r.Loot = true - return r -} - -func (r Record) Marshal() []byte { - b, _ := json.Marshal(r) - return b -} - -func ParseRecord(line []byte) (Record, error) { - var r Record - err := json.Unmarshal(line, &r) - return r, err -} - -func ParseRecordData[T any](r Record) (T, error) { - var v T - err := json.Unmarshal(r.Data, &v) - return v, err -} - -func ParseRecordFile(path string) ([]Record, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - var records []Record - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 || line[0] != '{' { - continue - } - r, err := ParseRecord(line) - if err != nil { - continue - } - records = append(records, r) - } - return records, scanner.Err() -} - -func RenderFile(path, format, outputPath string) error { - var w io.Writer = os.Stdout - if outputPath != "" { - outFile, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("create output file: %w", err) - } - defer outFile.Close() - w = outFile - } - - entries, err := ParseTimelineFile(path) - if err != nil { - return err - } - - switch strings.ToLower(format) { - case "markdown", "md": - return RenderTimelineMarkdown(w, entries) - default: - return RenderTimeline(w, entries) - } -} diff --git a/core/output/report.go b/core/output/report.go deleted file mode 100644 index 12dd6d3b..00000000 --- a/core/output/report.go +++ /dev/null @@ -1,854 +0,0 @@ -package output - -import ( - "fmt" - "sort" - "strconv" - "strings" - "time" -) - -// ReportStyle selects the emitter RenderReport hands the neutral model to. -type ReportStyle uint8 - -const ( - // StyleANSI is the operator-facing terminal report. - StyleANSI ReportStyle = iota - // StyleMarkdown is the report shipped to the web UI and to tool output. - StyleMarkdown -) - -// ReportOptions is the single knob set behind every asset report. The three -// renderers this replaced each owned a feature nobody else had (the sitemap -// tree, zh/en text + bare-host folding, the counter table), so the flags are -// what a caller opts into rather than what a style implies. -type ReportOptions struct { - Style ReportStyle - // Color enables ANSI escapes. StyleMarkdown ignores it — markdown output - // is never colorized. - Color bool - // Lang is "zh" or "en" (anything else, including "", means "en"). - // StyleANSI is English-only, so it ignores this. - Lang string - // Title is the report subject: the scan target for a web job, or a plain - // report name. Mode is the scan mode ("quick" / "full"); leaving Mode empty - // suppresses the target/mode/timestamp line and makes Title the bare H1. - // Markdown only. - Title string - Mode string - // Sitemap renders the per-asset path tree. - Sitemap bool - // CollapseBare folds live hosts that answered with nothing but non-web - // services into a trailing list instead of giving each one a section. - // Markdown only. - CollapseBare bool - // Metrics adds the counter table. Markdown only. - Metrics bool - // Inventory adds the flat per-kind sections (services / web evidence / - // findings / errors) the scan tool report carries. Markdown only. - Inventory bool -} - -// RenderReport walks Result → Asset → AssetItem exactly once into a neutral -// model, then emits it in the requested style. -func RenderReport(result *Result, opts ReportOptions) string { - model := buildReportModel(result, opts) - var report string - if opts.Style == StyleMarkdown { - report = renderMarkdownReport(model, opts) - } else { - report = renderANSIReport(model, opts) - } - return strings.TrimRight(report, " \t\r\n") + "\n" -} - -// --- neutral model --- - -type reportModel struct { - nilResult bool - summary Summary - total int - hosts int - fingers int - assets []reportAsset - bare []reportAsset -} - -type reportAsset struct { - title string // Title > Target > Key — the headline - label string // Target > Title > Key — the bare-host list entry - target string - status string - paths int - services []string - statuses []string - fingers []string - items []reportItem - sitemap *sitemapNode - isBare bool -} - -// reportItem is the per-item extraction — the part that used to exist in three -// places. text is the one-line rendering, name the short label used where a -// full line will not fit (sitemap annotations). -type reportItem struct { - kind string - label string // note-like items: Source > Kind - status string - target string - text string - name string - detail string - length int - fingers []string - validated bool -} - -func buildReportModel(result *Result, opts ReportOptions) reportModel { - if result == nil { - return reportModel{nilResult: true} - } - model := reportModel{summary: result.Summary, total: len(result.Assets)} - - hosts := make(map[string]struct{}) - fingers := make(map[string]struct{}) - for _, asset := range result.Assets { - item := buildReportAsset(asset, opts.Sitemap) - if host := reportAssetHost(asset); host != "" { - hosts[host] = struct{}{} - } - for _, finger := range item.fingers { - fingers[strings.ToLower(finger)] = struct{}{} - } - if opts.CollapseBare && item.isBare { - model.bare = append(model.bare, item) - continue - } - model.assets = append(model.assets, item) - } - - // An asset whose target parses to nothing still counts as a host. - model.hosts = len(hosts) - if model.hosts == 0 { - model.hosts = len(result.Assets) - } - model.fingers = len(fingers) - return model -} - -func buildReportAsset(asset Asset, sitemap bool) reportAsset { - out := reportAsset{ - title: FirstNonEmpty(asset.Title, asset.Target, asset.Key), - label: FirstNonEmpty(asset.Target, asset.Title, asset.Key), - target: asset.Target, - status: asset.Status, - } - - var services, statuses, fingers []string - annotations := make(map[string][]string) - hasService, onlyPlainServices := false, true - - for _, item := range asset.Items { - entry := reportItem{kind: item.Kind, status: item.Status, target: item.Target} - switch item.Kind { - case AssetItemService: - hasService = true - facts := strings.Join(CompactStrings( - AssetDataString(item.Data, "protocol"), - AssetDataString(item.Data, "service"), - AssetDataString(item.Data, "port"), - ), " ") - services = append(services, facts) - // A service with no structured facts still has a name to show. - entry.text = FirstNonEmpty(facts, item.Title, item.Target, item.Raw) - if isWebServiceItem(item) { - onlyPlainServices = false - } - case AssetItemFingerprint: - onlyPlainServices = false - entry.text = FirstNonEmpty(item.Title, item.Summary, AssetDataString(item.Data, "name"), item.Target) - entry.name = entry.text - fingers = append(fingers, entry.text) - if path := pathFromTarget(item.Target, asset.Target); path != "" { - annotations[path] = appendUniq(annotations[path], entry.text) - } - case AssetItemPath: - onlyPlainServices = false - out.paths++ - entry.text = FirstNonEmpty(AssetDataString(item.Data, "path"), WebPath(item.Target), item.Target) - entry.name = item.Title - entry.length = AssetDataInt(item.Data, "length") - entry.fingers = AssetDataStrings(item.Data, "fingers") - entry.validated = HasTag(item.Tags, "validated") - fingers = append(fingers, entry.fingers...) - if item.Status != "" { - statuses = append(statuses, item.Status) - } - case AssetItemLoot, AssetItemNote, AssetItemResponse, AssetItemError: - onlyPlainServices = false - entry.label = FirstNonEmpty(item.Source, item.Kind) - entry.detail = AssetItemDetail(item) - entry.text = FirstNonEmpty(item.Summary, item.Title, firstContentLine(entry.detail), item.Raw) - entry.name = FirstNonEmpty(item.Title, item.Summary) - if item.Kind != AssetItemError { - path := lootAnnotationPath(item, asset.Target) - annotations[path] = appendUniq(annotations[path], lootAnnotation(entry)) - } - default: - onlyPlainServices = false - entry.text = FirstNonEmpty(item.Summary, item.Title, item.Raw) - } - out.items = append(out.items, entry) - } - - out.isBare = hasService && onlyPlainServices - out.services = CompactStrings(services...) - out.statuses = CompactStrings(statuses...) - out.fingers = CompactStrings(fingers...) - if sitemap { - out.sitemap = buildSitemapTree(out.items, annotations) - } - return out -} - -// isWebServiceItem reports whether a service item is an HTTP-ish one, which is -// what keeps its host out of the "bare live host" bucket. -func isWebServiceItem(item AssetItem) bool { - svc := strings.ToLower(AssetDataString(item.Data, "service") + " " + AssetDataString(item.Data, "protocol")) - return strings.Contains(svc, "http") -} - -func lootAnnotationPath(item AssetItem, assetTarget string) string { - if path := pathFromTarget(item.Target, assetTarget); path != "" { - return path - } - return "/" -} - -// lootAnnotation is the compact "{skill:status summary}" tag hung off a -// sitemap node. Long summaries are dropped rather than wrapped. -func lootAnnotation(entry reportItem) string { - label := entry.label - if entry.status != "" { - label += ":" + entry.status - } - if entry.name != "" && len(entry.name) <= 40 { - label += " " + entry.name - } - return label -} - -// reportAssetHost reduces an asset to its host, so an IP that answered on both -// icmp and http counts once. -func reportAssetHost(asset Asset) string { - value := FirstNonEmpty(asset.Target, asset.Key, asset.Title) - if i := strings.Index(value, "://"); i >= 0 { - value = value[i+3:] - } - if i := strings.IndexAny(value, "/?#"); i >= 0 { - value = value[:i] - } - if strings.Count(value, ":") == 1 { // host:port — drop the port, leave IPv6 alone - value = value[:strings.LastIndex(value, ":")] - } - return value -} - -// --- ANSI emitter --- - -func renderANSIReport(model reportModel, opts ReportOptions) string { - if model.nilResult { - return "Assets: 0 total\n" - } - c := NewColor(opts.Color) - - var sb strings.Builder - fmt.Fprintf(&sb, "Assets: %d total\n", model.total) - fmt.Fprintf(&sb, "Summary: %d target(s), %d service(s), %d web endpoint(s), %d probe(s), %d loot(s), %d error(s), %s\n\n", - model.summary.Targets, - model.summary.Services, - model.summary.Webs, - model.summary.Probes, - model.summary.Loots, - model.summary.Errors, - model.summary.Duration, - ) - if model.total == 0 { - return sb.String() - } - - for i, asset := range model.assets { - fmt.Fprintf(&sb, "%d. %s\n", i+1, c.GreenBold(asset.title)) - if asset.target != "" && asset.target != asset.title { - fmt.Fprintf(&sb, " target: %s\n", asset.target) - } - if asset.status != "" { - fmt.Fprintf(&sb, " status: %s\n", asset.status) - } - for _, item := range asset.items { - writeANSIItem(&sb, item, c) - } - if asset.sitemap != nil { - sb.WriteString(" sitemap:\n") - renderSitemapNode(&sb, asset.sitemap, " ", true, c) - } - if i < len(model.assets)-1 { - sb.WriteByte('\n') - } - } - return sb.String() -} - -func writeANSIItem(sb *strings.Builder, item reportItem, c Color) { - switch item.kind { - case AssetItemPath: - return - case AssetItemService: - fmt.Fprintf(sb, " %s %s\n", c.Cyan("service:"), item.text) - case AssetItemFingerprint: - fmt.Fprintf(sb, " %s %s\n", c.Cyan("fingerprint:"), item.text) - case AssetItemLoot, AssetItemNote, AssetItemResponse: - line := item.text - if item.status != "" { - line = c.Yellow("["+item.status+"]") + " " + line - } - fmt.Fprintf(sb, " %s %s\n", c.Yellow(item.label+":"), line) - if item.detail != "" && item.detail != line && !strings.Contains(line, item.detail) { - for _, detailLine := range strings.Split(strings.TrimSpace(item.detail), "\n") { - if detailLine = strings.TrimSpace(detailLine); detailLine != "" { - fmt.Fprintf(sb, " %s\n", c.Dim(detailLine)) - } - } - } - case AssetItemError: - fmt.Fprintf(sb, " %s %s\n", c.Red("error:"), item.text) - } -} - -// --- markdown emitter --- - -// reportLang is the whole i18n surface: one flag, one lookup. -type reportLang struct{ zh bool } - -func newReportLang(lang string) reportLang { - return reportLang{zh: strings.HasPrefix(strings.ToLower(lang), "zh")} -} - -func (t reportLang) tr(zh, en string) string { - if t.zh { - return zh - } - return en -} - -func (t reportLang) sep() string { return t.tr(":", ": ") } - -func (t reportLang) modeName(mode string) string { - if strings.EqualFold(mode, "full") { - return t.tr("全面侦察", "Full recon") - } - return t.tr("快速侦察", "Quick recon") -} - -// renderMarkdownReport writes an operator-facing report: prose instead of a -// metric dump, no internal scanner names leaking into the text. -func renderMarkdownReport(model reportModel, opts ReportOptions) string { - t := newReportLang(opts.Lang) - - var sb strings.Builder - writeMarkdownHeader(&sb, t, opts) - if model.nilResult { - sb.WriteString(t.tr("本次扫描未返回结构化结果。\n", "No structured result was returned.\n")) - return sb.String() - } - - sb.WriteString("## " + t.tr("概述", "Overview") + "\n\n") - var overview strings.Builder - writeMarkdownOverview(&overview, t, model) - sb.WriteString(strings.TrimSpace(overview.String())) - sb.WriteString("\n\n") - - if opts.Metrics { - writeMarkdownMetrics(&sb, t, model) - } - if len(model.assets) > 0 { - sb.WriteString("## " + t.tr("资产明细", "Assets") + "\n\n") - for _, asset := range model.assets { - writeMarkdownAsset(&sb, t, asset, opts) - } - } - if len(model.bare) > 0 { - sb.WriteString("## " + t.tr("其他存活主机", "Other live hosts") + "\n\n") - for _, asset := range model.bare { - if len(asset.services) > 0 { - fmt.Fprintf(&sb, "- `%s` · %s\n", asset.label, strings.Join(asset.services, ", ")) - continue - } - fmt.Fprintf(&sb, "- `%s`\n", asset.label) - } - sb.WriteString("\n") - } - if opts.Inventory { - writeMarkdownInventory(&sb, t, model) - } - return sb.String() -} - -func writeMarkdownHeader(sb *strings.Builder, t reportLang, opts ReportOptions) { - if opts.Mode == "" { - fmt.Fprintf(sb, "# %s\n\n", FirstNonEmpty(opts.Title, t.tr("侦察报告", "Recon report"))) - sb.WriteString("---\n\n") - return - } - fmt.Fprintf(sb, "# %s%s\n\n", t.tr("侦察报告 · ", "Recon report · "), FirstNonEmpty(opts.Title, t.tr("目标", "target"))) - fmt.Fprintf(sb, "%s `%s` · %s · %s\n\n", - t.tr("目标", "Target"), opts.Title, - t.modeName(opts.Mode), - time.Now().Format("2006-01-02 15:04:05")) - sb.WriteString("---\n\n") -} - -// writeMarkdownOverview is the executive summary — one flowing paragraph that -// names only the numbers actually present, so a clean scan reads like a -// sentence rather than a table full of zeros. -func writeMarkdownOverview(sb *strings.Builder, t reportLang, model reportModel) { - s := model.summary - if t.zh { - fmt.Fprintf(sb, "本次侦察共识别 %d 台主机、%d 个开放服务", model.hosts, s.Services) - if s.Webs > 0 { - fmt.Fprintf(sb, "(含 %d 个 Web 站点)", s.Webs) - } - sb.WriteString("。") - if s.Probes > 0 { - fmt.Fprintf(sb, "累计探测 %d 条路径", s.Probes) - if model.fingers > 0 { - fmt.Fprintf(sb, "、命中 %d 项 Web 指纹", model.fingers) - } - sb.WriteString("。") - } else if model.fingers > 0 { - fmt.Fprintf(sb, "命中 %d 项 Web 指纹。", model.fingers) - } - if s.Loots > 0 { - fmt.Fprintf(sb, "**发现 %d 项需优先复核的安全发现(凭证 / 弱口令 / 漏洞)。**", s.Loots) - } - if s.Errors > 0 { - fmt.Fprintf(sb, "另有 %d 处探测报错。", s.Errors) - } - if s.Duration != "" { - fmt.Fprintf(sb, "全程耗时 %s。", s.Duration) - } - return - } - - fmt.Fprintf(sb, "The scan identified %s across %s", reportPlural(model.hosts, "host", "hosts"), reportPlural(s.Services, "open service", "open services")) - if s.Webs > 0 { - fmt.Fprintf(sb, " (%s)", reportPlural(s.Webs, "web site", "web sites")) - } - sb.WriteString(". ") - if s.Probes > 0 { - fmt.Fprintf(sb, "It probed %s", reportPlural(s.Probes, "path", "paths")) - if model.fingers > 0 { - fmt.Fprintf(sb, " and matched %s", reportPlural(model.fingers, "fingerprint", "fingerprints")) - } - sb.WriteString(". ") - } else if model.fingers > 0 { - fmt.Fprintf(sb, "It matched %s. ", reportPlural(model.fingers, "fingerprint", "fingerprints")) - } - if s.Loots > 0 { - fmt.Fprintf(sb, "**%s surfaced (credentials / weak passwords / vulnerabilities) — review these first.** ", reportPlural(s.Loots, "security finding", "security findings")) - } - if s.Errors > 0 { - fmt.Fprintf(sb, "%s occurred during probing. ", reportPlural(s.Errors, "error", "errors")) - } - if s.Duration != "" { - fmt.Fprintf(sb, "The scan took %s.", s.Duration) - } -} - -func writeMarkdownMetrics(sb *strings.Builder, t reportLang, model reportModel) { - s := model.summary - sb.WriteString("## " + t.tr("指标", "Metrics") + "\n\n") - fmt.Fprintf(sb, "| %s | %s |\n", t.tr("指标", "Metric"), t.tr("数值", "Value")) - sb.WriteString("| --- | ---: |\n") - for _, row := range []struct { - label string - value any - }{ - {t.tr("输入目标", "Inputs"), s.Targets}, - {t.tr("开放服务", "Open services"), s.Services}, - {t.tr("Web 站点", "Web endpoints"), s.Webs}, - {t.tr("路径探测", "Web probes"), s.Probes}, - {t.tr("Web 指纹", "Fingerprints"), model.fingers}, - {t.tr("安全发现", "Loots"), s.Loots}, - {t.tr("错误", "Errors"), s.Errors}, - {t.tr("任务", "Tasks"), s.Tasks}, - {t.tr("请求", "Requests"), s.Requests}, - {t.tr("耗时", "Duration"), s.Duration}, - } { - fmt.Fprintf(sb, "| %s | %v |\n", row.label, row.value) - } - sb.WriteString("\n") -} - -func writeMarkdownAsset(sb *strings.Builder, t reportLang, asset reportAsset, opts ReportOptions) { - title := FirstNonEmpty(asset.title, t.tr("资产", "Asset")) - if asset.target != "" && asset.target != title { - fmt.Fprintf(sb, "### %s — `%s`\n\n", title, asset.target) - } else { - fmt.Fprintf(sb, "### %s\n\n", title) - } - - writeMarkdownFact(sb, t, t.tr("开放服务", "Services"), asset.services) - writeMarkdownFact(sb, t, t.tr("HTTP 响应", "HTTP"), asset.statuses) - writeMarkdownFact(sb, t, t.tr("Web 指纹", "Fingerprints"), asset.fingers) - if asset.paths > 0 { - fmt.Fprintf(sb, "- %s%s%s\n", t.tr("已探测路径", "Paths"), t.sep(), t.tr(fmt.Sprintf("%d 条", asset.paths), strconv.Itoa(asset.paths))) - } - if asset.status != "" { - fmt.Fprintf(sb, "- %s%s%s\n", t.tr("状态", "State"), t.sep(), markdownCode(asset.status)) - } - sb.WriteString("\n") - - if opts.Sitemap && asset.sitemap != nil { - sb.WriteString("#### " + t.tr("站点地图", "Sitemap") + "\n\n```text\n") - renderSitemapNode(sb, asset.sitemap, "", true, NewColor(false)) - sb.WriteString("```\n\n") - } - writeMarkdownAnalysis(sb, t, asset.items) -} - -func writeMarkdownFact(sb *strings.Builder, t reportLang, label string, values []string) { - if len(values) == 0 { - return - } - coded := make([]string, 0, len(values)) - for _, value := range values { - coded = append(coded, markdownCode(value)) - } - fmt.Fprintf(sb, "- %s%s%s\n", label, t.sep(), strings.Join(coded, t.tr("、", ", "))) -} - -func writeMarkdownAnalysis(sb *strings.Builder, t reportLang, items []reportItem) { - wrote := false - for _, item := range items { - switch item.kind { - case AssetItemLoot, AssetItemNote, AssetItemResponse, AssetItemError: - default: - continue - } - if item.text == "" { - continue - } - if !wrote { - sb.WriteString("#### " + t.tr("分析研判", "Analysis") + "\n\n") - wrote = true - } - fmt.Fprintf(sb, "##### %s\n\n", markdownHeading(item.text)) - switch { - case item.detail != "" && strings.TrimSpace(item.text) != strings.TrimSpace(item.detail): - sb.WriteString(item.detail) - sb.WriteString("\n\n") - case item.detail == "": - sb.WriteString(item.text) - sb.WriteString("\n\n") - } - } -} - -// writeMarkdownInventory is the flat cross-asset listing the scan tool report -// has always carried: every service, probe, finding and error in one place. -func writeMarkdownInventory(sb *strings.Builder, t reportLang, model reportModel) { - assets := make([]reportAsset, 0, len(model.assets)+len(model.bare)) - assets = append(assets, model.assets...) - assets = append(assets, model.bare...) - - var services, paths, findings, errors []string - for _, asset := range assets { - for _, item := range asset.items { - switch item.kind { - case AssetItemService: - services = append(services, fmt.Sprintf("- %s · %s\n", - markdownCode(FirstNonEmpty(item.target, asset.label)), item.text)) - case AssetItemPath: - paths = append(paths, "- "+strings.Join(pathInventoryParts(item), " · ")+"\n") - case AssetItemLoot, AssetItemNote, AssetItemResponse: - findings = append(findings, markdownStatusLine(findingInventoryLine(item), item.status)) - case AssetItemError: - errors = append(errors, "- "+item.text+"\n") - } - } - } - - writeMarkdownSection(sb, t.tr("开放服务", "Open Services"), services) - writeMarkdownSection(sb, t.tr("Web 证据", "Web Evidence"), paths) - writeMarkdownSection(sb, t.tr("安全发现", "Findings"), findings) - writeMarkdownSection(sb, t.tr("错误", "Errors"), errors) -} - -func pathInventoryParts(item reportItem) []string { - parts := []string{markdownCode(FirstNonEmpty(item.target, item.text))} - if item.status != "" { - parts = append(parts, markdownCode(item.status)) - } - if item.name != "" && !isStaticTitle(item.name) { - parts = append(parts, strconv.Quote(item.name)) - } - if len(item.fingers) > 0 { - parts = append(parts, markdownCode(strings.Join(item.fingers, ","))) - } - return parts -} - -func findingInventoryLine(item reportItem) string { - line := item.text - if item.target != "" { - line += " — " + markdownCode(item.target) - } - return line -} - -// markdownStatusLine carries the verification verdict into the bullet, so an -// unconfirmed finding cannot be mistaken for a proven one. -func markdownStatusLine(line, status string) string { - if line == "" { - return "" - } - switch status { - case "not_confirmed": - return "- ~~" + line + "~~ *(not confirmed)*\n" - case "confirmed": - return "- **[verified]** " + line + "\n" - case "inconclusive": - return "- **[inconclusive]** " + line + "\n" - case "failed": - return "- **[verification failed]** " + line + "\n" - default: - return "- " + line + "\n" - } -} - -func writeMarkdownSection(sb *strings.Builder, heading string, lines []string) { - if len(lines) == 0 { - return - } - sb.WriteString("## " + heading + "\n\n") - for _, line := range lines { - sb.WriteString(line) - } - sb.WriteString("\n") -} - -func markdownCode(value string) string { - value = strings.ReplaceAll(value, "`", "'") - return "`" + value + "`" -} - -func markdownHeading(value string) string { - value = strings.TrimSpace(value) - value = strings.ReplaceAll(value, "\n", " ") - if value == "" { - return "Analysis" - } - return strings.TrimLeft(value, "# ") -} - -func reportPlural(n int, one, many string) string { - if n == 1 { - return fmt.Sprintf("%d %s", n, one) - } - return fmt.Sprintf("%d %s", n, many) -} - -// --- sitemap tree --- - -type sitemapNode struct { - segment string - status string - length int - title string - fingers []string - validated bool - isLeaf bool - annotations []string - children []*sitemapNode -} - -// buildSitemapTree folds the asset's path items into a directory tree and hangs -// the fingerprint / finding annotations off the node they were found on. -// Returns nil when the asset has no paths, so callers can skip the section. -func buildSitemapTree(items []reportItem, annotations map[string][]string) *sitemapNode { - paths := make([]reportItem, 0, len(items)) - for _, item := range items { - if item.kind == AssetItemPath && item.text != "" { - paths = append(paths, item) - } - } - if len(paths) == 0 { - return nil - } - sort.Slice(paths, func(i, j int) bool { return paths[i].text < paths[j].text }) - - root := &sitemapNode{segment: "/"} - for _, item := range paths { - node := root - for _, part := range splitPath(item.text) { - child := findSitemapChild(node, part) - if child == nil { - child = &sitemapNode{segment: part} - node.children = append(node.children, child) - } - node = child - } - node.isLeaf = true - node.status = item.status - node.length = item.length - node.title = item.name - node.fingers = mergeStrings(node.fingers, item.fingers) - node.validated = node.validated || item.validated - } - attachSitemapAnnotations(root, annotations) - return root -} - -func attachSitemapAnnotations(root *sitemapNode, annotations map[string][]string) { - if values, ok := annotations["/"]; ok { - root.annotations = append(root.annotations, values...) - } - for path, values := range annotations { - if path == "/" { - continue - } - node := root - for _, part := range splitPath(path) { - child := findSitemapChild(node, part) - if child == nil { - child = &sitemapNode{segment: part, isLeaf: true} - node.children = append(node.children, child) - } - node = child - } - node.annotations = append(node.annotations, values...) - } -} - -func renderSitemapNode(sb *strings.Builder, node *sitemapNode, indent string, isRoot bool, c Color) { - var line strings.Builder - - line.WriteString(indent) - if !isRoot { - line.WriteString("├── ") - } - - if node.isLeaf && node.status != "" { - line.WriteString(c.Status(fmt.Sprintf("[%-3s]", node.status))) - } else { - line.WriteString(" ") - } - line.WriteString(" ") - - path := "/" + node.segment - if isRoot { - path = "/" - } - switch { - case node.validated: - line.WriteString(c.GreenBold(path)) - case node.isLeaf: - line.WriteString(path) - default: - line.WriteString(c.Dim(path)) - } - - if node.isLeaf && node.length > 0 { - line.WriteString(" " + c.YellowBold(strconv.Itoa(node.length))) - } - if node.title != "" && !isStaticTitle(node.title) { - line.WriteString(" " + c.Green(strconv.Quote(node.title))) - } - if len(node.fingers) > 0 { - line.WriteString(" " + c.Cyan("["+strings.Join(node.fingers, ",")+"]")) - } - for _, annotation := range node.annotations { - line.WriteString(" " + c.Yellow("{"+annotation+"}")) - } - - sb.WriteString(line.String()) - sb.WriteByte('\n') - - for _, child := range node.children { - childIndent := indent - if !isRoot { - childIndent += "│ " - } - renderSitemapNode(sb, child, childIndent, false, c) - } -} - -func findSitemapChild(node *sitemapNode, segment string) *sitemapNode { - for _, child := range node.children { - if child.segment == segment { - return child - } - } - return nil -} - -func splitPath(p string) []string { - p = strings.Trim(p, "/") - if p == "" { - return nil - } - parts := strings.Split(p, "/") - if idx := strings.Index(parts[len(parts)-1], "?"); idx >= 0 { - parts[len(parts)-1] = parts[len(parts)-1][:idx] - } - return parts -} - -func pathFromTarget(target, assetTarget string) string { - if target == "" { - return "" - } - p := WebPath(target) - if p == target && assetTarget != "" && strings.HasPrefix(target, assetTarget) { - p = strings.TrimPrefix(target, assetTarget) - if p == "" { - p = "/" - } - } - return p -} - -func isStaticTitle(title string) bool { - switch strings.ToLower(title) { - case "js data", "css data", "ico data", "image data": - return true - } - return false -} - -func mergeStrings(a, b []string) []string { - if len(b) == 0 { - return a - } - seen := make(map[string]struct{}, len(a)) - for _, s := range a { - seen[strings.ToLower(s)] = struct{}{} - } - for _, s := range b { - if _, ok := seen[strings.ToLower(s)]; !ok { - a = append(a, s) - seen[strings.ToLower(s)] = struct{}{} - } - } - return a -} - -func appendUniq(slice []string, val string) []string { - for _, s := range slice { - if s == val { - return slice - } - } - return append(slice, val) -} diff --git a/core/output/report_golden_test.go b/core/output/report_golden_test.go deleted file mode 100644 index 238ec2ed..00000000 --- a/core/output/report_golden_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package output - -import ( - "encoding/json" - "flag" - "os" - "path/filepath" - "regexp" - "strings" - "testing" -) - -// updateReportGolden rewrites the .golden files instead of comparing against -// them, so the diff of a deliberate rendering change is reviewable on its own. -var updateReportGolden = flag.Bool("update-report-golden", false, "rewrite report golden files") - -// LoadReportFixture reads one of the shared report fixtures. It lives in -// core/output because that is where the fixtures live, but pkg/web reads the -// same files so the two renderers are pinned against identical input. -func loadReportFixture(t *testing.T, name string) *Result { - t.Helper() - raw, err := os.ReadFile(filepath.Join("testdata", name+".json")) - if err != nil { - t.Fatalf("read fixture: %v", err) - } - result := &Result{} - if err := json.Unmarshal(raw, result); err != nil { - t.Fatalf("decode fixture: %v", err) - } - return result -} - -// reportStamp matches the "generated at" header timestamp, the one part of the -// markdown report that cannot be pinned. -var reportStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`) - -func checkReportGolden(t *testing.T, name, got string) { - t.Helper() - got = reportStamp.ReplaceAllString(got, "") - path := filepath.Join("testdata", name+".golden") - if *updateReportGolden { - if err := os.WriteFile(path, []byte(got), 0o644); err != nil { - t.Fatalf("write golden: %v", err) - } - return - } - want, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read golden (run go test -run %s -update-report-golden): %v", t.Name(), err) - } - wantText := strings.ReplaceAll(string(want), "\r\n", "\n") - if got != wantText { - t.Errorf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, got, wantText) - } -} - -// TestAssetReportGolden pins the terminal asset report. FormatAssetReport is a -// wrapper over RenderReport, so this is also the ANSI emitter's contract. -func TestAssetReportGolden(t *testing.T) { - for _, tc := range []struct { - name string - fixture string - color bool - }{ - {name: "asset_plain", fixture: "report_fixture"}, - {name: "asset_color", fixture: "report_fixture", color: true}, - {name: "asset_empty", fixture: "report_empty"}, - } { - t.Run(tc.name, func(t *testing.T) { - checkReportGolden(t, tc.name, FormatAssetReport(loadReportFixture(t, tc.fixture), tc.color)) - }) - } -} - -// TestRenderReportMarkdownGolden pins the markdown emitter under the option -// sets its two callers use. The web_* goldens must stay byte-identical to -// pkg/web/testdata/web_*.golden — that is the cross-package parity check. -func TestRenderReportMarkdownGolden(t *testing.T) { - web := ReportOptions{Style: StyleMarkdown, Title: "10.0.0.1", Mode: "quick", CollapseBare: true} - tool := ReportOptions{Style: StyleMarkdown, Title: "Scan Report", Sitemap: true, CollapseBare: true, Metrics: true, Inventory: true} - - for _, tc := range []struct { - name string - fixture string - opts ReportOptions - nilRes bool - }{ - {name: "md_web_zh", fixture: "report_fixture", opts: withLang(web, "zh")}, - {name: "md_web_en", fixture: "report_fixture", opts: withLang(web, "en")}, - {name: "md_web_empty_zh", fixture: "report_empty", opts: withLang(web, "zh")}, - {name: "md_web_empty_en", fixture: "report_empty", opts: withLang(web, "en")}, - {name: "md_web_nil", opts: withLang(web, "en"), nilRes: true}, - {name: "md_tool", fixture: "report_fixture", opts: tool}, - {name: "md_tool_empty", fixture: "report_empty", opts: tool}, - } { - t.Run(tc.name, func(t *testing.T) { - var result *Result - if !tc.nilRes { - result = loadReportFixture(t, tc.fixture) - } - checkReportGolden(t, tc.name, RenderReport(result, tc.opts)) - }) - } -} - -func withLang(opts ReportOptions, lang string) ReportOptions { - opts.Lang = lang - return opts -} - -func TestAssetReportNilResult(t *testing.T) { - if got := FormatAssetReport(nil, false); got != "Assets: 0 total\n" { - t.Fatalf("nil result = %q", got) - } -} diff --git a/core/output/sco_sidecar.go b/core/output/sco_sidecar.go index fb17191b..468f5205 100644 --- a/core/output/sco_sidecar.go +++ b/core/output/sco_sidecar.go @@ -48,10 +48,13 @@ func (s *SCOSidecar) handle(ev ToolDataEvent) { if json.Unmarshal(raw, &header) != nil || header.ID == "" { continue } - if _, dup := s.seen[header.ID]; dup { + // A libcstx node may be observed by many operations. Deduplicate only + // inside one operation so every operation keeps its own provenance edge. + key := ev.CallID + "\x00" + header.ID + if _, dup := s.seen[key]; dup { continue } - s.seen[header.ID] = struct{}{} + s.seen[key] = struct{}{} s.nodes = append(s.nodes, raw) fresh = append(fresh, raw) } diff --git a/core/output/timeline.go b/core/output/timeline.go index 72e09872..fa3958c7 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -11,8 +11,7 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" - "github.com/chainreactors/utils/parsers" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" "google.golang.org/protobuf/encoding/protojson" @@ -63,41 +62,9 @@ func parseLine(line []byte) (TimelineEntry, bool) { } return TimelineEntry{Timestamp: timestamp, Type: aop.Kind(event), Data: event}, true } - rec, err := ParseRecord(line) - if err != nil || rec.Type == "" { - return TimelineEntry{}, false - } - if item := parseRecordData(rec); item != nil { - return TimelineEntry{Timestamp: rec.Timestamp, Type: string(rec.Type), Data: item}, true - } return TimelineEntry{}, false } -func parseRecordData(rec Record) any { - if rec.Loot { - return unmarshalItem[parsers.Loot](rec.Data) - } - switch rec.Type { - case TypeScanStart: - return unmarshalItem[ScanStart](rec.Data) - case TypeGogo: - return unmarshalItem[parsers.GOGOResult](rec.Data) - case TypeSpray: - return unmarshalItem[parsers.SprayResult](rec.Data) - case TypeScanEnd: - return unmarshalItem[ScanEnd](rec.Data) - } - return nil -} - -func unmarshalItem[T any](data json.RawMessage) *T { - var v T - if json.Unmarshal(data, &v) != nil { - return nil - } - return &v -} - // --------------------------------------------------------------------------- // Render entry points // --------------------------------------------------------------------------- @@ -112,6 +79,28 @@ func RenderTimelineMarkdown(w io.Writer, entries []TimelineEntry) error { return err } +// RenderFile renders an AOP Event ProtoJSONL file. Scanner fact files are +// libcstx SCO JSONL and are intentionally not accepted as agent timelines. +func RenderFile(path, format, outputPath string) error { + var writer io.Writer = os.Stdout + if outputPath != "" { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create output file: %w", err) + } + defer file.Close() + writer = file + } + entries, err := ParseTimelineFile(path) + if err != nil { + return err + } + if strings.EqualFold(format, "markdown") || strings.EqualFold(format, "md") { + return RenderTimelineMarkdown(writer, entries) + } + return RenderTimeline(writer, entries) +} + func BuildTimelineMarkdown(entries []TimelineEntry) string { var sb strings.Builder sess := collectSessionMeta(entries) @@ -119,18 +108,8 @@ func BuildTimelineMarkdown(entries []TimelineEntry) string { for _, e := range entries { switch d := e.Data.(type) { - case *ScanStart: - d.writeMarkdown(&sb) - case *parsers.GOGOResult: - writeGogoMarkdown(&sb, d) - case *parsers.SprayResult: - writeSprayMarkdown(&sb, d) - case *parsers.Loot: - writeLootMarkdown(&sb, d) case *aop.Event: writeAOPMarkdown(&sb, d) - case *ScanEnd: - d.writeMarkdown(&sb) } } return sb.String() @@ -165,19 +144,6 @@ func writeHeader(sb *strings.Builder, sess *sessionMeta) { } } -// --------------------------------------------------------------------------- -// Scan types -// --------------------------------------------------------------------------- - -func (d *ScanStart) writeMarkdown(sb *strings.Builder) { - sb.WriteString(fmt.Sprintf("- **scan** targets=%s mode=%s\n", strings.Join(d.Targets, ", "), d.Mode)) -} - -func (d *ScanEnd) writeMarkdown(sb *strings.Builder) { - sb.WriteString(fmt.Sprintf("\n> **scan done** %.1fs — %d services, %d webs, %d loots\n\n", - d.Duration, d.Services, d.Webs, d.Loots)) -} - // --------------------------------------------------------------------------- // Session metadata // --------------------------------------------------------------------------- @@ -429,8 +395,6 @@ func aopContentText(content []*aop.Content) string { for _, item := range content { if text := item.GetText().GetText(); text != "" { parts = append(parts, text) - } else if opaque := item.GetOpaque(); opaque != nil { - parts = append(parts, string(opaque.Value.GetData())) } } return strings.Join(parts, "\n") diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index a9844674..c42eb0b7 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -6,7 +6,7 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -63,17 +63,3 @@ func timelineEvent(event *aop.Event) *aop.Event { event.EmittedAt = timestamppb.New(time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)) return event } - -func TestParseLineRejectsLegacyAgentRecord(t *testing.T) { - record := NewRecord(RecordType("agent"), map[string]any{"type": "message_end"}) - if _, ok := parseLine(record.Marshal()); ok { - t.Fatal("legacy agent record should not be accepted") - } -} - -func TestParseLineRejectsLegacyAOPRecordPrefix(t *testing.T) { - record := NewRecord(RecordType("aop.text"), map[string]any{"content": "legacy"}) - if _, ok := parseLine(record.Marshal()); ok { - t.Fatal("legacy aop.* record should not be accepted after native AOP cutover") - } -} diff --git a/core/output/types.go b/core/output/types.go index d061ffa4..59c117f2 100644 --- a/core/output/types.go +++ b/core/output/types.go @@ -1,28 +1,28 @@ package output import ( - "encoding/json" "time" "github.com/chainreactors/utils/parsers" ) -type Result struct { - Summary Summary `json:"summary"` - Assets []Asset `json:"assets,omitempty"` - Nodes []json.RawMessage `json:"nodes,omitempty"` - Services []*parsers.GOGOResult `json:"services,omitempty"` - WebProbes []*parsers.SprayResult `json:"web_probes,omitempty"` - Loots []Loot `json:"loots,omitempty"` - Errors []Error `json:"errors,omitempty"` +// ScanResult is private collector state. It never crosses an AOP, ConnectRPC, +// persistence, or frontend boundary; scanner-native values are transformed to +// libcstx SCO nodes before leaving the process. +type ScanResult struct { + Summary Summary `json:"summary"` + GOGO []*parsers.GOGOResult `json:"gogo,omitempty"` + Spray []*parsers.SprayResult `json:"spray,omitempty"` + Loots []Loot `json:"loots,omitempty"` + Errors []Error `json:"errors,omitempty"` } type Summary struct { - Targets int `json:"targets"` - Services int `json:"services"` - Webs int `json:"webs"` - Probes int `json:"probes"` - Loots int `json:"loots"` + Inputs int `json:"inputs"` + Ports int `json:"ports"` + Web int `json:"web"` + URLs int `json:"urls"` + Findings int `json:"findings"` Errors int `json:"errors"` Tasks int64 `json:"tasks"` Requests int64 `json:"requests"` @@ -39,56 +39,7 @@ const ( LootVuln = parsers.LootVuln ) -type Asset struct { - ID string `json:"id"` - Key string `json:"key"` - Target string `json:"target"` - Title string `json:"title,omitempty"` - Status string `json:"status,omitempty"` - Items []AssetItem `json:"items,omitempty"` -} - -const ( - AssetItemService = "service" - AssetItemPath = "path" - AssetItemFingerprint = "fingerprint" - AssetItemLoot = "loot" - AssetItemNote = "note" - AssetItemResponse = "response" - AssetItemError = "error" -) - -type AssetItem struct { - Kind string `json:"kind"` - Source string `json:"source,omitempty"` - Target string `json:"target,omitempty"` - Status string `json:"status,omitempty"` - Title string `json:"title,omitempty"` - Summary string `json:"summary,omitempty"` - Detail string `json:"detail,omitempty"` - Tags []string `json:"tags,omitempty"` - Data map[string]any `json:"data,omitempty"` - Raw string `json:"raw,omitempty"` -} - type Error struct { Source string `json:"source,omitempty"` Message string `json:"message"` } - -// --- Record payload types (aiscan-specific) --- - -type ScanStart struct { - Targets []string `json:"targets"` - Mode string `json:"mode"` - Flags []string `json:"flags"` -} - -type ScanEnd struct { - Duration float64 `json:"duration_s"` - Targets int `json:"targets"` - Services int `json:"services"` - Webs int `json:"webs"` - Loots int `json:"loots"` - Errors int `json:"errors"` -} diff --git a/core/output/writer.go b/core/output/writer.go deleted file mode 100644 index d842f728..00000000 --- a/core/output/writer.go +++ /dev/null @@ -1,57 +0,0 @@ -package output - -import ( - "encoding/json" - "fmt" - "os" - "sync" -) - -// TimelineWriter writes Record entries to a single JSONL file. -type TimelineWriter struct { - mu sync.Mutex - file *os.File -} - -func NewTimelineWriter(path string) (*TimelineWriter, error) { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return nil, fmt.Errorf("open timeline file %s: %w", path, err) - } - return &TimelineWriter{file: f}, nil -} - -func (w *TimelineWriter) Close() error { - w.mu.Lock() - defer w.mu.Unlock() - if w.file == nil { - return nil - } - err := w.file.Close() - w.file = nil - return err -} - -func (w *TimelineWriter) WriteRaw(data []byte) { - line := append(data, '\n') - w.mu.Lock() - defer w.mu.Unlock() - if w.file == nil { - return - } - _, _ = w.file.Write(line) -} - -func (w *TimelineWriter) WriteRecord(rec Record) { - line, err := json.Marshal(rec) - if err != nil { - return - } - line = append(line, '\n') - w.mu.Lock() - defer w.mu.Unlock() - if w.file == nil { - return - } - _, _ = w.file.Write(line) -} diff --git a/tools/proton/command_test.go b/tools/proton/command_test.go index 090df25d..5593a9bc 100644 --- a/tools/proton/command_test.go +++ b/tools/proton/command_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/chainreactors/aiscan/core/resources" + "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" protoncmd "github.com/chainreactors/aiscan/tools/proton" ) @@ -42,7 +43,7 @@ func run(t *testing.T, bash *commands.BashTool, cmd string) string { if err != nil { t.Fatalf("execute %q: %v", cmd, err) } - return res.Text() + return tool.ResultText(res) } func writeFile(t *testing.T, dir, name, content string) string { diff --git a/tools/scan/aggregate.go b/tools/scan/aggregate.go deleted file mode 100644 index 7c1a3e30..00000000 --- a/tools/scan/aggregate.go +++ /dev/null @@ -1,690 +0,0 @@ -package scan - -import ( - "fmt" - "net/url" - "regexp" - "sort" - "strconv" - "strings" - - "github.com/chainreactors/aiscan/core/output" - sdktypes "github.com/chainreactors/sdk/pkg/types" - "github.com/chainreactors/utils/parsers" -) - -var firstURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) - -type assetBucket struct { - asset output.Asset - keys map[string]struct{} - itemIndex map[string]int -} - -type assetBuilder struct { - buckets []*assetBucket - byKey map[string]*assetBucket -} - -func AggregateStructuredResult(result *output.Result) []output.Asset { - if result == nil { - return nil - } - - builder := newAssetBuilder() - for _, service := range result.Services { - builder.addService(service) - if service != nil { - for _, fw := range service.Frameworks { - if fw == nil { - continue - } - builder.addFrameworkFingerprint(service.GetTarget(), fw.Name, fw.IsFocus, capGogoPortscan) - } - } - } - for _, probe := range result.WebProbes { - builder.addWebProbe(probe) - if probe != nil { - for _, fw := range probe.Frameworks { - if fw == nil { - continue - } - builder.addFrameworkFingerprint(probe.UrlString, fw.Name, fw.IsFocus, probe.Source.Name()) - } - } - } - for i := range result.Loots { - builder.addLoot(&result.Loots[i]) - } - for _, err := range result.Errors { - builder.addError(err) - } - // Older stored results may already contain AI notes/responses. Rebuilding the - // service/path buckets should not discard those supplemental items. - for _, asset := range result.Assets { - for _, item := range asset.Items { - switch item.Kind { - case output.AssetItemService, output.AssetItemPath, output.AssetItemFingerprint, output.AssetItemLoot, output.AssetItemError: - continue - } - target := output.FirstNonEmpty(item.Target, asset.Target, "Scan") - builder.addItem(target, targetKeys(asset.Key, asset.Target, item.Target), itemIdentity(item), item) - } - } - return builder.assets() -} - -func newAssetBuilder() *assetBuilder { - return &assetBuilder{byKey: make(map[string]*assetBucket)} -} - -func (b *assetBuilder) addService(service *sdktypes.GOGOResult) { - if service == nil { - return - } - target := gogoServiceAssetTarget(service) - hostPort := "" - if service.Ip != "" && service.Port != "" { - hostPort = service.Ip + ":" + service.Port - } - serviceTarget := service.GetTarget() - keys := targetKeys(target, serviceTarget, hostPort) - svcName := output.FirstNonEmpty(service.Protocol, service.Midware) - data := assetData( - "ip", service.Ip, - "port", service.Port, - "protocol", service.Protocol, - "service", svcName, - "banner", service.Midware, - "is_web", service.IsHttp(), - ) - item := output.AssetItem{ - Kind: output.AssetItemService, - Source: capGogoPortscan, - Target: serviceTarget, - Title: output.FirstNonEmpty(svcName, service.Protocol, service.Midware), - Summary: service.Midware, - Tags: output.CompactStrings(service.Protocol, svcName, service.Port), - Data: data, - } - b.addItem(target, keys, "service|"+strings.Join(sortedStrings(keys), "|"), item) -} - -func (b *assetBuilder) addWebProbe(probe *sdktypes.SprayResult) { - if probe == nil || probe.UrlString == "" { - return - } - if !strings.Contains(probe.UrlString, "://") { - return - } - target := webAssetTarget(probe.UrlString) - sourceName := probe.Source.Name() - keys := targetKeys(target, probe.UrlString) - status := "" - if probe.Status > 0 { - status = strconv.Itoa(probe.Status) - } - path := output.WebPath(probe.UrlString) - fingerNames := parsers.FrameworkNames(probe.Frameworks) - data := assetData( - "url", probe.UrlString, - "path", path, - "status", probe.Status, - "length", probe.BodyLength, - "title", probe.Title, - "content_type", probe.ContentType, - "redirect_url", probe.RedirectURL, - "fingers", fingerNames, - "validated", isSprayValidated(sourceName), - ) - tags := append([]string{sourceName}, fingerNames...) - if isSprayValidated(sourceName) { - tags = append(tags, "validated") - } - item := output.AssetItem{ - Kind: output.AssetItemPath, - Source: sourceName, - Target: probe.UrlString, - Status: status, - Title: probe.Title, - Summary: path, - Tags: output.CompactStrings(tags...), - Data: data, - } - identity := "path|" + canonicalKey(probe.UrlString) + "|host=" + strings.ToLower(probe.Host) - b.addItem(target, keys, identity, item) -} - -// isSprayValidated returns true when the source capability is a spray -// pipeline stage. Spray results that reach the collector have already -// survived spray's baseline comparison (body-length + simhash fuzzy -// deduplication), so they represent pages that are structurally distinct -// from the site's default response — higher signal for the -F report. -func isSprayValidated(source string) bool { - switch source { - case capSprayCheck, capSprayCrawl, capSprayPlugins, capSprayBrute: - return true - default: - return false - } -} - -func (b *assetBuilder) addFrameworkFingerprint(targetStr, name string, focus bool, source string) { - if name == "" { - return - } - target := assetTargetFromValues(targetStr) - keys := targetKeys(target, targetStr) - data := assetData( - "name", name, - "focus", focus, - ) - item := output.AssetItem{ - Kind: output.AssetItemFingerprint, - Source: source, - Target: targetStr, - Title: name, - Tags: output.CompactStrings(source, name), - Data: data, - } - identity := "fingerprint|" + canonicalKey(targetStr) + "|" + strings.ToLower(name) - b.addItem(target, keys, identity, item) -} - -func (b *assetBuilder) addLoot(loot *output.Loot) { - if loot == nil { - return - } - target := assetTargetFromValues(loot.Target) - keys := targetKeys(target, loot.Target) - status := output.FirstNonEmpty(loot.Priority, output.AssetItemLoot) - data := make(map[string]any) - data["kind"] = loot.Kind - for k, v := range loot.Data { - data[k] = v - } - item := output.AssetItem{ - Kind: output.AssetItemLoot, - Source: loot.Kind, - Target: loot.Target, - Status: status, - Title: loot.Description, - Summary: loot.Description, - Tags: output.CompactStrings(append([]string{loot.Kind}, loot.Tags...)...), - Data: data, - } - identity := strings.Join(output.CompactStrings( - output.AssetItemLoot, - loot.Kind, - loot.Target, - loot.Description, - ), "|") - b.addItem(target, keys, identity, item) -} - -func (b *assetBuilder) addError(err output.Error) { - keys := targetKeys("scan") - item := output.AssetItem{ - Kind: output.AssetItemError, - Source: err.Source, - Target: "scan", - Status: output.AssetItemError, - Summary: err.Message, - Data: assetData("message", err.Message), - } - identity := "error|" + err.Source + "|" + err.Message - b.addItem("Scan", keys, identity, item) -} - -func (b *assetBuilder) addItem(target string, keys []string, identity string, item output.AssetItem) { - target = output.FirstNonEmpty(target, item.Target, "Scan") - if len(keys) == 0 { - keys = targetKeys(target) - } - bucket := b.findBucket(keys) - if bucket == nil { - bucket = &assetBucket{ - asset: output.Asset{ - Target: target, - }, - keys: make(map[string]struct{}), - itemIndex: make(map[string]int), - } - b.buckets = append(b.buckets, bucket) - } - bucket.asset.Target = preferredAssetTarget(bucket.asset.Target, target) - for _, key := range keys { - if key == "" { - continue - } - bucket.keys[key] = struct{}{} - b.byKey[key] = bucket - } - if identity == "" { - identity = itemIdentity(item) - } - if existing, ok := bucket.itemIndex[identity]; ok { - bucket.asset.Items[existing] = mergeAssetItem(bucket.asset.Items[existing], item) - return - } - bucket.itemIndex[identity] = len(bucket.asset.Items) - bucket.asset.Items = append(bucket.asset.Items, normalizeAssetItem(item)) -} - -func (b *assetBuilder) findBucket(keys []string) *assetBucket { - for _, key := range sortedStrings(keys) { - if bucket := b.byKey[key]; bucket != nil { - return bucket - } - } - return nil -} - -func (b *assetBuilder) assets() []output.Asset { - out := make([]output.Asset, 0, len(b.buckets)) - for _, bucket := range b.buckets { - asset := bucket.asset - sortAssetItems(asset.Items) - asset.Target = output.FirstNonEmpty(asset.Target, "Scan") - asset.Key = preferredAssetKey(bucket.keys, asset.Target) - asset.ID = "asset:" + asset.Key - asset.Title = deriveAssetTitle(asset) - asset.Status = deriveAssetStatus(asset.Items) - out = append(out, asset) - } - sort.SliceStable(out, func(i, j int) bool { - return out[i].Key < out[j].Key - }) - return out -} - -func gogoServiceAssetTarget(service *sdktypes.GOGOResult) string { - if service.IsHttp() { - scheme := strings.ToLower(strings.TrimSpace(service.Protocol)) - if !strings.HasPrefix(scheme, "http") { - if service.Port == "443" { - scheme = "https" - } else { - scheme = "http" - } - } - if service.Ip != "" && service.Port != "" { - return scheme + "://" + service.Ip + ":" + service.Port - } - } - return assetTargetFromValues(service.GetTarget()) -} - -func webAssetTarget(rawURL string) string { - if origin := urlOrigin(rawURL); origin != "" { - return origin - } - return rawURL -} - -func assetTargetFromValues(values ...string) string { - for _, value := range values { - if origin := urlOrigin(value); origin != "" { - return origin - } - if first := firstURL(value); first != "" { - if origin := urlOrigin(first); origin != "" { - return origin - } - return first - } - } - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "Scan" -} - -func targetKeys(values ...string) []string { - seen := make(map[string]struct{}) - for _, value := range values { - addTargetKeys(seen, value) - } - keys := make([]string, 0, len(seen)) - for key := range seen { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -func addTargetKeys(keys map[string]struct{}, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - addCanonicalKey(keys, value) - withoutHost := strings.Split(value, "|host=")[0] - addCanonicalKey(keys, withoutHost) - if first := firstURL(withoutHost); first != "" { - if canonicalKey(first) != canonicalKey(withoutHost) { - addTargetKeys(keys, first) - } - } - if origin := urlOrigin(withoutHost); origin != "" { - addCanonicalKey(keys, origin) - } - if normalized := normalizedURL(withoutHost); normalized != "" { - addCanonicalKey(keys, normalized) - } -} - -func addCanonicalKey(keys map[string]struct{}, value string) { - if key := canonicalKey(value); key != "" { - keys[key] = struct{}{} - } -} - -func canonicalKey(value string) string { - value = strings.Trim(value, " \t\r\n\"'<>[](),") - value = strings.TrimRight(value, "/") - if value == "" { - return "" - } - return strings.ToLower(value) -} - -func normalizedURL(value string) string { - parsed, err := url.Parse(strings.TrimSpace(value)) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "" - } - path := strings.TrimRight(parsed.EscapedPath(), "/") - if path == "" || path == "/" { - path = "" - } - query := "" - if parsed.RawQuery != "" { - query = "?" + parsed.RawQuery - } - return strings.ToLower(parsed.Scheme + "://" + stripDefaultPort(parsed) + path + query) -} - -func urlOrigin(value string) string { - parsed, err := url.Parse(strings.TrimSpace(value)) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "" - } - return strings.ToLower(parsed.Scheme + "://" + stripDefaultPort(parsed)) -} - -func stripDefaultPort(u *url.URL) string { - host := u.Hostname() - port := u.Port() - if port == "" { - return host - } - if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") { - return host - } - return host + ":" + port -} - -func firstURL(value string) string { - if value == "" { - return "" - } - match := firstURLPattern.FindString(value) - return strings.Trim(match, " \t\r\n\"'<>[](),") -} - -func preferredAssetTarget(current, next string) string { - current = strings.TrimSpace(current) - next = strings.TrimSpace(next) - if current == "" || strings.EqualFold(current, "scan") { - return next - } - if next == "" { - return current - } - if urlOrigin(next) != "" && urlOrigin(current) == "" { - return next - } - return current -} - -func preferredAssetKey(keys map[string]struct{}, target string) string { - targetKey := canonicalKey(target) - if targetKey != "" { - if _, ok := keys[targetKey]; ok { - return targetKey - } - } - sorted := make([]string, 0, len(keys)) - for key := range keys { - sorted = append(sorted, key) - } - sort.Strings(sorted) - if len(sorted) > 0 { - return sorted[0] - } - return canonicalKey(output.FirstNonEmpty(target, "scan")) -} - -func deriveAssetTitle(asset output.Asset) string { - if title := firstItemText(asset.Items, func(item output.AssetItem) bool { - return (item.Kind == output.AssetItemLoot || item.Kind == output.AssetItemNote) && item.Status == "confirmed" - }); title != "" { - return title - } - if title := firstItemText(asset.Items, func(item output.AssetItem) bool { - return item.Kind == output.AssetItemNote && item.Status == "info" - }); title != "" { - return title - } - if title := firstItemText(asset.Items, func(item output.AssetItem) bool { - return item.Kind == output.AssetItemLoot || item.Kind == output.AssetItemNote - }); title != "" { - return title - } - if title := firstItemText(asset.Items, func(item output.AssetItem) bool { - return item.Kind == output.AssetItemPath && item.Title != "" - }); title != "" { - return title - } - for _, item := range asset.Items { - if item.Kind != output.AssetItemService || item.Data == nil { - continue - } - if banner, ok := item.Data["banner"].(string); ok && strings.TrimSpace(banner) != "" { - return strings.TrimSpace(banner) - } - } - return asset.Target -} - -func firstItemText(items []output.AssetItem, match func(output.AssetItem) bool) string { - for _, item := range items { - if !match(item) { - continue - } - if text := output.FirstNonEmpty(item.Title, item.Summary); text != "" { - return text - } - } - return "" -} - -func deriveAssetStatus(items []output.AssetItem) string { - bestStatus := "" - bestRank := 0 - for _, item := range items { - status := item.Status - if item.Kind == output.AssetItemLoot && status == "" { - status = output.AssetItemLoot - } - if item.Kind == output.AssetItemError && status == "" { - status = output.AssetItemError - } - rank := assetStatusRank(item.Kind, status) - if rank > bestRank { - bestRank = rank - bestStatus = status - } - } - return bestStatus -} - -func assetStatusRank(kind, status string) int { - status = strings.ToLower(strings.TrimSpace(status)) - switch status { - case "confirmed": - return 100 - case string(priorityCritical): - return 95 - case string(priorityHigh): - return 90 - case output.AssetItemLoot: - return 85 - case string(priorityMedium): - return 70 - case "info": - return 60 - case string(priorityLow): - return 50 - case "inconclusive": - return 40 - case "not_confirmed": - return 30 - case "failed", output.AssetItemError: - return 20 - } - if kind == output.AssetItemLoot { - return 85 - } - if kind == output.AssetItemError { - return 20 - } - if kind == output.AssetItemResponse { - return 10 - } - return 0 -} - -func sortAssetItems(items []output.AssetItem) { - sort.SliceStable(items, func(i, j int) bool { - ri, rj := assetItemRank(items[i].Kind), assetItemRank(items[j].Kind) - if ri != rj { - return ri < rj - } - vi, vj := output.HasTag(items[i].Tags, "validated"), output.HasTag(items[j].Tags, "validated") - if vi != vj { - return vi - } - left := fmt.Sprintf("%s|%s|%s", items[i].Target, items[i].Title, items[i].Summary) - right := fmt.Sprintf("%s|%s|%s", items[j].Target, items[j].Title, items[j].Summary) - return left < right - }) -} - -func assetItemRank(kind string) int { - switch kind { - case output.AssetItemService: - return 10 - case output.AssetItemFingerprint: - return 20 - case output.AssetItemLoot: - return 30 - case output.AssetItemNote: - return 40 - case output.AssetItemResponse: - return 45 - case output.AssetItemPath: - return 50 - case output.AssetItemError: - return 60 - default: - return 90 - } -} - -func mergeAssetItem(current, next output.AssetItem) output.AssetItem { - current.Kind = output.FirstNonEmpty(current.Kind, next.Kind) - current.Source = output.FirstNonEmpty(current.Source, next.Source) - current.Target = output.FirstNonEmpty(current.Target, next.Target) - current.Status = output.FirstNonEmpty(current.Status, next.Status) - current.Title = output.FirstNonEmpty(current.Title, next.Title) - current.Summary = output.FirstNonEmpty(current.Summary, next.Summary) - current.Detail = output.FirstNonEmpty(current.Detail, next.Detail) - current.Raw = output.FirstNonEmpty(current.Raw, next.Raw) - current.Tags = output.CompactStrings(append(current.Tags, next.Tags...)...) - if current.Data == nil { - current.Data = next.Data - } else { - for key, value := range next.Data { - if isEmptyAssetData(value) { - continue - } - if isEmptyAssetData(current.Data[key]) { - current.Data[key] = value - } - } - } - return normalizeAssetItem(current) -} - -func normalizeAssetItem(item output.AssetItem) output.AssetItem { - item.Kind = strings.TrimSpace(item.Kind) - item.Source = strings.TrimSpace(item.Source) - item.Target = strings.TrimSpace(item.Target) - item.Status = strings.TrimSpace(item.Status) - item.Title = strings.TrimSpace(item.Title) - item.Summary = strings.TrimSpace(item.Summary) - item.Detail = strings.TrimSpace(item.Detail) - item.Raw = strings.TrimSpace(item.Raw) - item.Tags = output.CompactStrings(item.Tags...) - if len(item.Data) == 0 { - item.Data = nil - } - return item -} - -func itemIdentity(item output.AssetItem) string { - return strings.Join(output.CompactStrings(item.Kind, item.Source, item.Target, item.Status, item.Title, item.Summary, item.Raw), "|") -} - -func assetData(values ...any) map[string]any { - data := make(map[string]any) - for i := 0; i+1 < len(values); i += 2 { - key, ok := values[i].(string) - if !ok || key == "" || isEmptyAssetData(values[i+1]) { - continue - } - data[key] = values[i+1] - } - if len(data) == 0 { - return nil - } - return data -} - -func isEmptyAssetData(value any) bool { - switch v := value.(type) { - case nil: - return true - case string: - return strings.TrimSpace(v) == "" - case int: - return v == 0 - case bool: - return !v - case []string: - return len(output.CompactStrings(v...)) == 0 - default: - return false - } -} - -func sortedStrings(values []string) []string { - out := append([]string(nil), values...) - sort.Strings(out) - return out -} diff --git a/tools/scan/aggregate_test.go b/tools/scan/aggregate_test.go deleted file mode 100644 index 8bc86ee2..00000000 --- a/tools/scan/aggregate_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package scan - -import ( - "testing" - - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/utils/parsers" -) - -func TestAggregateStructuredResultKeepsHTTPOriginsSeparate(t *testing.T) { - result := &output.Result{ - Services: []*parsers.GOGOResult{ - {Ip: "111.63.65.103", Port: "80", Protocol: "http"}, - {Ip: "111.63.65.103", Port: "443", Protocol: "https"}, - {Ip: "111.63.65.103", Port: "icmp", Protocol: "icmp"}, - }, - WebProbes: []*parsers.SprayResult{ - {UrlString: "http://111.63.65.103/admin", Status: 200, Source: parsers.CheckSource}, - {UrlString: "https://111.63.65.103/login", Status: 301, Source: parsers.CheckSource}, - }, - } - - assets := AggregateStructuredResult(result) - if len(assets) != 3 { - t.Fatalf("got %d assets, want separate http, https, and icmp services: %#v", len(assets), assets) - } - for _, asset := range assets { - services, paths := 0, 0 - for _, item := range asset.Items { - switch item.Kind { - case output.AssetItemService: - services++ - case output.AssetItemPath: - paths++ - } - } - wantPaths := 1 - if asset.Target == "111.63.65.103:icmp" { - wantPaths = 0 - } - if services != 1 || paths != wantPaths { - t.Fatalf("asset %q has %d services and %d paths, want 1 service and %d paths", asset.Target, services, paths, wantPaths) - } - } -} diff --git a/tools/scan/collector.go b/tools/scan/collector.go index c0a81569..fc4fd7b5 100644 --- a/tools/scan/collector.go +++ b/tools/scan/collector.go @@ -167,17 +167,6 @@ func (c *collector) TerminalString(color bool) string { return formatSummary(c, color) } -func (c *collector) ReportMarkdown() string { - return output.RenderReport(c.StructuredResult(), output.ReportOptions{ - Style: output.StyleMarkdown, - Title: "Scan Report", - Sitemap: true, - CollapseBare: true, - Metrics: true, - Inventory: true, - }) -} - func (c *collector) JSONLines() (string, error) { return formatJSONLines(c) } @@ -189,10 +178,6 @@ func (c *collector) PlainText() string { return formatPlainText(c, lines) } -func (c *collector) AssetReport() string { - return output.FormatAssetReport(c.StructuredResult(), false) -} - type statsSnapshot struct { StartedAt time.Time FinishedAt time.Time diff --git a/tools/scan/command.go b/tools/scan/command.go index f3cca1af..8c9a8d1a 100644 --- a/tools/scan/command.go +++ b/tools/scan/command.go @@ -1,6 +1,7 @@ package scan import ( + "bytes" "context" "fmt" "io" @@ -8,7 +9,6 @@ import ( "path/filepath" "github.com/chainreactors/aiscan/agent" - aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" @@ -37,9 +37,7 @@ type flags struct { Trace bool `long:"trace" description:"Show internal scanner source and pipeline trace"` Debug bool `long:"debug" description:"Enable trace and underlying scanner debug logs"` JSON bool `short:"j" long:"json" description:"Output raw gogo and spray results as JSON Lines"` - Report bool `long:"report" description:"Output a concise final markdown report"` OutputFile string `short:"f" long:"file" description:"Write output to file without ANSI colors"` - AssetReportFile string `short:"F" long:"format" description:"Write aggregated asset report to file"` NoColor bool `long:"no-color" description:"Disable ANSI colors in terminal output"` Ports string `long:"ports" description:"Ports for gogo scanning; defaults to all in quick and - in full"` Threads int // derived from Thread; not a CLI flag @@ -90,17 +88,20 @@ func Usage() string { func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any, err error) { defer telemetry.RecoverAsError("scan", &err) - out, result, err := c.execute(ctx, c.resolveRelativePaths(execution.Args), execution.Stdout) + out, _, err := c.execute(ctx, c.resolveRelativePaths(execution.Args), execution.Stdout) if err != nil { return nil, err } if out != "" { fmt.Fprint(execution.Stdout, out) } - return result, nil + // Structured scan facts are emitted through the SCO sidecar. Returning the + // collector's private aggregation here would leak a second result schema + // through AOP tool.result. + return nil, nil } -func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) (string, *output.Result, error) { +func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) (string, *output.ScanResult, error) { var flags flags parser := toolargs.NewGoFlagsParser("scan", &flags) if _, err := parser.ParseArgs(args); err != nil { @@ -134,13 +135,10 @@ func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) return "", nil, err } if len(rawInputs) == 0 { - if flags.AssetReportFile != "" { - return output.RenderRecordFileAsAsset(flags.AssetReportFile, !flags.NoColor, AggregateStructuredResult) - } return "", nil, fmt.Errorf("scan: no input targets") } - if flags.JSON || flags.Report { + if flags.JSON { stream = nil } @@ -149,23 +147,6 @@ func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) coll := newCollector(rawInputs, stream, stream != nil && !flags.NoColor, trace) subscribePipeline(pipelineBus, coll, trace, stream) - var scanWriter *scanJSONLWriter - if flags.OutputFile != "" { - var agentBus *eventbus.Bus[*aop.Event] - if c.parent != nil { - agentBus = c.parent.Cfg.Bus - } - w, wErr := newScanJSONLWriter(flags.OutputFile, pipelineBus, agentBus) - if wErr != nil { - return "", nil, fmt.Errorf("scan: open record file: %w", wErr) - } - scanWriter = w - defer scanWriter.Close() - scanWriter.WriteRecord(output.NewRecord(output.TypeScanStart, output.ScanStart{ - Targets: rawInputs, Mode: flags.Mode, Flags: args, - })) - } - seeds := buildSeedEvents(rawInputs, func(raw string) { pipelineBus.Emit(pipeline.Observation{ Action: pipeline.ActionAccept, @@ -201,55 +182,34 @@ func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) if err != nil { return "", nil, fmt.Errorf("scan json output: %w", err) } - } else if flags.Report { - out = coll.ReportMarkdown() } else { out = coll.TerminalString(stream != nil && !flags.NoColor) } - if scanWriter != nil { - coll.mu.Lock() - stats := coll.statsSnapshotLocked() - gogoCount := len(coll.gogoResults) - webCount := len(coll.seenWeb) - lootCount := len(coll.loots) - errCount := len(coll.errors) - coll.mu.Unlock() - scanWriter.WriteRecord(output.NewRecord(output.TypeScanEnd, output.ScanEnd{ - Duration: stats.Duration().Seconds(), - Targets: stats.Inputs, - Services: gogoCount, - Webs: webCount, - Loots: lootCount, - Errors: errCount, - })) - } - if flags.OutputFile != "" && !flags.JSON { - plainOut := coll.PlainText() - if err := writeOutputFile(flags.OutputFile, plainOut); err != nil { - c.Logger.Errorf("%s", err.Error()) + result := coll.StructuredResult() + c.emitStructuredData(ctx, result) + if flags.OutputFile != "" { + nodes := buildSCONodes(result) + lines := make([][]byte, 0, len(nodes)) + for _, node := range nodes { + lines = append(lines, node) } - } - if flags.AssetReportFile != "" { - assetOut := coll.AssetReport() - if err := writeOutputFile(flags.AssetReportFile, assetOut); err != nil { + if err := writeOutputFile(flags.OutputFile, string(bytes.Join(lines, []byte{'\n'}))); err != nil { c.Logger.Errorf("%s", err.Error()) } } - result := coll.StructuredResult() - c.emitStructuredData(ctx, result) return out, result, nil } -func (c *Command) emitStructuredData(ctx context.Context, result *output.Result) { +func (c *Command) emitStructuredData(ctx context.Context, result *output.ScanResult) { if result == nil || c.DataBus == nil { return } - for _, service := range result.Services { + for _, service := range result.GOGO { if service != nil { c.EmitDataCtx(ctx, "gogo", output.ToolDataService, service.GetTarget(), service) } } - for _, probe := range result.WebProbes { + for _, probe := range result.Spray { if probe != nil { c.EmitDataCtx(ctx, "spray", output.ToolDataWeb, probe.UrlString, probe) } @@ -259,7 +219,6 @@ func (c *Command) emitStructuredData(ctx context.Context, result *output.Result) var scanFileFlags = map[string]bool{ "-l": true, "--list": true, "-f": true, "--file": true, - "-F": true, "--format": true, "--dict": true, "--rule": true, } diff --git a/tools/scan/command_test.go b/tools/scan/command_test.go index 5b91057c..384177c0 100644 --- a/tools/scan/command_test.go +++ b/tools/scan/command_test.go @@ -1345,13 +1345,6 @@ func TestScanSummaryAggregatesEngineStats(t *testing.T) { if !strings.Contains(out, "7 tasks 9 requests") { t.Fatalf("summary missing aggregated stats:\n%s", out) } - - report := coll.ReportMarkdown() - for _, want := range []string{"| Tasks | 7 |", "| Requests | 9 |"} { - if !strings.Contains(report, want) { - t.Fatalf("report missing %q:\n%s", want, report) - } - } } func TestProjectorSlowStreamDoesNotHoldStateLock(t *testing.T) { @@ -1419,7 +1412,7 @@ func TestScanPlainTextStripsANSI(t *testing.T) { } } -func TestScanAggregatesAssets(t *testing.T) { +func TestStructuredResultKeepsScannerValuesInsideCollector(t *testing.T) { coll := newCollector([]string{"seed"}, nil, false, false) service := parsers.NewGOGOResult("127.0.0.1", "8080") service.Protocol = "http" @@ -1437,26 +1430,15 @@ func TestScanAggregatesAssets(t *testing.T) { coll.Finish() result := coll.StructuredResult() - if len(result.Assets) != 1 { - t.Fatalf("assets = %d, want 1: %#v", len(result.Assets), result.Assets) + if len(result.GOGO) != 1 || result.GOGO[0].Port != "8080" { + t.Fatalf("gogo results = %#v", result.GOGO) } - kinds := assetItemKindCounts(result.Assets[0].Items) - for _, kind := range []string{output.AssetItemService, output.AssetItemPath, output.AssetItemFingerprint} { - if kinds[kind] != 1 { - t.Fatalf("asset item %s count = %d, want 1 in %#v", kind, kinds[kind], result.Assets[0].Items) - } + if len(result.Spray) != 1 || result.Spray[0].UrlString != "http://127.0.0.1:8080/admin" { + t.Fatalf("spray results = %#v", result.Spray) } } -func assetItemKindCounts(items []output.AssetItem) map[string]int { - counts := make(map[string]int) - for _, item := range items { - counts[item.Kind]++ - } - return counts -} - -func TestScanOutputFileWritesPlainTextWithoutChangingStdout(t *testing.T) { +func TestScanOutputFileContainsOnlySCOFactsWithoutChangingStdout(t *testing.T) { sprayEng, _ := spray.NewEngine(nil) cmd := New(&engine.Set{Spray: sprayEng}) file := filepath.Join(t.TempDir(), "scan.txt") @@ -1465,8 +1447,10 @@ func TestScanOutputFileWritesPlainTextWithoutChangingStdout(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - if details == nil { - t.Fatal("Run() returned nil details") + // Structured scan facts flow through the SCO sidecar; Run no longer + // returns a second result envelope. + if details != nil { + t.Fatalf("Run() returned unexpected details: %#v", details) } out := stdout.String() data, err := os.ReadFile(file) @@ -1477,8 +1461,8 @@ func TestScanOutputFileWritesPlainTextWithoutChangingStdout(t *testing.T) { if hasANSI(fileOut) { t.Fatalf("file output contains ANSI: %q", fileOut) } - if !strings.Contains(fileOut, "[summary] completed") { - t.Fatalf("file output missing summary: %q", fileOut) + if strings.Contains(fileOut, "[summary]") || strings.Contains(fileOut, "scan_start") || strings.Contains(fileOut, "scan_end") { + t.Fatalf("fact file contains legacy record data: %q", fileOut) } if !strings.Contains(output.StripANSI(out), "[summary] completed") { t.Fatalf("stdout output missing summary: %q", out) @@ -1510,29 +1494,6 @@ func (w *blockingWriter) Write(p []byte) (int, error) { return len(p), nil } -func TestScanReportMarkdown(t *testing.T) { - coll := newCollector([]string{"seed"}, nil, false, false) - coll.Observe(pipelineEvent{Action: pipeline.ActionCapabilityStart, Capability: capGogoPortscan, Event: targetEvent("", "", newScanTarget("", "127.0.0.1", ""))}) - coll.Observe(pipelineEvent{Action: pipeline.ActionAccept, Event: targetEvent(capGogoPortscan, "", newServiceTarget("", parsers.NewGOGOResult("127.0.0.1", "80")))}) - coll.Observe(pipelineEvent{Action: pipeline.ActionAccept, Event: targetEvent("spray_check", "", newWebProbeTarget("", "spray_check", "", &parsers.SprayResult{ - IsValid: true, - UrlString: "http://127.0.0.1:80", - Status: 200, - Distance: 1, - }))}) - coll.Finish() - - report := coll.ReportMarkdown() - if hasANSI(report) { - t.Fatalf("report contains ANSI: %q", report) - } - for _, want := range []string{"# Scan Report", "## Metrics", "## Open Services"} { - if !strings.Contains(report, want) { - t.Fatalf("report missing %q:\n%s", want, report) - } - } -} - func TestPipelinePerRouteDedupIsolation(t *testing.T) { // Two capabilities both subscribe to seed webTargets. // The same URL sent as seed should be deduped within each route, diff --git a/tools/scan/data_bus_test.go b/tools/scan/data_bus_test.go index 1a0b2e77..c7cebd3b 100644 --- a/tools/scan/data_bus_test.go +++ b/tools/scan/data_bus_test.go @@ -10,7 +10,7 @@ import ( "github.com/chainreactors/utils/parsers" ) -func TestEmitStructuredDataPublishesScanAssets(t *testing.T) { +func TestEmitStructuredDataPublishesScannerFacts(t *testing.T) { bus := eventbus.New[output.ToolDataEvent]() cmd := New(&engine.Set{}, WithDataBus(bus)) @@ -21,9 +21,9 @@ func TestEmitStructuredDataPublishesScanAssets(t *testing.T) { defer unsub() ctx := output.ContextWithCallID(context.Background(), "scan-call-1") - cmd.emitStructuredData(ctx, &output.Result{ - Services: []*parsers.GOGOResult{{Ip: "127.0.0.1", Port: "8080", Protocol: "http"}}, - WebProbes: []*parsers.SprayResult{{ + cmd.emitStructuredData(ctx, &output.ScanResult{ + GOGO: []*parsers.GOGOResult{{Ip: "127.0.0.1", Port: "8080", Protocol: "http"}}, + Spray: []*parsers.SprayResult{{ UrlString: "http://127.0.0.1:8080/", Status: 200, }}, }) diff --git a/tools/scan/jsonl_writer.go b/tools/scan/jsonl_writer.go deleted file mode 100644 index 9fd81256..00000000 --- a/tools/scan/jsonl_writer.go +++ /dev/null @@ -1,111 +0,0 @@ -package scan - -import ( - "strings" - - aop "github.com/chainreactors/aiscan/aop" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/tools/scan/pipeline" - "google.golang.org/protobuf/encoding/protojson" -) - -type scanJSONLWriter struct { - w *output.TimelineWriter - scanUnsub func() - agentUnsub func() -} - -func newScanJSONLWriter(path string, scanBus *eventbus.Bus[pipeline.Observation], agentBus *eventbus.Bus[*aop.Event]) (*scanJSONLWriter, error) { - tw, err := output.NewTimelineWriter(path) - if err != nil { - return nil, err - } - w := &scanJSONLWriter{w: tw} - w.scanUnsub = scanBus.Subscribe(w.handleObservation) - if agentBus != nil { - w.agentUnsub = agentBus.Subscribe(w.handleAgentEvent) - } - return w, nil -} - -func (w *scanJSONLWriter) Close() error { - if w.scanUnsub != nil { - w.scanUnsub() - w.scanUnsub = nil - } - if w.agentUnsub != nil { - w.agentUnsub() - w.agentUnsub = nil - } - return w.w.Close() -} - -func (w *scanJSONLWriter) WriteRecord(rec output.Record) { - w.w.WriteRecord(rec) -} - -func (w *scanJSONLWriter) handleObservation(obs pipeline.Observation) { - if obs.Action != pipeline.ActionAccept { - return - } - e, ok := obs.Event.(event) - if !ok { - return - } - for _, rec := range observationToRecords(e) { - w.w.WriteRecord(rec) - } -} - -func (w *scanJSONLWriter) handleAgentEvent(event *aop.Event) { - raw, _ := protojson.Marshal(event) - w.w.WriteRaw(raw) -} - -func observationToRecords(e event) []output.Record { - switch e.Kind { - case eventTarget: - return targetToRecords(e) - case eventLoot: - return lootToRecords(e) - default: - return nil - } -} - -func targetToRecords(e event) []output.Record { - switch target := e.Target.(type) { - case serviceTarget: - if target.Result != nil { - return []output.Record{output.NewRecord(output.TypeGogo, target.Result)} - } - case webProbeTarget: - if reportableSprayResultForCapability(target.Result, target.Capability) && target.Result != nil { - return []output.Record{output.NewRecord(output.TypeSpray, target.Result)} - } - } - return nil -} - -func lootToRecords(e event) []output.Record { - if e.Loot == nil { - return nil - } - return []output.Record{output.NewLootRecord(capabilityRecordType(e.Source), e.Loot)} -} - -func capabilityRecordType(source string) output.RecordType { - switch { - case strings.HasPrefix(source, "gogo"): - return output.TypeGogo - case strings.HasPrefix(source, "spray"), source == capCoreWeb: - return output.TypeSpray - case strings.HasPrefix(source, "zombie"), source == capHTTPBasicAuth: - return output.TypeZombie - case strings.HasPrefix(source, "neutron"): - return output.TypeNeutron - default: - return output.RecordType(source) - } -} diff --git a/tools/scan/sco.go b/tools/scan/sco.go index ed8a1b47..128ddaab 100644 --- a/tools/scan/sco.go +++ b/tools/scan/sco.go @@ -1,5 +1,3 @@ -//go:build cstx_native - package scan import ( @@ -9,16 +7,16 @@ import ( "github.com/chainreactors/libcstx/go" ) -func buildSCONodes(result *output.Result) []json.RawMessage { +func buildSCONodes(result *output.ScanResult) []json.RawMessage { var allNodes []cstx.SCONode - if len(result.Services) > 0 { - if nodes, err := cstx.Parse("gogo", result.Services); err == nil { + if len(result.GOGO) > 0 { + if nodes, err := cstx.Parse("gogo", result.GOGO); err == nil { allNodes = append(allNodes, nodes...) } } - if len(result.WebProbes) > 0 { - if nodes, err := cstx.Parse("spray", result.WebProbes); err == nil { + if len(result.Spray) > 0 { + if nodes, err := cstx.Parse("spray", result.Spray); err == nil { allNodes = append(allNodes, nodes...) } } diff --git a/tools/scan/sco_stub.go b/tools/scan/sco_stub.go deleted file mode 100644 index b2d9c606..00000000 --- a/tools/scan/sco_stub.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build !cstx_native - -package scan - -import ( - "encoding/json" - - "github.com/chainreactors/aiscan/core/output" -) - -func buildSCONodes(_ *output.Result) []json.RawMessage { return nil } diff --git a/tools/scan/sco_test.go b/tools/scan/sco_test.go index 82c63893..bd891a35 100644 --- a/tools/scan/sco_test.go +++ b/tools/scan/sco_test.go @@ -43,15 +43,16 @@ func TestStructuredResultContainsSCONodes(t *testing.T) { coll.Finish() result := coll.StructuredResult() + nodes := buildSCONodes(result) // Verify nodes exist - if len(result.Nodes) == 0 { - t.Fatal("StructuredResult().Nodes is empty — SCO conversion did not run") + if len(nodes) == 0 { + t.Fatal("buildSCONodes() returned no libcstx facts") } // Parse nodes back and check types types := make(map[string]int) - for _, raw := range result.Nodes { + for _, raw := range nodes { var header struct { Type string `json:"cstx_type"` ID string `json:"cstx_id"` @@ -71,15 +72,5 @@ func TestStructuredResultContainsSCONodes(t *testing.T) { } } - t.Logf("total SCO nodes: %d, types: %v", len(result.Nodes), types) - - // Verify JSON serialization round-trip - resultJSON, err := json.Marshal(result) - if err != nil { - t.Fatalf("failed to marshal result: %v", err) - } - if len(resultJSON) == 0 { - t.Fatal("empty result JSON") - } - t.Logf("Result JSON size: %d bytes", len(resultJSON)) + t.Logf("total SCO nodes: %d, types: %v", len(nodes), types) } diff --git a/tools/scan/structured.go b/tools/scan/structured.go index 39b2c079..4771b70c 100644 --- a/tools/scan/structured.go +++ b/tools/scan/structured.go @@ -6,18 +6,18 @@ import ( "github.com/chainreactors/aiscan/core/output" ) -func (c *collector) StructuredResult() *output.Result { +func (c *collector) StructuredResult() *output.ScanResult { c.mu.Lock() defer c.mu.Unlock() stats := c.statsSnapshotLocked() - result := &output.Result{ + result := &output.ScanResult{ Summary: output.Summary{ - Targets: stats.Inputs, - Services: len(c.gogoResults), - Webs: len(c.seenWeb), - Probes: len(c.sprayResults), - Loots: len(c.loots), + Inputs: stats.Inputs, + Ports: len(c.gogoResults), + Web: len(c.seenWeb), + URLs: len(c.sprayResults), + Findings: len(c.loots), Errors: len(c.errors), Tasks: stats.Tasks, Requests: stats.Requests, @@ -31,20 +31,18 @@ func (c *collector) StructuredResult() *output.Result { if item == nil { continue } - result.Services = append(result.Services, item) + result.GOGO = append(result.GOGO, item) } for _, item := range c.sprayResults { if item.Result == nil { continue } - result.WebProbes = append(result.WebProbes, item.Result) + result.Spray = append(result.Spray, item.Result) } result.Loots = append(result.Loots, c.loots...) for _, message := range c.errors { result.Errors = append(result.Errors, output.Error{Message: message}) } - result.Assets = AggregateStructuredResult(result) - result.Nodes = buildSCONodes(result) return result } diff --git a/tools/search/websearch_tool.go b/tools/search/websearch_tool.go index adec6c15..9e325891 100644 --- a/tools/search/websearch_tool.go +++ b/tools/search/websearch_tool.go @@ -29,18 +29,18 @@ func (t *WebSearchTool) Description() string { return "Search the web for CVEs, exploits, vulnerability details, and product documentation." } -func (t *WebSearchTool) Definition() tool.Definition { +func (t *WebSearchTool) Definition() *tool.Definition { return tool.Def("web_search", t.Description(), webSearchArgs{}) } -func (t *WebSearchTool) Execute(ctx context.Context, arguments string) (tool.Result, error) { +func (t *WebSearchTool) Execute(ctx context.Context, arguments string) (*tool.Result, error) { args, err := tool.ParseArgs[webSearchArgs](arguments) if err != nil { - return tool.Result{}, err + return nil, err } args.Query = strings.TrimSpace(args.Query) if args.Query == "" { - return tool.Result{}, fmt.Errorf("query is required") + return nil, fmt.Errorf("query is required") } num := args.Num @@ -65,5 +65,5 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments string) (tool.Res } } - return tool.Result{}, fmt.Errorf("web_search: no search backend available. Configure Tavily API key via --tavily-key flag, env (TAVILY_API_KEY), or config file (search.tavily_keys). Do not retry until configured") + return nil, fmt.Errorf("web_search: no search backend available. Configure Tavily API key via --tavily-key flag, env (TAVILY_API_KEY), or config file (search.tavily_keys). Do not retry until configured") } From 770890663cdf33e78f312b753fe1cae1e5a7fed9 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sun, 2 Aug 2026 12:02:00 +0800 Subject: [PATCH 159/348] refactor(agent): use typed AOP events across runtime --- agent/agent.go | 23 +- agent/agent_test.go | 75 +-- agent/aop_emit.go | 92 +--- agent/aop_emit_test.go | 28 +- agent/compact.go | 103 ++-- agent/compact_test.go | 63 +-- agent/evaluator/evaluator.go | 62 +-- agent/evaluator/loop.go | 40 +- agent/evaluator/loop_test.go | 49 +- agent/finish_tool.go | 4 +- agent/helpers_test.go | 150 +++++- agent/hooks/hooks_test.go | 10 +- agent/hooks/points.go | 25 +- agent/hooks_emit.go | 11 +- agent/inbox/expand.go | 4 +- agent/inbox/expand_test.go | 14 +- agent/inbox/inbox_test.go | 12 +- agent/inbox/message.go | 50 +- agent/input.go | 162 ++---- agent/input_test.go | 116 +++-- agent/loop.go | 493 ++++++++++-------- agent/loop_test.go | 254 +++++---- agent/overflow.go | 12 +- agent/probe/llm.go | 8 +- agent/provider/anthropic.go | 246 ++++----- agent/provider/cache_test.go | 314 ++++++----- agent/provider/endpoint_hint_test.go | 4 +- agent/provider/http.go | 4 +- agent/provider/openai.go | 371 +++++++++++-- agent/provider/provider_test.go | 114 ++-- agent/provider/types.go | 360 ++++++------- agent/retry.go | 89 ++-- agent/retry_test.go | 71 ++- agent/session.go | 119 +++-- agent/session_test.go | 63 +-- agent/subagent.go | 26 +- agent/subagent_test.go | 21 +- agent/types.go | 72 +-- core/tool/definition.go | 14 +- core/tool/interface.go | 16 +- core/tool/result.go | 61 +-- core/tool/schema.go | 18 +- pkg/commands/bash.go | 21 +- pkg/commands/bash_test.go | 72 +-- pkg/commands/command.go | 10 +- pkg/commands/glob.go | 10 +- pkg/commands/glob_test.go | 6 +- pkg/commands/image_optimize.go | 58 +-- pkg/commands/image_optimize_test.go | 2 +- pkg/commands/list.go | 17 +- pkg/commands/list_test.go | 6 +- pkg/commands/read.go | 47 +- pkg/commands/read_test.go | 20 +- pkg/commands/schema_test.go | 29 +- pkg/commands/write.go | 20 +- pkg/commands/write_test.go | 34 +- pkg/runner/application_builder.go | 50 ++ pkg/runner/provider_config.go | 48 ++ pkg/runner/provider_config_from_proto_test.go | 86 +++ pkg/runner/runner.go | 9 +- pkg/runner/runtime_protocol.go | 180 ++++--- pkg/runner/runtime_protocol_test.go | 70 ++- pkg/runner/runtime_semantics_test.go | 21 +- pkg/runner/runtime_session.go | 28 +- pkg/runner/runtime_session_isolation_test.go | 5 + pkg/runner/stdio.go | 66 ++- pkg/runner/stdio_concurrency_test.go | 18 +- pkg/runner/stdio_test.go | 133 ++--- pkg/runner/subagent_handoff.go | 2 +- pkg/runner/subagent_handoff_test.go | 2 +- pkg/tui/commands.go | 8 +- pkg/tui/console.go | 3 +- pkg/tui/console_test.go | 39 +- pkg/tui/controller_test.go | 2 +- pkg/tui/format.go | 10 +- pkg/tui/live.go | 26 +- pkg/tui/output.go | 51 +- pkg/tui/output_test.go | 9 +- 78 files changed, 2848 insertions(+), 2213 deletions(-) create mode 100644 pkg/runner/provider_config_from_proto_test.go diff --git a/agent/agent.go b/agent/agent.go index 1ca83ed7..03ce0384 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -7,8 +7,9 @@ import ( "github.com/chainreactors/aiscan/agent/inbox" providerpkg "github.com/chainreactors/aiscan/agent/provider" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "google.golang.org/protobuf/proto" ) @@ -36,8 +37,8 @@ func WithTurnID(turnID string) RunOption { } } -func (a *Agent) Run(ctx context.Context, input Input, opts ...RunOption) (*Result, error) { - userMsg, err := input.chatMessage() +func (a *Agent) Run(ctx context.Context, input *aop.Message, opts ...RunOption) (*Result, error) { + userMsg, err := resolveInputMessage(input) if err != nil { return nil, err } @@ -66,7 +67,7 @@ func (a *Agent) Run(ctx context.Context, input Input, opts ...RunOption) (*Resul if cfg.Inbox == nil { cfg.Inbox = inbox.NewBuffered(SubInboxCapacity) } - msg := inbox.FromChatMessage(userMsg, inbox.OriginUser) + msg := inbox.FromAOPMessage(userMsg, inbox.OriginUser) if err := cfg.Inbox.Push(msg); err != nil { return nil, fmt.Errorf("push prompt: %w", err) } @@ -249,7 +250,7 @@ func deriveNamedFromConfig(cfg Config, name, parentToolCallID string, detail *ex // EmitStatus emits an AOP status event on the agent's session. Used by // out-of-kernel helpers (evaluator) so their events carry session/seq. -func (a *Agent) EmitStatus(state, namespace string, detail proto.Message, turnID ...string) { +func (a *Agent) EmitStatus(state string, detail proto.Message, turnID ...string) { a.mu.Lock() em := a.Cfg.emitter a.mu.Unlock() @@ -257,7 +258,7 @@ func (a *Agent) EmitStatus(state, namespace string, detail proto.Message, turnID if len(turnID) > 0 && turnID[0] != "" { em = em.turn(turnID[0]) } - em.status(state, namespace, detail) + em.status(state, detail) } } @@ -276,10 +277,10 @@ func (a *Agent) Reset() { a.state.ErrorMessage = "" } -func (a *Agent) LoadMessages(messages []ChatMessage) { +func (a *Agent) LoadMessages(messages []*aop.Message) { a.mu.Lock() defer a.mu.Unlock() - a.state.Messages = append([]ChatMessage(nil), messages...) + a.state.Messages = append([]*aop.Message(nil), messages...) } func (a *Agent) validateContinue() error { @@ -316,10 +317,10 @@ func (a *Agent) finishRun() { a.running = false } -func (a *Agent) MessagesSnapshot() []ChatMessage { +func (a *Agent) MessagesSnapshot() []*aop.Message { a.mu.Lock() defer a.mu.Unlock() - return append([]ChatMessage(nil), a.state.Messages...) + return append([]*aop.Message(nil), a.state.Messages...) } func (a *Agent) saveState(result *Result, err error) { @@ -330,6 +331,6 @@ func (a *Agent) saveState(result *Result, err error) { a.state.ErrorMessage = err.Error() } if result != nil { - a.state.Messages = append([]ChatMessage(nil), result.Messages...) + a.state.Messages = append([]*aop.Message(nil), result.Messages...) } } diff --git a/agent/agent_test.go b/agent/agent_test.go index 959e1644..1547c2a6 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" @@ -41,7 +42,7 @@ func TestRunWithoutToolsReturnsFinalText(t *testing.T) { if len(requests) != 1 { t.Fatalf("requests = %d, want 1", len(requests)) } - if requests[0].Messages[0].Role != "system" || *requests[0].Messages[0].Content != "system" { + if requests[0].Messages[0].Role != "system" || provider.MessageText(requests[0].Messages[0]) != "system" { t.Fatalf("system message not injected: %#v", requests[0].Messages) } } @@ -104,7 +105,7 @@ func TestContinueRequiresNonAssistantLastMessage(t *testing.T) { t.Fatalf("Continue() error = %v, want no messages", err) } - a.state.Messages = []ChatMessage{NewTextMessage("assistant", "done")} + a.state.Messages = []*aop.Message{textMessage("assistant", "done")} if _, err := a.Continue(context.Background()); err == nil || !strings.Contains(err.Error(), "assistant") { t.Fatalf("Continue() error = %v, want assistant", err) } @@ -132,7 +133,7 @@ func TestAgentReusesConversationAcrossPrompts(t *testing.T) { if len(requests[1].Messages) != 3 { t.Fatalf("second request messages = %d, want 3: %#v", len(requests[1].Messages), requests[1].Messages) } - if *requests[1].Messages[0].Content != "one" || *requests[1].Messages[1].Content != "first" || *requests[1].Messages[2].Content != "two" { + if provider.MessageText(requests[1].Messages[0]) != "one" || provider.MessageText(requests[1].Messages[1]) != "first" || provider.MessageText(requests[1].Messages[2]) != "two" { t.Fatalf("unexpected reused context: %#v", requests[1].Messages) } } @@ -145,7 +146,7 @@ func TestAgentPromptReturnsRunScopedNewMessages(t *testing.T) { }, } ag := NewAgent(Config{Provider: llm, Tools: tools, Model: "test"}) - ag.state.Messages = []ChatMessage{NewTextMessage("user", "base")} + ag.state.Messages = []*aop.Message{textMessage("user", "base")} result, err := ag.Run(context.Background(), TextInput("prompt")) if err != nil { t.Fatalf("Prompt() error = %v", err) @@ -277,7 +278,7 @@ func TestNoEmptyAssistantMessageInStateAfterError(t *testing.T) { return nil, fmt.Errorf("boom") } for _, msg := range req.Messages { - if msg.Role == "assistant" && messageContent(msg) == "" && len(msg.ToolCalls) == 0 { + if msg.Role == "assistant" && messageContent(msg) == "" && len(provider.MessageToolCalls(msg)) == 0 { t.Errorf("found empty assistant message in request on call %d", callCount) } } @@ -296,7 +297,7 @@ func TestNoEmptyAssistantMessageInStateAfterError(t *testing.T) { a.mu.Lock() for i, msg := range a.state.Messages { - if msg.Role == "assistant" && messageContent(msg) == "" && len(msg.ToolCalls) == 0 { + if msg.Role == "assistant" && messageContent(msg) == "" && len(provider.MessageToolCalls(msg)) == 0 { t.Errorf("state.Messages[%d] is empty assistant message", i) } } @@ -403,11 +404,11 @@ func TestAgentPromptIncludesEmbeddedSkillIndexAndExpansion(t *testing.T) { t.Fatalf("provider calls = %d, want 1", len(requests)) } system := requests[0].Messages[0] - if system.Role != "system" || system.Content == nil || !strings.Contains(*system.Content, "") { + if system.Role != "system" || !strings.Contains(provider.MessageText(system), "") { t.Fatalf("system prompt missing skills") } user := requests[0].Messages[1] - if user.Role != "user" || user.Content == nil || !strings.Contains(*user.Content, ` 0 { - ratio = float64(r.TotalUsage.CacheReadTokens) / float64(r.TotalUsage.PromptTokens) * 100 + if r.TotalUsage.InputTokens > 0 { + ratio = float64(r.TotalUsage.Detail["cache_read"]) / float64(r.TotalUsage.InputTokens) * 100 } t.Logf("Turn %d: output=%q prompt=%d cache_read=%d cache_write=%d hit_ratio=%.1f%%", i+1, truncateOutput(r.Output, 40), - r.TotalUsage.PromptTokens, r.TotalUsage.CacheReadTokens, r.TotalUsage.CacheWriteTokens, ratio) + r.TotalUsage.InputTokens, r.TotalUsage.Detail["cache_read"], r.TotalUsage.Detail["cache_write"], ratio) } - totalCacheRead := result2.TotalUsage.CacheReadTokens + result3.TotalUsage.CacheReadTokens + totalCacheRead := result2.TotalUsage.Detail["cache_read"] + result3.TotalUsage.Detail["cache_read"] if totalCacheRead == 0 { t.Error("expected cache_read > 0 in turn 2 or 3, got 0 for both — caching may not be working") } @@ -1086,14 +1087,14 @@ func TestMultiTurnStreamingCache(t *testing.T) { t.Fatalf("stream turn 1 failed: %v", err) } t.Logf("Stream Turn 1: output=%q prompt=%d cache_read=%d", - truncateOutput(result1.Output, 40), result1.TotalUsage.PromptTokens, result1.TotalUsage.CacheReadTokens) + truncateOutput(result1.Output, 40), result1.TotalUsage.InputTokens, result1.TotalUsage.Detail["cache_read"]) result2, err := NewAgent(agentCfg.WithMessages(result1.Messages)).Run(context.Background(), TextInput("Goodbye")) if err != nil { t.Fatalf("stream turn 2 failed: %v", err) } t.Logf("Stream Turn 2: output=%q prompt=%d cache_read=%d", - truncateOutput(result2.Output, 40), result2.TotalUsage.PromptTokens, result2.TotalUsage.CacheReadTokens) + truncateOutput(result2.Output, 40), result2.TotalUsage.InputTokens, result2.TotalUsage.Detail["cache_read"]) allMsgs := append(result1.Messages, result2.NewMessages...) result3, err := NewAgent(agentCfg.WithMessages(allMsgs)).Run(context.Background(), TextInput("Thank you")) @@ -1101,16 +1102,16 @@ func TestMultiTurnStreamingCache(t *testing.T) { t.Fatalf("stream turn 3 failed: %v", err) } t.Logf("Stream Turn 3: output=%q prompt=%d cache_read=%d", - truncateOutput(result3.Output, 40), result3.TotalUsage.PromptTokens, result3.TotalUsage.CacheReadTokens) + truncateOutput(result3.Output, 40), result3.TotalUsage.InputTokens, result3.TotalUsage.Detail["cache_read"]) t.Logf("\n=== Streaming Cache Summary ===") for i, r := range []*Result{result1, result2, result3} { ratio := 0.0 - if r.TotalUsage.PromptTokens > 0 { - ratio = float64(r.TotalUsage.CacheReadTokens) / float64(r.TotalUsage.PromptTokens) * 100 + if r.TotalUsage.InputTokens > 0 { + ratio = float64(r.TotalUsage.Detail["cache_read"]) / float64(r.TotalUsage.InputTokens) * 100 } t.Logf("Turn %d: prompt=%d cache_read=%d cache_write=%d hit_ratio=%.1f%%", - i+1, r.TotalUsage.PromptTokens, r.TotalUsage.CacheReadTokens, r.TotalUsage.CacheWriteTokens, ratio) + i+1, r.TotalUsage.InputTokens, r.TotalUsage.Detail["cache_read"], r.TotalUsage.Detail["cache_write"], ratio) } } @@ -1153,26 +1154,26 @@ func TestMultiTurnWithToolCallsCache(t *testing.T) { t.Logf("Tool calls recorded: %d", len(calcTool.callsSnapshot())) t.Logf("\n=== Per-Turn Usage (with tool calls) ===") - for _, tu := range result.TurnUsages { + for i, tu := range result.TurnUsages { ratio := 0.0 - if tu.PromptTokens > 0 { - ratio = float64(tu.CacheReadTokens) / float64(tu.PromptTokens) * 100 + if tu.InputTokens > 0 { + ratio = float64(tu.Detail["cache_read"]) / float64(tu.InputTokens) * 100 } t.Logf(" turn %d: prompt=%d completion=%d cache_read=%d cache_write=%d hit_ratio=%.1f%%", - tu.Turn, tu.PromptTokens, tu.CompletionTokens, - tu.CacheReadTokens, tu.CacheWriteTokens, ratio) + i+1, tu.InputTokens, tu.OutputTokens, + tu.Detail["cache_read"], tu.Detail["cache_write"], ratio) } t.Logf("Total usage: prompt=%d completion=%d cache_read=%d cache_write=%d", - result.TotalUsage.PromptTokens, result.TotalUsage.CompletionTokens, - result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheWriteTokens) + result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens, + result.TotalUsage.Detail["cache_read"], result.TotalUsage.Detail["cache_write"]) if result.Turns < 2 { t.Logf("WARNING: expected >= 2 turns for tool call flow, got %d (model may have answered without tool)", result.Turns) } if result.Turns >= 2 && len(result.TurnUsages) >= 2 { - laterCacheRead := result.TurnUsages[len(result.TurnUsages)-1].CacheReadTokens + laterCacheRead := result.TurnUsages[len(result.TurnUsages)-1].Detail["cache_read"] if laterCacheRead == 0 { t.Logf("WARNING: last turn cache_read=0 — provider may not support automatic prefix caching") } else { diff --git a/agent/aop_emit.go b/agent/aop_emit.go index 4a38ec76..53267a75 100644 --- a/agent/aop_emit.go +++ b/agent/aop_emit.go @@ -1,13 +1,13 @@ package agent import ( - "encoding/base64" "fmt" "sync/atomic" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/tool" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -17,7 +17,6 @@ const ( partReasoning = "reasoning" statusTokenBudgetWarning = "token_budget_warning" statusLLMRequest = "llm_request" - aopStatusNamespace = "aop" ) type aopEmitter struct { @@ -65,8 +64,8 @@ func (e *aopEmitter) emit(event *aop.Event) { e.bus.Emit(event) } -func (e *aopEmitter) emitWithExt(event *aop.Event, namespace string, value proto.Message) { - if err := aop.SetProtoExtension(event, namespace, value); err == nil { +func (e *aopEmitter) emitWithExt(event *aop.Event, value proto.Message) { + if err := aop.SetTypedExtension(event, value); err == nil { e.emit(event) } } @@ -82,7 +81,7 @@ func (e *aopEmitter) sessionStart(model string) { Model: model, ParentSessionId: e.parentSessionID, ParentToolCallId: e.parentToolCallID, }}} if e.delegation != nil { - e.emitWithExt(event, ext.DelegationNamespace, e.delegation) + e.emitWithExt(event, e.delegation) return } e.emit(event) @@ -96,8 +95,8 @@ func (e *aopEmitter) turnStart() { e.emit(&aop.Event{Payload: &aop.Event_TurnStarted{TurnStarted: &aop.TurnStarted{}}}) } -func (e *aopEmitter) turnEnd(stop StopReason, totalUsage Usage, contextTokens int, runErr error) { - ended := &aop.TurnEnded{StopReason: string(stop), Usage: usageData(totalUsage), ContextTokens: uint64(max(contextTokens, 0))} +func (e *aopEmitter) turnEnd(stop StopReason, totalUsage *aop.TokenUsage, contextTokens int, runErr error) { + ended := &aop.TurnEnded{StopReason: string(stop), Usage: totalUsage, ContextTokens: uint64(max(contextTokens, 0))} if runErr != nil { ended.Error = &aop.ProtocolError{Message: runErr.Error()} } @@ -118,6 +117,12 @@ func (e *aopEmitter) messageWithIdentity(id, role, name string, content []*aop.C e.emit(&aop.Event{Payload: &aop.Event_Message{Message: &aop.Message{Id: id, Role: role, Name: name, Content: content}}}) } +// messageProto emits an already-built assistant message. The message id is +// assigned by the caller (requestWithRetry) so retries and deltas share it. +func (e *aopEmitter) messageProto(msg *aop.Message) { + e.emit(&aop.Event{Payload: &aop.Event_Message{Message: msg}}) +} + func (e *aopEmitter) messageDelta(messageID string, contentIndex int, partType, delta string) { messageDelta := &aop.MessageDelta{ MessageId: messageID, ContentIndex: uint32(max(contentIndex, 0)), Operation: aop.DeltaOperation_DELTA_OPERATION_APPEND, @@ -130,39 +135,28 @@ func (e *aopEmitter) messageDelta(messageID string, contentIndex int, partType, e.emit(&aop.Event{Payload: &aop.Event_MessageDelta{MessageDelta: messageDelta}}) } -func (e *aopEmitter) toolCall(toolCallID, toolName string, args any, workDir string) { - arguments, err := aop.JSONValue(args) - if err != nil { - e.errorEvt(err, false) - return - } - call := &aop.ToolCall{Id: toolCallID, Name: toolName, Kind: "function", Arguments: arguments, WorkingDirectory: workDir} +func (e *aopEmitter) toolCall(call *aop.ToolCall) { event := &aop.Event{Payload: &aop.Event_ToolCall{ToolCall: call}} - if detail, ok := delegationFromToolCall(toolName, args); ok { - e.emitWithExt(event, ext.DelegationNamespace, &detail) + if detail, ok := delegationFromToolCall(call.Name, decodeToolArguments(call)); ok { + e.emitWithExt(event, &detail) return } e.emit(event) } -func (e *aopEmitter) toolResult(toolCallID, toolName string, content []*aop.Content, details any, terminate, isError bool, durationMs int) { - detail, err := aop.JSONValue(details) - if err != nil { - e.errorEvt(err, false) - return - } +func (e *aopEmitter) toolResult(call *aop.ToolCall, content []*aop.Content, fullResult *tool.Result, terminate, isError bool, durationMs int) { result := &aop.ToolResult{ - CallId: toolCallID, Name: toolName, Output: content, Detail: detail, + CallId: call.Id, Name: call.Name, Output: content, Terminate: terminate, IsError: isError, DurationMs: uint64(max(durationMs, 0)), } e.emit(&aop.Event{Payload: &aop.Event_ToolResult{ToolResult: result}}) } -func (e *aopEmitter) usage(usage *Usage, model string) { +func (e *aopEmitter) usage(usage *aop.TokenUsage, model string) { if usage == nil { return } - value := usageData(*usage) + value := proto.Clone(usage).(*aop.TokenUsage) value.Model = model e.emit(&aop.Event{Payload: &aop.Event_Usage{Usage: value}}) } @@ -184,51 +178,11 @@ func (e *aopEmitter) providerFrame(frame ProviderRawFrame) { }}}) } -func (e *aopEmitter) status(state, namespace string, detail proto.Message) { +func (e *aopEmitter) status(state string, detail proto.Message) { event := &aop.Event{Payload: &aop.Event_Status{Status: &aop.Status{State: state}}} - if namespace != "" && detail != nil { - e.emitWithExt(event, namespace, detail) + if detail != nil { + e.emitWithExt(event, detail) return } e.emit(event) } - -func usageData(usage Usage) *aop.TokenUsage { - if usage == (Usage{}) { - return nil - } - return &aop.TokenUsage{ - InputTokens: uint64(max(usage.PromptTokens, 0)), OutputTokens: uint64(max(usage.CompletionTokens, 0)), - TotalTokens: uint64(max(usage.TotalTokens, 0)), Detail: map[string]uint64{ - "cache_read": uint64(max(usage.CacheReadTokens, 0)), "cache_write": uint64(max(usage.CacheWriteTokens, 0)), - }, - } -} - -func messagePartsFromChat(message ChatMessage) []*aop.Content { - var content []*aop.Content - if message.ReasoningContent != nil && *message.ReasoningContent != "" { - content = append(content, aop.Reasoning(*message.ReasoningContent)) - } - if message.Content != nil && *message.Content != "" { - content = append(content, aop.Text(*message.Content)) - } - for _, part := range message.ContentParts { - switch part.Type { - case "text": - if part.Text != "" { - content = append(content, aop.Text(part.Text)) - } - case "image_url": - if part.ImageURL == nil { - continue - } - mediaType, base64Data := ParseDataURI(part.ImageURL.URL) - data, err := base64.StdEncoding.DecodeString(base64Data) - if err == nil { - content = append(content, aop.Image(mediaType, data)) - } - } - } - return content -} diff --git a/agent/aop_emit_test.go b/agent/aop_emit_test.go index dd096deb..7444176c 100644 --- a/agent/aop_emit_test.go +++ b/agent/aop_emit_test.go @@ -7,8 +7,9 @@ import ( "testing" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" + "github.com/chainreactors/aiscan/core/tool" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) // streamEventCollector records message/message.delta events from the bus. @@ -49,11 +50,11 @@ func (c *streamEventCollector) assistantMessages() []*aop.Message { func reasoningStreamEvents() []ChatCompletionStreamEvent { return []ChatCompletionStreamEvent{ - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{ReasoningContent: strPtr("think-")}}, - {Delta: ChatMessageDelta{ReasoningContent: strPtr("hard")}}, - {Delta: ChatMessageDelta{Content: strPtr("ans-")}}, - {Delta: ChatMessageDelta{Content: strPtr("wer")}}, + roleDelta("assistant"), + reasoningDelta("think-"), + reasoningDelta("hard"), + textDelta("ans-"), + textDelta("wer"), {Done: true}, } } @@ -182,12 +183,12 @@ func TestMessageIDStableAcrossStreamRetry(t *testing.T) { } } -func TestStatusPreservesTypedExtensionNamespace(t *testing.T) { +func TestStatusPreservesTypedExtension(t *testing.T) { bus := eventbus.New[*aop.Event]() var emitted *aop.Event bus.Subscribe(func(event *aop.Event) { emitted = event }) emitter := newAOPEmitter(bus, "agent-1", "session-1", "", "", nil, 0) - emitter.status(ext.CompactStateEnd, ext.CompactNamespace, &ext.CompactDetail{ + emitter.status(ext.CompactStateEnd, &ext.CompactDetail{ TokensBefore: 1000, TokensAfter: 400, KeptMessages: 8, @@ -200,9 +201,6 @@ func TestStatusPreservesTypedExtensionNamespace(t *testing.T) { if err != nil || !ok || detail.TokensBefore != 1000 || detail.TokensAfter != 400 || detail.KeptMessages != 8 { t.Fatalf("compact detail = %+v, ok=%v, err=%v", detail, ok, err) } - if emitted.GetStatus().Detail != nil { - t.Fatal("status detail must have one canonical representation in event.extensions") - } } func TestToolResultEmitterPreservesAllProtocolFields(t *testing.T) { @@ -210,10 +208,10 @@ func TestToolResultEmitterPreservesAllProtocolFields(t *testing.T) { var emitted *aop.Event bus.Subscribe(func(event *aop.Event) { emitted = event }) emitter := newAOPEmitter(bus, "agent-1", "session-1", "", "", nil, 0).turn("turn-1") - emitter.toolResult("call-1", "scan", []*aop.Content{ + emitter.toolResult(&aop.ToolCall{Id: "call-1", Name: "scan"}, []*aop.Content{ aop.Text("done"), aop.Image("image/png", []byte("image")), - }, map[string]any{"ports": 3}, true, true, 12) + }, &tool.Result{}, true, true, 12) result := emitted.GetToolResult() if result == nil || result.CallId != "call-1" || result.Name != "scan" || !result.Terminate || !result.IsError || result.DurationMs != 12 { @@ -222,8 +220,4 @@ func TestToolResultEmitterPreservesAllProtocolFields(t *testing.T) { if len(result.Output) != 2 || result.Output[0].GetText().GetText() != "done" || string(result.Output[1].GetMedia().GetResource().GetData()) != "image" { t.Fatalf("tool result output = %+v", result.Output) } - detail, err := aop.DecodeJSON[map[string]int](result.Detail) - if err != nil || detail["ports"] != 3 { - t.Fatalf("tool result detail = %+v, err=%v", detail, err) - } } diff --git a/agent/compact.go b/agent/compact.go index 0c10e70b..3c20efce 100644 --- a/agent/compact.go +++ b/agent/compact.go @@ -5,8 +5,10 @@ import ( "fmt" "strings" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/truncate" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) const compactSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. @@ -71,7 +73,7 @@ type CompactResult struct { func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, error) { a.mu.Lock() - msgs := append([]ChatMessage(nil), a.state.Messages...) + msgs := append([]*aop.Message(nil), a.state.Messages...) em := a.Cfg.emitter if cfg.Provider == nil { cfg.Provider = a.Cfg.Provider @@ -90,10 +92,10 @@ func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, } a.mu.Unlock() - em.status(ext.CompactStateStart, "", nil) + em.status(ext.CompactStateStart, nil) newMsgs, result, err := compactHistory(ctx, cfg, msgs) if err != nil { - em.status(ext.CompactStateError, ext.CompactNamespace, &ext.CompactDetail{Error: err.Error()}) + em.status(ext.CompactStateError, &ext.CompactDetail{Error: err.Error()}) return nil, err } @@ -101,11 +103,11 @@ func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, a.state.Messages = newMsgs a.mu.Unlock() - em.status(ext.CompactStateEnd, ext.CompactNamespace, &ext.CompactDetail{TokensBefore: uint64(max(result.TokensBefore, 0)), TokensAfter: uint64(max(result.TokensAfter, 0)), KeptMessages: uint64(max(result.KeptMessages, 0))}) + em.status(ext.CompactStateEnd, &ext.CompactDetail{TokensBefore: uint64(max(result.TokensBefore, 0)), TokensAfter: uint64(max(result.TokensAfter, 0)), KeptMessages: uint64(max(result.KeptMessages, 0))}) return result, nil } -func compactHistory(ctx context.Context, cfg CompactConfig, msgs []ChatMessage) ([]ChatMessage, *CompactResult, error) { +func compactHistory(ctx context.Context, cfg CompactConfig, msgs []*aop.Message) ([]*aop.Message, *CompactResult, error) { if len(msgs) < 2 { return nil, nil, fmt.Errorf("nothing to compact (too few messages)") } @@ -167,10 +169,10 @@ func compactHistory(ctx context.Context, cfg CompactConfig, msgs []ChatMessage) } } - summaryMsg := NewTextMessage("user", + summaryMsg := provider.TextMessage("user", "The conversation history before this point was compacted into the following summary:\n\n\n"+ summary+"\n") - newMsgs := make([]ChatMessage, 0, 1+len(msgs)-cut.FirstKept) + newMsgs := make([]*aop.Message, 0, 1+len(msgs)-cut.FirstKept) newMsgs = append(newMsgs, summaryMsg) newMsgs = append(newMsgs, msgs[cut.FirstKept:]...) tokensAfter := estimateAllTokens(newMsgs) @@ -184,31 +186,35 @@ func compactHistory(ctx context.Context, cfg CompactConfig, msgs []ChatMessage) }, nil } -func estimateMessageTokens(msg ChatMessage) int { +func estimateMessageTokens(msg *aop.Message) int { chars := 0 - if msg.Content != nil { - chars += len(*msg.Content) - } - for _, part := range msg.ContentParts { - if part.Type == "image_url" { + for _, part := range msg.GetContent() { + switch value := part.Value.(type) { + case *aop.Content_Text: + chars += len(value.Text.Text) + case *aop.Content_Reasoning: + chars += len(value.Reasoning.Text) + case *aop.Content_Media: chars += 4800 - } else { - chars += len(part.Text) + case *aop.Content_ToolCall: + chars += len(value.ToolCall.Name) + len(value.ToolCall.GetArguments().GetData()) + case *aop.Content_ToolResult: + for _, block := range value.ToolResult.Output { + if text := block.GetText(); text != nil { + chars += len(text.Text) + } else if block.GetMedia() != nil { + chars += 4800 + } + } } } - if msg.ReasoningContent != nil { - chars += len(*msg.ReasoningContent) - } - for _, tc := range msg.ToolCalls { - chars += len(tc.Function.Name) + len(tc.Function.Arguments) - } if chars == 0 { return 0 } return (chars + 3) / 4 } -func estimateAllTokens(msgs []ChatMessage) int { +func estimateAllTokens(msgs []*aop.Message) int { total := 0 for _, m := range msgs { total += estimateMessageTokens(m) @@ -222,14 +228,14 @@ type compactionCut struct { SplitTurn bool } -func isCompactionCutPoint(msg ChatMessage) bool { - return (msg.Role == "user" && msg.ToolCallID == "") || msg.Role == "assistant" +func isCompactionCutPoint(msg *aop.Message) bool { + return (msg.Role == "user" && provider.MessageToolResult(msg) == nil) || msg.Role == "assistant" } // findCompactionCut walks backward to retain approximately keepTokens. A cut // may land at a user turn boundary or at an assistant message inside a single // oversized turn, but never at a tool result. -func findCompactionCut(msgs []ChatMessage, keepTokens int) compactionCut { +func findCompactionCut(msgs []*aop.Message, keepTokens int) compactionCut { valid := make([]int, 0, len(msgs)) for i := range msgs { if isCompactionCutPoint(msgs[i]) { @@ -262,11 +268,11 @@ func findCompactionCut(msgs []ChatMessage, keepTokens int) compactionCut { if cutIdx <= 0 { return compactionCut{} } - if msgs[cutIdx].Role == "user" && msgs[cutIdx].ToolCallID == "" { + if msgs[cutIdx].Role == "user" && provider.MessageToolResult(msgs[cutIdx]) == nil { return compactionCut{FirstKept: cutIdx, TurnStart: cutIdx} } for i := cutIdx - 1; i >= 0; i-- { - if msgs[i].Role == "user" && msgs[i].ToolCallID == "" { + if msgs[i].Role == "user" && provider.MessageToolResult(msgs[i]) == nil { return compactionCut{FirstKept: cutIdx, TurnStart: i, SplitTurn: true} } } @@ -274,20 +280,17 @@ func findCompactionCut(msgs []ChatMessage, keepTokens int) compactionCut { } // findCutPoint is kept as the simple index helper used by trigger checks. -func findCutPoint(msgs []ChatMessage, keepTokens int) int { +func findCutPoint(msgs []*aop.Message, keepTokens int) int { return findCompactionCut(msgs, keepTokens).FirstKept } -func serializeMessages(msgs []ChatMessage) string { +func serializeMessages(msgs []*aop.Message) string { var sb strings.Builder for _, m := range msgs { - content := "" - if m.Content != nil { - content = *m.Content - } + content := provider.MessageText(m) switch m.Role { case "user": - if m.ToolCallID != "" { + if provider.MessageToolResult(m) != nil { continue } fmt.Fprintf(&sb, "[User]: %s\n\n", content) @@ -295,9 +298,9 @@ func serializeMessages(msgs []ChatMessage) string { if content != "" { fmt.Fprintf(&sb, "[Assistant]: %s\n\n", content) } - for _, tc := range m.ToolCalls { + for _, call := range provider.MessageToolCalls(m) { fmt.Fprintf(&sb, "[Tool Call]: %s(%s)\n\n", - tc.Function.Name, truncate.Clip(tc.Function.Arguments, 200)) + call.Name, truncate.Clip(string(call.GetArguments().GetData()), 200)) } case "tool": fmt.Fprintf(&sb, "[Tool Result]: %s\n\n", truncate.Clip(content, 500)) @@ -308,7 +311,7 @@ func serializeMessages(msgs []ChatMessage) string { return sb.String() } -func summarize(ctx context.Context, p Provider, model string, msgs []ChatMessage, customInstructions string, maxTokens int) (string, error) { +func summarize(ctx context.Context, p Provider, model string, msgs []*aop.Message, customInstructions string, maxTokens int) (string, error) { prompt := compactUserPrompt if customInstructions != "" { prompt += "\n\nAdditional focus: " + customInstructions @@ -316,15 +319,15 @@ func summarize(ctx context.Context, p Provider, model string, msgs []ChatMessage return summarizeConversation(ctx, p, model, msgs, prompt, maxTokens) } -func summarizeConversation(ctx context.Context, p Provider, model string, msgs []ChatMessage, prompt string, maxTokens int) (string, error) { +func summarizeConversation(ctx context.Context, p Provider, model string, msgs []*aop.Message, prompt string, maxTokens int) (string, error) { userContent := "\n" + serializeMessages(msgs) + "\n\n" + prompt temp := float64(0) resp, err := p.ChatCompletion(ctx, &ChatCompletionRequest{ Model: model, - Messages: []ChatMessage{ - NewTextMessage("system", compactSystemPrompt), - NewTextMessage("user", userContent), + Messages: []*aop.Message{ + provider.TextMessage("system", compactSystemPrompt), + provider.TextMessage("user", userContent), }, MaxTokens: maxTokens, Temperature: &temp, @@ -339,19 +342,9 @@ func summarizeConversation(ctx context.Context, p Provider, model string, msgs [ if isOutputLimitFinishReason(choice.FinishReason) { return "", fmt.Errorf("summary output truncated (finish_reason=%s)", choice.FinishReason) } - content := choice.Message.Content - if content == nil || *content == "" { + content := provider.MessageText(choice.Message) + if content == "" { return "", fmt.Errorf("empty summary returned") } - return *content, nil -} - -func usageTotalTokens(usage *Usage) int { - if usage == nil { - return 0 - } - if usage.TotalTokens > 0 { - return usage.TotalTokens - } - return usage.PromptTokens + usage.CompletionTokens + return content, nil } diff --git a/agent/compact_test.go b/agent/compact_test.go index 2dc33646..6a614277 100644 --- a/agent/compact_test.go +++ b/agent/compact_test.go @@ -6,33 +6,33 @@ import ( "strings" "testing" + "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/pkg/commands" ) -func msg(role, content string) ChatMessage { - return NewTextMessage(role, content) +func msg(role, content string) *aop.Message { + return textMessage(role, content) } -func toolResult(id, content string) ChatMessage { - return NewToolResultMessage(id, content) +func toolResult(id, content string) *aop.Message { + return toolResultMessage(id, content) } func TestEstimateMessageTokens(t *testing.T) { tests := []struct { name string - msg ChatMessage + msg *aop.Message want int }{ - {"empty", ChatMessage{Role: "user"}, 0}, + {"empty", &aop.Message{Role: "user"}, 0}, {"short text", msg("user", "hello"), 2}, // 5 chars → ceil(5/4) = 2 {"exact boundary", msg("user", "abcd"), 1}, // 4 chars → 1 {"longer text", msg("user", "hello world, this is a test message"), 9}, // 35 chars → ceil(35/4) = 9 - {"image", NewMultimodalMessage("user", []ContentPart{ImagePart("image/png", "data", "high")}), 1200}, - {"with tool calls", ChatMessage{ - Role: "assistant", - ToolCalls: []ToolCall{{ - Function: FunctionCall{Name: "bash", Arguments: `{"command":"ls -la"}`}, - }}, + {"image", imageMessage("user", aop.Image("image/png", []byte("data"))), 1200}, + {"with tool calls", &aop.Message{ + Role: "assistant", + Content: []*aop.Content{toolCallContent("", "bash", `{"command":"ls -la"}`)}, }, 6}, // (4+19+3)/4 = 6 } for _, tt := range tests { @@ -46,7 +46,7 @@ func TestEstimateMessageTokens(t *testing.T) { } func TestEstimateAllTokens(t *testing.T) { - msgs := []ChatMessage{ + msgs := []*aop.Message{ msg("user", "hello"), // 2 msg("assistant", "world"), // 2 msg("user", "how are you"), // 3 @@ -67,25 +67,25 @@ func TestFindCutPoint(t *testing.T) { tests := []struct { name string - msgs []ChatMessage + msgs []*aop.Message keepTokens int wantIdx int }{ { "all fit within budget", - []ChatMessage{msg("user", "hi"), msg("assistant", "hello")}, + []*aop.Message{msg("user", "hi"), msg("assistant", "hello")}, 20000, 0, }, { "split a single oversized turn", - []ChatMessage{msg("user", longStr), msg("assistant", "recent")}, + []*aop.Message{msg("user", longStr), msg("assistant", "recent")}, 20000, 1, }, { "split at assistant boundary to honor recent budget", - []ChatMessage{ + []*aop.Message{ msg("user", longStr), // ~25000 tokens — old msg("assistant", longStr), // ~25000 tokens — old msg("user", "recent"), // kept @@ -96,7 +96,7 @@ func TestFindCutPoint(t *testing.T) { }, { "assistant boundary before old tool result", - []ChatMessage{ + []*aop.Message{ msg("user", longStr), msg("assistant", longStr), toolResult("tc1", "result"), @@ -108,7 +108,7 @@ func TestFindCutPoint(t *testing.T) { }, { "split an oversized tool turn at an assistant boundary", - []ChatMessage{ + []*aop.Message{ msg("user", longStr), msg("assistant", "calling a tool"), toolResult("tc1", longStr), @@ -119,7 +119,7 @@ func TestFindCutPoint(t *testing.T) { }, { "empty messages", - []ChatMessage{}, + []*aop.Message{}, 20000, 0, }, @@ -141,8 +141,8 @@ func TestCompactHistorySummarizesOversizedTurnPrefix(t *testing.T) { }} toolCall := ChatMessage{Role: "assistant", ToolCalls: []ToolCall{{ ID: "tc1", Type: "function", Function: FunctionCall{Name: "bash", Arguments: `{"command":"scan"}`}, - }}} - messages := []ChatMessage{ + }}}.toAOP() + messages := []*aop.Message{ msg("user", long), toolCall, toolResult("tc1", long), @@ -172,7 +172,7 @@ func TestCompactHistorySummarizesOversizedTurnPrefix(t *testing.T) { } func TestSerializeMessages(t *testing.T) { - msgs := []ChatMessage{ + msgs := []*aop.Message{ msg("user", "search for bugs"), msg("assistant", "I'll search now"), } @@ -189,7 +189,7 @@ func TestSerializeMessages(t *testing.T) { } func TestSerializeMessagesSkipsToolResultRoleUser(t *testing.T) { - msgs := []ChatMessage{ + msgs := []*aop.Message{ toolResult("tc1", "some tool output"), } result := serializeMessages(msgs) @@ -236,7 +236,7 @@ func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) { KeepRecentTokens: 20, }, }) - agent.LoadMessages([]ChatMessage{ + agent.LoadMessages([]*aop.Message{ msg("user", long), msg("assistant", long), msg("user", long), msg("assistant", long), }) @@ -258,9 +258,10 @@ func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) { if got, want := requests[1].MaxTokens, 20; got != want { t.Fatalf("turn-prefix max_tokens = %d, want %d", got, want) } - if len(result.Messages) < 3 || result.Messages[0].Content == nil || - !strings.Contains(*result.Messages[0].Content, "history checkpoint") || - !strings.Contains(*result.Messages[0].Content, "turn-prefix checkpoint") { + firstContent := provider.MessageText(result.Messages[0]) + if len(result.Messages) < 3 || + !strings.Contains(firstContent, "history checkpoint") || + !strings.Contains(firstContent, "turn-prefix checkpoint") { t.Fatalf("compacted messages = %#v", result.Messages) } if len(result.NewMessages) != 2 { @@ -294,7 +295,7 @@ func TestRunRecoversFromContextOverflowOnce(t *testing.T) { KeepRecentTokens: 20, }, }) - agent.LoadMessages([]ChatMessage{ + agent.LoadMessages([]*aop.Message{ msg("user", long), msg("assistant", long), msg("user", long), msg("assistant", long), }) @@ -312,11 +313,11 @@ func TestCompactHistoryRejectsTruncatedSummary(t *testing.T) { long := strings.Repeat("x", 240) llm := &scriptedProvider{responses: []*ChatCompletionResponse{{ Choices: []Choice{{ - Message: NewTextMessage("assistant", "incomplete checkpoint"), + Message: NewTextMessage("assistant", "incomplete checkpoint").toAOP(), FinishReason: "max_tokens", }}, }}} - messages := []ChatMessage{ + messages := []*aop.Message{ msg("user", long), msg("assistant", long), msg("user", "recent"), msg("assistant", "reply"), } diff --git a/agent/evaluator/evaluator.go b/agent/evaluator/evaluator.go index e4608ac5..a1de6643 100644 --- a/agent/evaluator/evaluator.go +++ b/agent/evaluator/evaluator.go @@ -9,6 +9,7 @@ import ( agentpkg "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/truncate" ) @@ -52,7 +53,7 @@ func New(cfg Config) *Evaluator { return &Evaluator{cfg: cfg} } -func (e *Evaluator) Evaluate(ctx context.Context, goal, criteria string, messages []provider.ChatMessage, output string, turns, contextTokens int) (*Verdict, error) { +func (e *Evaluator) Evaluate(ctx context.Context, goal, criteria string, messages []*aop.Message, output string, turns, contextTokens int) (*Verdict, error) { trace := buildTrace(messages, output, turns, contextTokens, e.cfg.ContextWindow) prompt := buildPrompt(goal, criteria, trace) @@ -86,33 +87,34 @@ Rules: - <=50%: default inherit_context=true - When inherit_context=false, feedback must be fully self-contained (include file paths, findings, variable names, prior progress)` -var verdictTool = provider.ToolDefinition{ - Type: "function", - Function: provider.FunctionDefinition{ +var verdictTool = func() *aop.ToolDefinition { + schema, _ := aop.JSONValue(map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "pass": map[string]interface{}{"type": "boolean", "description": "task fully achieved"}, + "reason": map[string]interface{}{"type": "string", "description": "one-sentence summary"}, + "feedback": map[string]interface{}{"type": "string", "description": "next step if not pass; self-contained when inherit_context=false"}, + "inherit_context": map[string]interface{}{"type": "boolean", "description": "false to discard conversation history for next round"}, + }, + "required": []string{"pass", "reason", "feedback", "inherit_context"}, + }) + return &aop.ToolDefinition{ + Type: "function", Name: "verdict", Description: "Submit evaluation verdict", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "pass": map[string]interface{}{"type": "boolean", "description": "task fully achieved"}, - "reason": map[string]interface{}{"type": "string", "description": "one-sentence summary"}, - "feedback": map[string]interface{}{"type": "string", "description": "next step if not pass; self-contained when inherit_context=false"}, - "inherit_context": map[string]interface{}{"type": "boolean", "description": "false to discard conversation history for next round"}, - }, - "required": []string{"pass", "reason", "feedback", "inherit_context"}, - }, - }, -} + InputSchema: schema, + } +}() func (e *Evaluator) call(ctx context.Context, userPrompt string) (*Verdict, error) { temp := float64(0) resp, err := e.cfg.Provider.ChatCompletion(ctx, &provider.ChatCompletionRequest{ Model: e.cfg.Model, - Messages: []provider.ChatMessage{ - provider.NewTextMessage("system", systemPrompt), - provider.NewTextMessage("user", userPrompt), + Messages: []*aop.Message{ + provider.TextMessage("system", systemPrompt), + provider.TextMessage("user", userPrompt), }, - Tools: []provider.ToolDefinition{verdictTool}, + Tools: []*aop.ToolDefinition{verdictTool}, MaxTokens: 2048, Temperature: &temp, }) @@ -123,10 +125,10 @@ func (e *Evaluator) call(ctx context.Context, userPrompt string) (*Verdict, erro return nil, fmt.Errorf("no choices returned") } - for _, tc := range resp.Choices[0].Message.ToolCalls { - if tc.Function.Name == "verdict" { + for _, call := range provider.MessageToolCalls(resp.Choices[0].Message) { + if call.Name == "verdict" { var v Verdict - if err := json.Unmarshal([]byte(tc.Function.Arguments), &v); err != nil { + if err := json.Unmarshal(call.GetArguments().GetData(), &v); err != nil { return nil, fmt.Errorf("unmarshal verdict: %w", err) } return &v, nil @@ -145,30 +147,32 @@ func buildPrompt(goal, criteria, trace string) string { return sb.String() } -func buildTrace(messages []provider.ChatMessage, output string, turns, contextTokens, contextWindow int) string { +func buildTrace(messages []*aop.Message, output string, turns, contextTokens, contextWindow int) string { var sb strings.Builder usagePct := float64(contextTokens) / float64(contextWindow) * 100 fmt.Fprintf(&sb, "Turns: %d | Messages: %d | Context tokens: %d/%d (%.0f%%)\n", turns, len(messages), contextTokens, contextWindow, usagePct) toolCallCount := 0 for _, msg := range messages { - toolCallCount += len(msg.ToolCalls) + toolCallCount += len(provider.MessageToolCalls(msg)) } fmt.Fprintf(&sb, "Tool calls: %d\n", toolCallCount) sb.WriteString("\nTool call sequence:\n") seq := 0 for _, msg := range messages { - for _, tc := range msg.ToolCalls { + for _, call := range provider.MessageToolCalls(msg) { seq++ - fmt.Fprintf(&sb, " [%d] %s\n", seq, tc.Function.Name) + fmt.Fprintf(&sb, " [%d] %s\n", seq, call.Name) } } sb.WriteString("\nAssistant summaries:\n") for _, msg := range messages { - if msg.Role == "assistant" && msg.Content != nil && *msg.Content != "" { - fmt.Fprintf(&sb, "- %s\n", truncate.Clip(*msg.Content, maxResultPreview)) + if msg.Role == "assistant" { + if text := provider.MessageText(msg); text != "" { + fmt.Fprintf(&sb, "- %s\n", truncate.Clip(text, maxResultPreview)) + } } } diff --git a/agent/evaluator/loop.go b/agent/evaluator/loop.go index 129b82e8..133d0a5e 100644 --- a/agent/evaluator/loop.go +++ b/agent/evaluator/loop.go @@ -7,8 +7,9 @@ import ( "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/agent/provider" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) const defaultMaxEvalRounds = 3 @@ -19,7 +20,7 @@ type EvalLoopConfig struct { Goal string Criteria string TurnID string - InitialInput agent.Input + InitialInput *aop.Message } // NewLoopConfig builds an EvalLoopConfig around a fresh Evaluator. A @@ -31,11 +32,11 @@ func NewLoopConfig(p provider.Provider, model string, logger telemetry.Logger, g // NewLoopConfigWithInput preserves transport controls and multimodal parts on // the first evaluation round. Boundaries that already published the user input // use this constructor so the original multimodal input is preserved in Goal mode. -func NewLoopConfigWithInput(p provider.Provider, model string, logger telemetry.Logger, input agent.Input, criteria string, maxRounds int) EvalLoopConfig { - return newLoopConfig(p, model, logger, strings.TrimSpace(input.Text()), input, criteria, maxRounds) +func NewLoopConfigWithInput(p provider.Provider, model string, logger telemetry.Logger, input *aop.Message, criteria string, maxRounds int) EvalLoopConfig { + return newLoopConfig(p, model, logger, strings.TrimSpace(provider.MessageText(input)), input, criteria, maxRounds) } -func newLoopConfig(p provider.Provider, model string, logger telemetry.Logger, goal string, input agent.Input, criteria string, maxRounds int) EvalLoopConfig { +func newLoopConfig(p provider.Provider, model string, logger telemetry.Logger, goal string, input *aop.Message, criteria string, maxRounds int) EvalLoopConfig { return EvalLoopConfig{ Evaluator: New(Config{Provider: p, Model: model, Logger: logger}), MaxEvalRounds: maxRounds, @@ -50,9 +51,22 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . cfg.MaxEvalRounds = defaultMaxEvalRounds } var ( - totalUsage agent.Usage + totalUsage *aop.TokenUsage totalTurns int ) + accumulate := func(u *aop.TokenUsage) { + if u == nil { + return + } + if totalUsage == nil { + totalUsage = &aop.TokenUsage{Detail: map[string]uint64{}} + } + totalUsage.InputTokens += u.InputTokens + totalUsage.OutputTokens += u.OutputTokens + totalUsage.TotalTokens += u.TotalTokens + totalUsage.Detail["cache_read"] += u.Detail["cache_read"] + totalUsage.Detail["cache_write"] += u.Detail["cache_write"] + } finish := func(result *agent.Result) *agent.Result { if result != nil { result.TotalUsage = totalUsage @@ -64,7 +78,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . input := cfg.InitialInput // Keep direct EvalLoopConfig literals compatible with the pre-InitialInput // API. Constructors always populate InitialInput. - if len(input.Parts) == 0 { + if input == nil { input = agent.TextInput(cfg.Goal) } var lastVerdict *Verdict @@ -72,11 +86,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . result, err := a.Run(ctx, input, opts...) if result != nil { totalTurns += result.Turns - totalUsage.PromptTokens += result.TotalUsage.PromptTokens - totalUsage.CompletionTokens += result.TotalUsage.CompletionTokens - totalUsage.TotalTokens += result.TotalUsage.TotalTokens - totalUsage.CacheReadTokens += result.TotalUsage.CacheReadTokens - totalUsage.CacheWriteTokens += result.TotalUsage.CacheWriteTokens + accumulate(result.TotalUsage) } if err != nil { return finish(result), lastVerdict, err @@ -91,7 +101,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . return finish(result), lastVerdict, result.Err } - a.EmitStatus(ext.EvalStateStart, ext.EvalNamespace, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0))}, cfg.TurnID) + a.EmitStatus(ext.EvalStateStart, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0))}, cfg.TurnID) verdict, evalErr := cfg.Evaluator.Evaluate( ctx, cfg.Goal, cfg.Criteria, @@ -100,7 +110,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . if evalErr != nil { cfg.Evaluator.cfg.Logger.Warnf("evaluate error (round %d): %s", round, evalErr) - a.EmitStatus(ext.EvalStateError, ext.EvalNamespace, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0)), Error: evalErr.Error()}, cfg.TurnID) + a.EmitStatus(ext.EvalStateError, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0)), Error: evalErr.Error()}, cfg.TurnID) if round == cfg.MaxEvalRounds { return finish(result), lastVerdict, evalErr } @@ -110,7 +120,7 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig, opts . } lastVerdict = verdict - a.EmitStatus(ext.EvalStateEnd, ext.EvalNamespace, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0)), Pass: verdict.Pass, Reason: verdict.Reason}, cfg.TurnID) + a.EmitStatus(ext.EvalStateEnd, &ext.EvalDetail{Round: uint32(round), MaxRounds: uint32(max(cfg.MaxEvalRounds, 0)), Pass: verdict.Pass, Reason: verdict.Reason}, cfg.TurnID) cfg.Evaluator.cfg.Logger.Importantf("evaluate round %d: pass=%v inherit_context=%v reason=%q", round, verdict.Pass, verdict.InheritContext, verdict.Reason) if verdict.Pass { diff --git a/agent/evaluator/loop_test.go b/agent/evaluator/loop_test.go index 03b9e267..79fd7619 100644 --- a/agent/evaluator/loop_test.go +++ b/agent/evaluator/loop_test.go @@ -24,19 +24,22 @@ func (p *fixedProvider) ChatCompletion(_ context.Context, request *provider.Chat func TestRunWithEvalPreservesInitialInputAndEmitsCanonicalUserMessage(t *testing.T) { agentProvider := &fixedProvider{response: &provider.ChatCompletionResponse{ - Choices: []provider.Choice{{Message: provider.NewTextMessage("assistant", "done")}}, + Choices: []provider.Choice{{Message: provider.TextMessage("assistant", "done")}}, }} verdictProvider := &fixedProvider{response: &provider.ChatCompletionResponse{ - Choices: []provider.Choice{{Message: provider.ChatMessage{ + Choices: []provider.Choice{{Message: &aop.Message{ Role: "assistant", - ToolCalls: []provider.ToolCall{{ - ID: "verdict-1", - Type: "function", - Function: provider.FunctionCall{ - Name: "verdict", - Arguments: `{"pass":true,"reason":"done","feedback":"","inherit_context":true}`, - }, - }}, + Content: []*aop.Content{ + {Value: &aop.Content_ToolCall{ToolCall: &aop.ToolCall{ + Id: "verdict-1", + Name: "verdict", + Kind: "function", + Arguments: &aop.EncodedValue{ + Data: []byte(`{"pass":true,"reason":"done","feedback":"","inherit_context":true}`), + MediaType: aop.JSONMediaType, + }, + }}}, + }, }}}, }} @@ -49,10 +52,11 @@ func TestRunWithEvalPreservesInitialInputAndEmitsCanonicalUserMessage(t *testing Bus: bus, SessionID: "root-session", }) - input := agent.Input{ - Parts: []agent.InputPart{ - {Text: "inspect this"}, - {Image: &agent.InputImage{Base64: "AA==", MediaType: "image/png"}}, + input := &aop.Message{ + Role: "user", + Content: []*aop.Content{ + aop.Text("inspect this"), + aop.Image("image/png", []byte{0x00}), }, } @@ -78,17 +82,20 @@ func TestRunWithEvalPreservesInitialInputAndEmitsCanonicalUserMessage(t *testing if agentProvider.request == nil { t.Fatal("agent provider received no request") } - var userMessage *provider.ChatMessage - for i := range agentProvider.request.Messages { - if agentProvider.request.Messages[i].Role == "user" { - userMessage = &agentProvider.request.Messages[i] + var userMessage *aop.Message + for _, m := range agentProvider.request.Messages { + if m.Role == "user" { + userMessage = m break } } - if userMessage == nil || len(userMessage.ContentParts) != 2 { + if userMessage == nil || len(userMessage.Content) != 2 { t.Fatalf("agent user message = %+v, want text and image parts", userMessage) } - if userMessage.ContentParts[0].Text != "inspect this" || userMessage.ContentParts[1].ImageURL == nil { - t.Fatalf("agent user parts = %+v, want original multimodal input", userMessage.ContentParts) + if text := userMessage.Content[0].GetText(); text == nil || text.Text != "inspect this" { + t.Fatalf("agent user part[0] = %+v, want original text", userMessage.Content[0]) + } + if media := userMessage.Content[1].GetMedia(); media == nil || media.Kind != "image" { + t.Fatalf("agent user part[1] = %+v, want original image", userMessage.Content[1]) } } diff --git a/agent/finish_tool.go b/agent/finish_tool.go index 2b0b6632..be967dce 100644 --- a/agent/finish_tool.go +++ b/agent/finish_tool.go @@ -21,11 +21,11 @@ type finishArgs struct { Summary string `json:"summary" jsonschema:"description=Brief summary of what was accomplished"` } -func (t *FinishTool) Definition() ToolDefinition { +func (t *FinishTool) Definition() *ToolDefinition { return tool.Def("finish", t.Description(), finishArgs{}) } -func (t *FinishTool) Execute(_ context.Context, arguments string) (tool.Result, error) { +func (t *FinishTool) Execute(_ context.Context, arguments string) (*tool.Result, error) { args, _ := tool.ParseArgs[finishArgs](arguments) summary := strings.TrimSpace(args.Summary) if summary == "" { diff --git a/agent/helpers_test.go b/agent/helpers_test.go index a1763d1e..bee02d3c 100644 --- a/agent/helpers_test.go +++ b/agent/helpers_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/tool" @@ -18,6 +19,93 @@ import ( "github.com/chainreactors/aiscan/skills" ) +// --- Legacy message construction shims ------------------------------------- +// These mirror the pre-proto ChatMessage shape so the many construction sites +// in the tests stay readable; chatResponse converts them to *aop.Message. + +type FunctionCall struct { + Name string + Arguments string +} + +type ToolCall struct { + ID string + Type string + Function FunctionCall +} + +type ChatMessage struct { + Role string + Content *string + ToolCalls []ToolCall +} + +func (m ChatMessage) toAOP() *aop.Message { + msg := &aop.Message{Role: m.Role} + if m.Content != nil { + msg.Content = append(msg.Content, aop.Text(*m.Content)) + } + for _, c := range m.ToolCalls { + msg.Content = append(msg.Content, toolCallContent(c.ID, c.Function.Name, c.Function.Arguments)) + } + return msg +} + +func toolCallContent(id, name, args string) *aop.Content { + return &aop.Content{Value: &aop.Content_ToolCall{ToolCall: &aop.ToolCall{ + Id: id, + Name: name, + Kind: "function", + Arguments: &aop.EncodedValue{ + Data: []byte(args), + MediaType: aop.JSONMediaType, + }, + }}} +} + +func NewTextMessage(role, text string) ChatMessage { + return ChatMessage{Role: role, Content: &text} +} + +func textMessage(role, text string) *aop.Message { + return provider.TextMessage(role, text) +} + +func toolResultMessage(callID, output string) *aop.Message { + return provider.ToolResultMessage(callID, tool.TextResult(output)) +} + +func imageMessage(role string, parts ...*aop.Content) *aop.Message { + return &aop.Message{Role: role, Content: parts} +} + +// --- Streaming event shims -------------------------------------------------- + +func roleDelta(role string) ChatCompletionStreamEvent { + return ChatCompletionStreamEvent{Role: role} +} + +func textDelta(s string) ChatCompletionStreamEvent { + return ChatCompletionStreamEvent{MessageDelta: &aop.MessageDelta{ + Value: &aop.MessageDelta_Text{Text: s}, + }} +} + +func reasoningDelta(s string) ChatCompletionStreamEvent { + return ChatCompletionStreamEvent{MessageDelta: &aop.MessageDelta{ + Value: &aop.MessageDelta_Reasoning{Reasoning: s}, + }} +} + +func toolCallDelta(index uint32, id, name, args string) ChatCompletionStreamEvent { + return ChatCompletionStreamEvent{ToolDeltas: []*aop.ToolCallDelta{{ + Index: index, + CallId: id, + Name: name, + Arguments: []byte(args), + }}} +} + func testBus(handler func(*aop.Event)) *eventbus.Bus[*aop.Event] { b := eventbus.New[*aop.Event]() if handler != nil { @@ -38,23 +126,16 @@ func (t *recordingTool) Name() string { return t.name } func (t *recordingTool) Description() string { return "recording tool" } -func (t *recordingTool) Definition() ToolDefinition { - return ToolDefinition{ - Type: "function", - Function: FunctionDefinition{ - Name: t.name, - Description: t.Description(), - Parameters: map[string]any{"type": "object"}, - }, - } +func (t *recordingTool) Definition() *aop.ToolDefinition { + return tool.Def(t.name, t.Description(), struct{}{}) } -func (t *recordingTool) Execute(_ context.Context, arguments string) (tool.Result, error) { +func (t *recordingTool) Execute(_ context.Context, arguments string) (*tool.Result, error) { t.mu.Lock() defer t.mu.Unlock() t.calls = append(t.calls, arguments) if strings.Contains(arguments, "fail") { - return tool.Result{}, fmt.Errorf("failed") + return nil, fmt.Errorf("failed") } return tool.TextResult(t.output), nil } @@ -184,12 +265,19 @@ func (p *imageErrorProvider) ChatCompletion(_ context.Context, req *ChatCompleti return nil, &APIError{StatusCode: 400, Message: "Invalid parameter: messages[5].content[1].type is not supported, unknown type: image_url"} } -func messagesContainImages(msgs []ChatMessage) bool { +func messagesContainImages(msgs []*aop.Message) bool { for _, m := range msgs { - for _, p := range m.ContentParts { - if p.Type == "image_url" { + for _, p := range m.Content { + if p.GetMedia() != nil { return true } + if r := p.GetToolResult(); r != nil { + for _, block := range r.Output { + if block.GetMedia() != nil { + return true + } + } + } } } return false @@ -226,23 +314,27 @@ func (c *stubPseudoCommand) Run(_ context.Context, execution *commands.Execution func chatResponse(msg ChatMessage) *ChatCompletionResponse { return &ChatCompletionResponse{ - Choices: []Choice{{Message: msg}}, + Choices: []Choice{{Message: msg.toAOP()}}, } } func cloneRequest(req *ChatCompletionRequest) *ChatCompletionRequest { cloned := *req - cloned.Messages = append([]ChatMessage(nil), req.Messages...) - cloned.Tools = append([]ToolDefinition(nil), req.Tools...) + cloned.Messages = append([]*aop.Message(nil), req.Messages...) + cloned.Tools = append([]*aop.ToolDefinition(nil), req.Tools...) return &cloned } -func hasToolMessage(messages []ChatMessage, toolCallID, contains string) bool { +func hasToolMessage(messages []*aop.Message, toolCallID, contains string) bool { for _, msg := range messages { - if msg.Role != "tool" || msg.ToolCallID != toolCallID || msg.Content == nil { + if msg.Role != "tool" { + continue + } + r := provider.MessageToolResult(msg) + if r == nil || r.CallId != toolCallID { continue } - if strings.Contains(*msg.Content, contains) { + if strings.Contains(tool.ResultText(r), contains) { return true } } @@ -279,11 +371,12 @@ func strPtr(s string) *string { return &s } -func contentOf(m ChatMessage) string { - if m.Content == nil { - return "" - } - return *m.Content +func messageContent(m *aop.Message) string { + return provider.MessageText(m) +} + +func contentOf(m *aop.Message) string { + return provider.MessageText(m) } func envOr(key, fallback string) string { @@ -308,8 +401,11 @@ func assertToolResult(t *testing.T, req *ChatCompletionRequest, toolCallID, cont if !hasToolMessage(req.Messages, toolCallID, contains) { var actual string for _, msg := range req.Messages { - if msg.Role == "tool" && msg.ToolCallID == toolCallID && msg.Content != nil { - actual = *msg.Content + if msg.Role != "tool" { + continue + } + if r := provider.MessageToolResult(msg); r != nil && r.CallId == toolCallID { + actual = tool.ResultText(r) break } } diff --git a/agent/hooks/hooks_test.go b/agent/hooks/hooks_test.go index e00732ad..6c072b7a 100644 --- a/agent/hooks/hooks_test.go +++ b/agent/hooks/hooks_test.go @@ -7,6 +7,8 @@ import ( "sync" "sync/atomic" "testing" + + aop "github.com/chainreactors/aiscan/aop" ) func ptr[T any](v T) *T { return &v } @@ -195,14 +197,14 @@ func TestBeforeRunFoldsSystemPromptAndAggregatesPrepend(t *testing.T) { BeforeRun.On(r, "base", func(_ context.Context, ev RunStartEvent) (RunStartResult, error) { return RunStartResult{ SystemPrompt: ptr(ev.SystemPrompt + "\nbase"), - Prepend: []Msg{{Role: "system", Content: ptr("one")}}, + Prepend: []*Msg{{Role: "system", Content: []*aop.Content{aop.Text("one")}}}, }, nil }) BeforeRun.On(r, "extra", func(_ context.Context, ev RunStartEvent) (RunStartResult, error) { observed = ev.SystemPrompt return RunStartResult{ SystemPrompt: ptr(ev.SystemPrompt + "\nextra"), - Prepend: []Msg{{Role: "user", Content: ptr("two")}}, + Prepend: []*Msg{{Role: "user", Content: []*aop.Content{aop.Text("two")}}}, }, nil }) @@ -233,7 +235,7 @@ func TestContextReplacementFolds(t *testing.T) { return ContextResult{}, nil }) - res, err := Context.Emit(context.Background(), r, ContextEvent{Messages: make([]Msg, 3)}) + res, err := Context.Emit(context.Background(), r, ContextEvent{Messages: make([]*Msg, 3)}) if err != nil { t.Fatalf("emit: %v", err) } @@ -309,7 +311,7 @@ func TestEmitFastPathDoesNotAllocate(t *testing.T) { } ctx := context.Background() - ev := ToolCallEvent{SessionID: "s1", TurnID: "t1", Call: ToolCall{ID: "c1"}} + ev := ToolCallEvent{SessionID: "s1", TurnID: "t1", Call: &ToolCall{Id: "c1"}} if got := testing.AllocsPerRun(100, func() { sinkResult, sinkErr = ToolCallHook.Emit(ctx, r, ev) diff --git a/agent/hooks/points.go b/agent/hooks/points.go index 9d717423..cce82c01 100644 --- a/agent/hooks/points.go +++ b/agent/hooks/points.go @@ -1,15 +1,16 @@ package hooks import ( - "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/tool" ) // Aliases keep event definitions readable without pulling agent in (that // would be an import cycle). type ( - Msg = provider.ChatMessage - ToolCall = provider.ToolCall + Msg = aop.Message + ToolCall = aop.ToolCall + Usage = aop.TokenUsage ) // StopReason lives here rather than in agent because run_end events carry @@ -41,7 +42,7 @@ type RunStartEvent struct { // to the turn. type RunStartResult struct { SystemPrompt *string - Prepend []Msg + Prepend []*Msg } var BeforeRun = Point[RunStartEvent, RunStartResult]{ @@ -59,12 +60,12 @@ var BeforeRun = Point[RunStartEvent, RunStartResult]{ type ContextEvent struct { SessionID string Turn int - Messages []Msg + Messages []*Msg } // ContextResult replaces the whole message list; nil means unchanged. type ContextResult struct { - Messages []Msg + Messages []*Msg } var Context = Point[ContextEvent, ContextResult]{ @@ -81,10 +82,10 @@ var Context = Point[ContextEvent, ContextResult]{ type ToolCallEvent struct { SessionID string TurnID string - AssistantMessage Msg - Call ToolCall + AssistantMessage *Msg + Call *ToolCall SystemPrompt string - Messages []Msg + Messages []*Msg } type ToolCallResult struct { @@ -103,7 +104,7 @@ var ToolCallHook = Point[ToolCallEvent, ToolCallResult]{ type ToolResultEvent struct { SessionID string TurnID string - Call ToolCall + Call *ToolCall Content string IsError bool Terminate bool @@ -143,9 +144,9 @@ type RunEndEvent struct { TurnID string Stop StopReason Output string - Messages []Msg + Messages []*Msg MessageCounter int64 - Usage provider.Usage + Usage *Usage Err error } diff --git a/agent/hooks_emit.go b/agent/hooks_emit.go index 6244beb2..aa455961 100644 --- a/agent/hooks_emit.go +++ b/agent/hooks_emit.go @@ -5,12 +5,13 @@ import ( "fmt" "github.com/chainreactors/aiscan/agent/hooks" + aop "github.com/chainreactors/aiscan/aop" ) // The kernel reaches the typed hook registry only through these helpers. Each // helper preserves the zero-handler fast path exposed by hooks.Registry. -func runStartHook(ctx context.Context, cfg Config, systemPrompt string) (string, []ChatMessage) { +func runStartHook(ctx context.Context, cfg Config, systemPrompt string) (string, []*aop.Message) { if !cfg.Hooks.Has(hooks.BeforeRun.Kind) { return systemPrompt, nil } @@ -35,12 +36,12 @@ func toolNames(cfg Config) []string { definitions := cfg.Tools.ToolDefinitions() names := make([]string, 0, len(definitions)) for _, definition := range definitions { - names = append(names, definition.Function.Name) + names = append(names, definition.Name) } return names } -func transformContextHook(ctx context.Context, cfg Config, messages []ChatMessage, turn int) []ChatMessage { +func transformContextHook(ctx context.Context, cfg Config, messages []*aop.Message, turn int) []*aop.Message { if !cfg.Hooks.Has(hooks.Context.Kind) { return messages } @@ -57,7 +58,7 @@ func transformContextHook(ctx context.Context, cfg Config, messages []ChatMessag // beforeTypedToolCall is fail-closed: a handler error means the call was not // approved and is returned to the model as a tool error. -func beforeTypedToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall) toolExecution { +func beforeTypedToolCall(ctx context.Context, cfg Config, assistantMsg *aop.Message, tc *aop.ToolCall) toolExecution { if !cfg.Hooks.Has(hooks.ToolCallHook.Kind) { return toolExecution{} } @@ -82,7 +83,7 @@ func beforeTypedToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessa return toolExecution{result: reason, isError: true} } -func afterTypedToolCall(ctx context.Context, cfg Config, tc ToolCall, execution toolExecution, durationMs int) toolExecution { +func afterTypedToolCall(ctx context.Context, cfg Config, tc *aop.ToolCall, execution toolExecution, durationMs int) toolExecution { if !cfg.Hooks.Has(hooks.ToolResult.Kind) { return execution } diff --git a/agent/inbox/expand.go b/agent/inbox/expand.go index b2dedfb0..0fedb1b5 100644 --- a/agent/inbox/expand.go +++ b/agent/inbox/expand.go @@ -20,10 +20,10 @@ type Expander struct { var atPattern = regexp.MustCompile(`@(?:(file|skill):)?(\S+)`) func (e *Expander) Expand(msg Message) Message { - if msg.ChatMessage.Content == nil { + content := messageText(msg.Message) + if content == "" { return msg } - content := *msg.ChatMessage.Content matches := atPattern.FindAllStringSubmatchIndex(content, -1) if len(matches) == 0 { return msg diff --git a/agent/inbox/expand_test.go b/agent/inbox/expand_test.go index 4f48ad40..a9e7aba5 100644 --- a/agent/inbox/expand_test.go +++ b/agent/inbox/expand_test.go @@ -13,7 +13,7 @@ func TestExpandNoReferences(t *testing.T) { if len(result.Attachments) != 0 { t.Fatalf("expected no attachments, got %d", len(result.Attachments)) } - if *result.ChatMessage.Content != "scan 10.0.0.0/24" { + if messageText(result.Message) != "scan 10.0.0.0/24" { t.Errorf("content should be unchanged") } } @@ -186,17 +186,17 @@ func TestExpandNilContent(t *testing.T) { } } -func TestToChatMessagesWithAttachments(t *testing.T) { +func TestToMessagesWithAttachments(t *testing.T) { msg := NewUserMessage("hello") msg.Attachments = []Attachment{ {Type: "file", Ref: "@/tmp/a", Content: "file-data"}, {Type: "skill", Ref: "@scan", Content: "skill-body"}, } - cms := msg.ToChatMessages() + cms := msg.ToMessages() if len(cms) != 1 { t.Fatalf("expected 1 chat message, got %d", len(cms)) } - content := *cms[0].Content + content := messageText(cms[0]) if !strings.Contains(content, "hello") { t.Error("should contain original content") } @@ -211,13 +211,13 @@ func TestToChatMessagesWithAttachments(t *testing.T) { } } -func TestToChatMessagesWithAttachmentError(t *testing.T) { +func TestToMessagesWithAttachmentError(t *testing.T) { msg := NewUserMessage("hello") msg.Attachments = []Attachment{ {Type: "file", Ref: "@/bad", Error: "not found"}, } - cms := msg.ToChatMessages() - content := *cms[0].Content + cms := msg.ToMessages() + content := messageText(cms[0]) if !strings.Contains(content, "attachment_error") { t.Error("should contain error tag") } diff --git a/agent/inbox/inbox_test.go b/agent/inbox/inbox_test.go index e8d43417..b3de3b52 100644 --- a/agent/inbox/inbox_test.go +++ b/agent/inbox/inbox_test.go @@ -17,11 +17,11 @@ func TestBufferedPushDrain(t *testing.T) { if len(msgs) != 2 { t.Fatalf("expected 2 messages, got %d", len(msgs)) } - if *msgs[0].ChatMessage.Content != "a" { - t.Errorf("expected 'a', got %q", *msgs[0].ChatMessage.Content) + if messageText(msgs[0].Message) != "a" { + t.Errorf("expected 'a', got %q", messageText(msgs[0].Message)) } - if *msgs[1].ChatMessage.Content != "b" { - t.Errorf("expected 'b', got %q", *msgs[1].ChatMessage.Content) + if messageText(msgs[1].Message) != "b" { + t.Errorf("expected 'b', got %q", messageText(msgs[1].Message)) } if b.Drain() != nil { t.Error("drain on empty buffer should return nil") @@ -66,7 +66,7 @@ func TestBufferedPriorityOrdering(t *testing.T) { } expected := []string{"high", "normal-1", "normal-2", "low"} for i, want := range expected { - got := *msgs[i].ChatMessage.Content + got := messageText(msgs[i].Message) if got != want { t.Errorf("position %d: expected %q, got %q", i, want, got) } @@ -81,7 +81,7 @@ func TestBufferedStableOrderWithinPriority(t *testing.T) { msgs := b.Drain() for i, want := range []string{"a", "b", "c"} { - got := *msgs[i].ChatMessage.Content + got := messageText(msgs[i].Message) if got != want { t.Errorf("position %d: expected %q, got %q", i, want, got) } diff --git a/agent/inbox/message.go b/agent/inbox/message.go index c4297dba..cf112e06 100644 --- a/agent/inbox/message.go +++ b/agent/inbox/message.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" ) type Origin string @@ -33,7 +33,7 @@ type Attachment struct { } type Message struct { - ChatMessage provider.ChatMessage + Message *aop.Message Origin Origin Priority Priority Attachments []Attachment @@ -43,9 +43,9 @@ type Message struct { func NewMessage(origin Origin, role, content string) Message { return Message{ - ChatMessage: provider.NewTextMessage(role, content), - Origin: origin, - CreatedAt: time.Now(), + Message: &aop.Message{Role: role, Content: []*aop.Content{aop.Text(content)}}, + Origin: origin, + CreatedAt: time.Now(), } } @@ -57,29 +57,41 @@ func NewSystemMessage(content string) Message { return NewMessage(OriginSystem, "user", content) } -func FromChatMessage(msg provider.ChatMessage, origin Origin) Message { +func FromAOPMessage(msg *aop.Message, origin Origin) Message { return Message{ - ChatMessage: msg, - Origin: origin, - CreatedAt: time.Now(), + Message: msg, + Origin: origin, + CreatedAt: time.Now(), } } -// ToChatMessages converts an inbox Message to LLM-compatible ChatMessages. +// ToMessages converts an inbox Message to LLM-bound aop messages. // User-origin messages with no attachments pass through unchanged. // All other origins get a metadata envelope so the LLM knows the source. -func (m Message) ToChatMessages() []provider.ChatMessage { - content := m.renderContent() - msg := m.ChatMessage - msg.Content = &content - return []provider.ChatMessage{msg} +func (m Message) ToMessages() []*aop.Message { + if !m.needsEnvelope() && len(m.Attachments) == 0 { + return []*aop.Message{m.Message} + } + rendered := m.renderContent() + msg := m.Message + return []*aop.Message{{Id: msg.Id, Role: msg.Role, Name: msg.Name, Content: []*aop.Content{aop.Text(rendered)}}} } -func (m Message) renderContent() string { - body := "" - if m.ChatMessage.Content != nil { - body = *m.ChatMessage.Content +func messageText(msg *aop.Message) string { + if msg == nil { + return "" + } + var sb strings.Builder + for _, part := range msg.Content { + if text := part.GetText(); text != nil { + sb.WriteString(text.Text) + } } + return sb.String() +} + +func (m Message) renderContent() string { + body := messageText(m.Message) var sb strings.Builder diff --git a/agent/input.go b/agent/input.go index 8262a62f..ac9367c8 100644 --- a/agent/input.go +++ b/agent/input.go @@ -1,11 +1,9 @@ package agent import ( - "encoding/base64" "fmt" "net/http" "os" - "strings" aop "github.com/chainreactors/aiscan/aop" ) @@ -14,145 +12,59 @@ import ( // provider limits. const maxInputImageBytes = 20 << 20 -// InputImage is a user-supplied image, either by local path (read and encoded -// by the agent) or inline base64 with an explicit media type. -type InputImage struct { - Path string - Base64 string - MediaType string +// TextInput builds a plain user message from text. +func TextInput(text string) *aop.Message { + return &aop.Message{Role: "user", Content: []*aop.Content{aop.Text(text)}} } -// InputPart is one part of a user input: text or image. -type InputPart struct { - Text string - Image *InputImage -} - -// Input is the agent's inbound unit. A text-only input becomes a plain user -// message; inputs with images become a multimodal message. -type Input struct { - MessageID string - Role string - Name string - Parts []InputPart -} - -func TextInput(text string) Input { - return Input{Parts: []InputPart{{Text: text}}} -} - -// InputFromAOPMessage maps the protocol's typed message parts into the Agent's -// provider input. Session.Run is the only runtime entry point that calls it. -func InputFromAOPMessage(message *aop.Message) Input { - input := Input{MessageID: message.GetId(), Role: message.GetRole(), Name: message.GetName()} +// resolveInputMessage prepares a user-supplied aop message for the provider: +// image parts referenced by file URI are read from disk and inlined as data, +// enforcing the size cap. +func resolveInputMessage(message *aop.Message) (*aop.Message, error) { if message == nil { - return input + return nil, fmt.Errorf("input message is required") } + resolved := *message + resolved.Content = make([]*aop.Content, 0, len(message.Content)) for _, content := range message.Content { - switch value := content.Value.(type) { - case *aop.Content_Text: - input.Parts = append(input.Parts, InputPart{Text: value.Text.Text}) - case *aop.Content_Media: - if value.Media.Kind != "image" || value.Media.Resource == nil { - continue - } - image := &InputImage{MediaType: value.Media.Resource.MediaType} - switch source := value.Media.Resource.Source.(type) { - case *aop.Resource_Data: - image.Base64 = base64.StdEncoding.EncodeToString(source.Data) - case *aop.Resource_Uri: - image.Path = source.Uri - } - input.Parts = append(input.Parts, InputPart{Image: image}) - } - } - return input -} - -// Text returns the textual parts joined in their original order. Image parts -// are intentionally omitted; callers use Parts when they need the full input. -func (in Input) Text() string { - var sb strings.Builder - for _, p := range in.Parts { - if p.Text == "" { + media := content.GetMedia() + if media == nil || media.Kind != "image" || media.Resource == nil { + resolved.Content = append(resolved.Content, content) continue } - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString(p.Text) - } - return sb.String() -} - -// chatMessage validates the input and converts it to an LLM message. -func (in Input) chatMessage() (ChatMessage, error) { - role := in.Role - if role == "" { - role = "user" - } - hasImage := false - for _, p := range in.Parts { - if p.Image != nil { - hasImage = true - break - } - } - if !hasImage { - message := NewTextMessage(role, in.Text()) - message.AOPMessageID = in.MessageID - message.Name = in.Name - return message, nil - } - parts := make([]ContentPart, 0, len(in.Parts)) - for _, p := range in.Parts { - if p.Text != "" { - parts = append(parts, TextPart(p.Text)) - } - if p.Image == nil { + resource := media.Resource + if data := resource.GetData(); len(data) > 0 { + if len(data) > maxInputImageBytes { + return nil, fmt.Errorf("image exceeds %d MiB limit", maxInputImageBytes>>20) + } + if resource.MediaType == "" { + return nil, fmt.Errorf("base64 image requires media_type") + } + resolved.Content = append(resolved.Content, content) continue } - mediaType, data, err := p.Image.load() - if err != nil { - return ChatMessage{}, err + uri := resource.GetUri() + if uri == "" { + return nil, fmt.Errorf("image part has neither data nor uri") } - parts = append(parts, ImagePart(mediaType, data, "high")) - } - message := NewMultimodalMessage(role, parts) - message.AOPMessageID = in.MessageID - message.Name = in.Name - return message, nil -} - -// load resolves the image to (mediaType, base64Data), enforcing the size cap. -func (im *InputImage) load() (string, string, error) { - if im.Path != "" { - raw, err := os.ReadFile(im.Path) + raw, err := os.ReadFile(uri) if err != nil { - return "", "", fmt.Errorf("read image %s: %w", im.Path, err) + return nil, fmt.Errorf("read image %s: %w", uri, err) } if len(raw) > maxInputImageBytes { - return "", "", fmt.Errorf("image %s exceeds %d MiB limit", im.Path, maxInputImageBytes>>20) + return nil, fmt.Errorf("image %s exceeds %d MiB limit", uri, maxInputImageBytes>>20) } - mediaType := im.MediaType + mediaType := resource.MediaType if mediaType == "" { mediaType = http.DetectContentType(raw) } - return mediaType, base64.StdEncoding.EncodeToString(raw), nil - } - if im.Base64 == "" { - return "", "", fmt.Errorf("image part has neither path nor base64 data") - } - raw, err := base64.StdEncoding.DecodeString(im.Base64) - if err != nil { - return "", "", fmt.Errorf("decode image base64: %w", err) - } - if len(raw) > maxInputImageBytes { - return "", "", fmt.Errorf("image exceeds %d MiB limit", maxInputImageBytes>>20) - } - mediaType := im.MediaType - if mediaType == "" { - return "", "", fmt.Errorf("base64 image requires media_type") + resolved.Content = append(resolved.Content, &aop.Content{Value: &aop.Content_Media{Media: &aop.MediaContent{ + Kind: "image", + Resource: &aop.Resource{ + Source: &aop.Resource_Data{Data: raw}, + MediaType: mediaType, + }, + }}}) } - return mediaType, im.Base64, nil + return &resolved, nil } diff --git a/agent/input_test.go b/agent/input_test.go index aa0d9332..86c8ce1d 100644 --- a/agent/input_test.go +++ b/agent/input_test.go @@ -1,28 +1,41 @@ package agent import ( - "encoding/base64" "os" "path/filepath" "strings" "testing" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" ) // pngBytes is a minimal PNG header so http.DetectContentType sniffs image/png. var pngBytes = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52} +func uriImageMessage(uri, mediaType string) *aop.Message { + return &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Media{Media: &aop.MediaContent{ + Kind: "image", + Resource: &aop.Resource{Source: &aop.Resource_Uri{Uri: uri}, MediaType: mediaType}, + }}}}} +} + +func dataImageMessage(data []byte, mediaType string) *aop.Message { + return &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Media{Media: &aop.MediaContent{ + Kind: "image", + Resource: &aop.Resource{Source: &aop.Resource_Data{Data: data}, MediaType: mediaType}, + }}}}} +} + func TestTextInputProducesPlainUserMessage(t *testing.T) { - msg, err := TextInput("hello").chatMessage() - if err != nil { - t.Fatal(err) - } - if msg.Role != "user" || msg.Content == nil || *msg.Content != "hello" { + msg := TextInput("hello") + if msg.Role != "user" || provider.MessageText(msg) != "hello" { t.Fatalf("message = %+v", msg) } - if len(msg.ContentParts) != 0 { - t.Fatalf("text-only input must not become multimodal: %+v", msg.ContentParts) + for _, part := range msg.Content { + if part.GetMedia() != nil { + t.Fatalf("text-only input must not become multimodal: %+v", msg.Content) + } } } @@ -32,36 +45,33 @@ func TestInputImageFromPath(t *testing.T) { t.Fatal(err) } - msg, err := (Input{Parts: []InputPart{ - {Text: "look"}, - {Image: &InputImage{Path: path}}, - }}).chatMessage() + msg, err := resolveInputMessage(&aop.Message{Role: "user", Content: []*aop.Content{ + aop.Text("look"), + uriImageMessage(path, "").Content[0], + }}) if err != nil { t.Fatal(err) } - if len(msg.ContentParts) != 2 { - t.Fatalf("parts = %+v, want text+image", msg.ContentParts) + if len(msg.Content) != 2 { + t.Fatalf("parts = %+v, want text+image", msg.Content) } - if msg.ContentParts[0].Type != "text" || msg.ContentParts[0].Text != "look" { - t.Fatalf("text part = %+v", msg.ContentParts[0]) + if msg.Content[0].GetText().GetText() != "look" { + t.Fatalf("text part = %+v", msg.Content[0]) } - img := msg.ContentParts[1] - if img.Type != "image_url" || img.ImageURL == nil { - t.Fatalf("image part = %+v", img) + media := msg.Content[1].GetMedia() + if media == nil || media.Resource == nil { + t.Fatalf("image part = %+v", msg.Content[1]) } - mediaType, data := ParseDataURI(img.ImageURL.URL) - if mediaType != "image/png" { - t.Fatalf("sniffed media type = %q, want image/png", mediaType) + if media.Resource.MediaType != "image/png" { + t.Fatalf("sniffed media type = %q, want image/png", media.Resource.MediaType) } - if data != base64.StdEncoding.EncodeToString(pngBytes) { - t.Fatal("image base64 does not round-trip the file bytes") + if string(media.Resource.GetData()) != string(pngBytes) { + t.Fatal("image data does not round-trip the file bytes") } } func TestInputImagePathMissing(t *testing.T) { - _, err := (Input{Parts: []InputPart{ - {Image: &InputImage{Path: filepath.Join(t.TempDir(), "nope.png")}}, - }}).chatMessage() + _, err := resolveInputMessage(uriImageMessage(filepath.Join(t.TempDir(), "nope.png"), "")) if err == nil || !strings.Contains(err.Error(), "read image") { t.Fatalf("err = %v", err) } @@ -74,7 +84,7 @@ func TestInputImagePathExceedsSizeCap(t *testing.T) { if err := os.WriteFile(path, raw, 0o644); err != nil { t.Fatal(err) } - _, err := (Input{Parts: []InputPart{{Image: &InputImage{Path: path}}}}).chatMessage() + _, err := resolveInputMessage(uriImageMessage(path, "")) if err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("err = %v", err) } @@ -85,49 +95,54 @@ func TestInputImagePathMediaTypeOverride(t *testing.T) { if err := os.WriteFile(path, pngBytes, 0o644); err != nil { t.Fatal(err) } - mediaType, _, err := (&InputImage{Path: path, MediaType: "image/jpeg"}).load() + msg, err := resolveInputMessage(uriImageMessage(path, "image/jpeg")) if err != nil { t.Fatal(err) } - if mediaType != "image/jpeg" { - t.Fatalf("explicit media type overridden by sniffing: %q", mediaType) + if got := msg.Content[0].GetMedia().Resource.MediaType; got != "image/jpeg" { + t.Fatalf("explicit media type overridden by sniffing: %q", got) } } -func TestInputImageBase64RequiresMediaType(t *testing.T) { - _, _, err := (&InputImage{Base64: base64.StdEncoding.EncodeToString(pngBytes)}).load() +func TestInputImageDataRequiresMediaType(t *testing.T) { + _, err := resolveInputMessage(dataImageMessage(pngBytes, "")) if err == nil || !strings.Contains(err.Error(), "media_type") { t.Fatalf("err = %v", err) } } -func TestInputImageBase64Invalid(t *testing.T) { - _, _, err := (&InputImage{Base64: "!!!not-base64!!!", MediaType: "image/png"}).load() - if err == nil || !strings.Contains(err.Error(), "base64") { +func TestInputImageDataExceedsSizeCap(t *testing.T) { + raw := make([]byte, maxInputImageBytes+1) + copy(raw, pngBytes) + _, err := resolveInputMessage(dataImageMessage(raw, "image/png")) + if err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("err = %v", err) } } -func TestInputImageBase64Passthrough(t *testing.T) { - encoded := base64.StdEncoding.EncodeToString(pngBytes) - mediaType, data, err := (&InputImage{Base64: encoded, MediaType: "image/png"}).load() +func TestInputImageDataPassthrough(t *testing.T) { + msg, err := resolveInputMessage(dataImageMessage(pngBytes, "image/png")) if err != nil { t.Fatal(err) } - if mediaType != "image/png" || data != encoded { - t.Fatalf("load = %q, %q", mediaType, data) + media := msg.Content[0].GetMedia() + if media.Resource.MediaType != "image/png" || string(media.Resource.GetData()) != string(pngBytes) { + t.Fatalf("load = %q, %d bytes", media.Resource.MediaType, len(media.Resource.GetData())) } } func TestInputImageEmptySource(t *testing.T) { - _, _, err := (&InputImage{}).load() - if err == nil || !strings.Contains(err.Error(), "neither path nor base64") { + _, err := resolveInputMessage(&aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Media{Media: &aop.MediaContent{ + Kind: "image", + Resource: &aop.Resource{}, + }}}}}) + if err == nil || !strings.Contains(err.Error(), "neither data nor uri") { t.Fatalf("err = %v", err) } } -func TestInputFromAOPMessageMapsParts(t *testing.T) { - input := InputFromAOPMessage(&aop.Message{ +func TestResolveInputMessageKeepsTextAndInlineImages(t *testing.T) { + msg, err := resolveInputMessage(&aop.Message{ Id: "m-1", Role: "user", Content: []*aop.Content{ @@ -135,10 +150,13 @@ func TestInputFromAOPMessageMapsParts(t *testing.T) { aop.Image("image/png", []byte{0, 0, 0}), }, }) - if len(input.Parts) != 2 || input.Parts[0].Text != "hi" || input.Parts[1].Image == nil { - t.Fatalf("input = %+v", input) + if err != nil { + t.Fatal(err) } - if input.Parts[1].Image.Base64 != "AAAA" || input.Parts[1].Image.MediaType != "image/png" { - t.Fatalf("image = %+v", input.Parts[1].Image) + if msg.Id != "m-1" || len(msg.Content) != 2 { + t.Fatalf("message = %+v", msg) + } + if provider.MessageText(msg) != "hi" || msg.Content[1].GetMedia() == nil { + t.Fatalf("message = %+v", msg) } } diff --git a/agent/loop.go b/agent/loop.go index 3b62143b..4da700ba 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/base64" "encoding/json" "fmt" "sort" @@ -11,13 +10,14 @@ import ( "time" "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/core/truncate" + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) func runLoop(ctx context.Context, cfg Config) (*Result, error) { @@ -63,7 +63,7 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { for turn = 1; ; turn++ { if err := ctx.Err(); err != nil { - failure := NewTextMessage("assistant", "") + failure := &aop.Message{Role: "assistant"} transcript.append(failure) return end(nil, err, StopReasonCanceled) } @@ -73,13 +73,13 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { if cfg.Expander != nil { inboxMsgs[i] = cfg.Expander.Expand(msg) } - for _, cm := range inboxMsgs[i].ToChatMessages() { + for _, cm := range inboxMsgs[i].ToMessages() { transcript.append(cm) if inboxMsgs[i].Origin == inbox.OriginUser { - if cm.AOPMessageID != "" { - em.messageWithIdentity(cm.AOPMessageID, cm.Role, cm.Name, messagePartsFromChat(cm)) + if cm.Id != "" { + em.messageWithIdentity(cm.Id, cm.Role, cm.Name, cm.Content) } else { - em.message(cm.Role, messagePartsFromChat(cm)) + em.message(cm.Role, cm.Content) } } } @@ -108,7 +108,7 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { } cfg.Logger.Debugf("[turn %d] sending %d messages to LLM", turn, len(reqMessages)) - assistantMsg, usage, err := requestWithRetry(ctx, cfg, em, reqMessages, toolDefinitions, turn) + assistant, usage, err := requestWithRetry(ctx, cfg, em, reqMessages, toolDefinitions, turn) transcript.recordTurnUsage(turn, usage) if err != nil { if ctx.Err() != nil { @@ -127,8 +127,8 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { transcript.completedTurns = turn return end(nil, err, StopReasonError) } - assistantMsg = normalizeToolCalls(assistantMsg) - if isLengthContextOverflow(assistantMsg.FinishReason, usage, cfg.ContextWindow) { + assistant.normalize() + if isLengthContextOverflow(assistant.finishReason, usage, cfg.ContextWindow) { if !overflowRecoveryAttempted { compacted, compactErr := runAutoCompaction(ctx, cfg, em, transcript, "overflow", transcript.contextTokens) if compactErr != nil { @@ -141,39 +141,39 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { } promptTokens := 0 if usage != nil { - promptTokens = usage.PromptTokens + promptTokens = int(usage.InputTokens) } overflowErr := fmt.Errorf("LLM context overflow at turn %d (finish_reason=%s, prompt_tokens=%d)", - turn, assistantMsg.FinishReason, promptTokens) + turn, assistant.finishReason, promptTokens) transcript.completedTurns = turn return end(nil, overflowErr, StopReasonError) } overflowRecoveryAttempted = false - if cfg.TokenBudget > 0 && transcript.totalUsage.TotalTokens >= cfg.TokenBudget && len(assistantMsg.ToolCalls) > 0 { - cfg.Logger.Warnf("token budget exhausted: %d/%d", transcript.totalUsage.TotalTokens, cfg.TokenBudget) - result := transcript.result(messageContent(assistantMsg), turn, fmt.Errorf("token budget exhausted: %d/%d", transcript.totalUsage.TotalTokens, cfg.TokenBudget)) + if cfg.TokenBudget > 0 && transcript.totalUsage.GetTotalTokens() >= uint64(cfg.TokenBudget) && len(assistant.toolCalls) > 0 { + cfg.Logger.Warnf("token budget exhausted: %d/%d", transcript.totalUsage.GetTotalTokens(), cfg.TokenBudget) + result := transcript.result(provider.MessageText(assistant.message), turn, fmt.Errorf("token budget exhausted: %d/%d", transcript.totalUsage.GetTotalTokens(), cfg.TokenBudget)) return end(result, result.Err, StopReasonBudget) } - transcript.append(assistantMsg) + transcript.append(assistant.message) if cfg.TokenBudget > 0 { - if transcript.totalUsage.TotalTokens >= cfg.TokenBudget { - cfg.Logger.Warnf("token budget exhausted: %d/%d", transcript.totalUsage.TotalTokens, cfg.TokenBudget) - result := transcript.result(messageContent(assistantMsg), turn, fmt.Errorf("token budget exhausted: %d/%d", transcript.totalUsage.TotalTokens, cfg.TokenBudget)) + if transcript.totalUsage.GetTotalTokens() >= uint64(cfg.TokenBudget) { + cfg.Logger.Warnf("token budget exhausted: %d/%d", transcript.totalUsage.GetTotalTokens(), cfg.TokenBudget) + result := transcript.result(provider.MessageText(assistant.message), turn, fmt.Errorf("token budget exhausted: %d/%d", transcript.totalUsage.GetTotalTokens(), cfg.TokenBudget)) return end(result, result.Err, StopReasonBudget) } - if transcript.totalUsage.TotalTokens >= cfg.TokenBudget*DefaultTokenBudgetWarningPct/100 { - em.status(statusTokenBudgetWarning, aopStatusNamespace, &transport.BudgetWarning{ + if transcript.totalUsage.GetTotalTokens() >= uint64(cfg.TokenBudget)*DefaultTokenBudgetWarningPct/100 { + em.status(statusTokenBudgetWarning, &agentpb.BudgetWarning{ ContextTokens: uint64(max(transcript.contextTokens, 0)), TokenBudget: uint64(max(cfg.TokenBudget, 0)), }) - cfg.Logger.Warnf("token budget warning: %d/%d (80%%)", transcript.totalUsage.TotalTokens, cfg.TokenBudget) + cfg.Logger.Warnf("token budget warning: %d/%d (80%%)", transcript.totalUsage.GetTotalTokens(), cfg.TokenBudget) } } - var toolResults []ChatMessage + var toolResults []*aop.Message terminate := false - if len(assistantMsg.ToolCalls) > 0 { - cfg.Messages = append([]ChatMessage(nil), transcript.messages...) - batch, err := executeToolCalls(ctx, cfg, em, assistantMsg, turn) + if len(assistant.toolCalls) > 0 { + cfg.Messages = append([]*aop.Message(nil), transcript.messages...) + batch, err := executeToolCalls(ctx, cfg, em, assistant, turn) if err != nil { if ctx.Err() != nil { return end(nil, ctx.Err(), StopReasonCanceled) @@ -189,17 +189,17 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { transcript.completedTurns = turn if cfg.MaxTurns > 0 && turn >= cfg.MaxTurns { - cfg.Logger.Debugf("agent status=stopped turns=%d/%d tokens=%d", turn, cfg.MaxTurns, transcript.totalUsage.TotalTokens) - result := transcript.result(messageContent(assistantMsg), turn, nil) + cfg.Logger.Debugf("agent status=stopped turns=%d/%d tokens=%d", turn, cfg.MaxTurns, transcript.totalUsage.GetTotalTokens()) + result := transcript.result(provider.MessageText(assistant.message), turn, nil) return end(result, nil, StopReasonStopped) } if terminate { - cfg.Logger.Debugf("agent status=completed turns=%d tokens=%d", turn, transcript.totalUsage.TotalTokens) - result := transcript.result(messageContent(assistantMsg), turn, nil) + cfg.Logger.Debugf("agent status=completed turns=%d tokens=%d", turn, transcript.totalUsage.GetTotalTokens()) + result := transcript.result(provider.MessageText(assistant.message), turn, nil) return end(result, nil, StopReasonTerminated) } - if len(assistantMsg.ToolCalls) == 0 { + if len(assistant.toolCalls) == 0 { if ib != nil && ib.Len() > 0 { cfg.Logger.Debugf("[turn %d] continuing for pending inbox message(s)", turn) continue @@ -217,38 +217,107 @@ func runLoop(ctx context.Context, cfg Config) (*Result, error) { } } - cfg.Logger.Debugf("agent status=completed turns=%d tokens=%d", turn, transcript.totalUsage.TotalTokens) - result := transcript.result(messageContent(assistantMsg), turn, nil) + cfg.Logger.Debugf("agent status=completed turns=%d tokens=%d", turn, transcript.totalUsage.GetTotalTokens()) + result := transcript.result(provider.MessageText(assistant.message), turn, nil) return end(result, nil, StopReasonCompleted) } } } +// assistantTurn carries one model response: the aop message, its tool calls in +// execution order, and response metadata that has no place in the proto. +type assistantTurn struct { + message *aop.Message + toolCalls []*aop.ToolCall + finishReason string + rejected map[int]string // tool call index → rejection reason +} + +func (a *assistantTurn) normalize() { + if a.message == nil { + a.message = &aop.Message{Role: "assistant"} + } + if a.message.Role == "" { + a.message.Role = "assistant" + } + a.toolCalls = provider.MessageToolCalls(a.message) + a.rejected = nil + if len(a.toolCalls) == 0 { + return + } + truncated := isOutputLimitFinishReason(a.finishReason) + for i, call := range a.toolCalls { + rejected := truncated + reason := truncatedToolCallError + call.Id = strings.TrimSpace(call.Id) + call.Name = strings.TrimSpace(call.Name) + arguments := "" + if call.Arguments != nil { + arguments = strings.TrimSpace(string(call.Arguments.Data)) + } + if arguments == "" { + arguments = "{}" + } + call.Arguments = &aop.EncodedValue{Data: []byte(arguments), MediaType: aop.JSONMediaType} + if !rejected { + var args map[string]any + if call.Id == "" || call.Name == "" || + json.Unmarshal([]byte(arguments), &args) != nil || args == nil { + rejected = true + reason = invalidToolCallError + } + } + if !rejected { + if call.Kind == "" { + call.Kind = "function" + } + continue + } + if call.Id == "" { + call.Id = fmt.Sprintf("rejected_tool_call_%d", i+1) + } + if call.Kind == "" { + call.Kind = "function" + } + if call.Name == "" { + call.Name = "unknown_tool" + } + // Invalid JSON would poison the next Anthropic request during history + // serialization. Rejected arguments are never safe to execute or retain. + call.Arguments = &aop.EncodedValue{Data: []byte("{}"), MediaType: aop.JSONMediaType} + if a.rejected == nil { + a.rejected = make(map[int]string) + } + a.rejected[i] = reason + } +} + type transcript struct { - messages []ChatMessage - newMessages []ChatMessage + messages []*aop.Message + newMessages []*aop.Message completedTurns int - turnUsages []TurnUsage - totalUsage Usage + turnUsages []*aop.TokenUsage + totalUsage *aop.TokenUsage contextTokens int usageMessageCount int } -func newTranscript(base []ChatMessage, newCapacity int) *transcript { +func newTranscript(base []*aop.Message, newCapacity int) *transcript { return &transcript{ - messages: append([]ChatMessage(nil), base...), - newMessages: make([]ChatMessage, 0, newCapacity), + messages: append([]*aop.Message(nil), base...), + newMessages: make([]*aop.Message, 0, newCapacity), + totalUsage: &aop.TokenUsage{Detail: map[string]uint64{}}, } } -func (t *transcript) append(messages ...ChatMessage) { +func (t *transcript) append(messages ...*aop.Message) { t.messages = append(t.messages, messages...) t.newMessages = append(t.newMessages, messages...) } -func (t *transcript) replace(messages []ChatMessage, contextTokens int) { - t.messages = append([]ChatMessage(nil), messages...) +func (t *transcript) replace(messages []*aop.Message, contextTokens int) { + t.messages = append([]*aop.Message(nil), messages...) t.contextTokens = contextTokens t.usageMessageCount = len(messages) } @@ -287,7 +356,7 @@ func runAutoCompaction(ctx context.Context, cfg Config, em *aopEmitter, transcri return false, nil } - em.status(ext.CompactStateStart, "", nil) + em.status(ext.CompactStateStart, nil) newMessages, result, err := compactHistory(ctx, CompactConfig{ Provider: cfg.Provider, Model: cfg.Model, @@ -296,11 +365,11 @@ func runAutoCompaction(ctx context.Context, cfg Config, em *aopEmitter, transcri MaxTokens: cfg.MaxTokens, }, transcript.messages) if err != nil { - em.status(ext.CompactStateError, ext.CompactNamespace, &ext.CompactDetail{Error: err.Error()}) + em.status(ext.CompactStateError, &ext.CompactDetail{Error: err.Error()}) return false, err } transcript.replace(newMessages, result.TokensAfter) - em.status(ext.CompactStateEnd, ext.CompactNamespace, &ext.CompactDetail{ + em.status(ext.CompactStateEnd, &ext.CompactDetail{ TokensBefore: uint64(max(result.TokensBefore, 0)), TokensAfter: uint64(max(result.TokensAfter, 0)), KeptMessages: uint64(max(result.KeptMessages, 0)), @@ -331,31 +400,24 @@ func effectiveCompactionLimits(contextWindow int, settings CompactionSettings) ( return reserve, keepRecent } -func (t *transcript) recordTurnUsage(turn int, usage *Usage) { +func (t *transcript) recordTurnUsage(turn int, usage *aop.TokenUsage) { if usage == nil { return } - t.turnUsages = append(t.turnUsages, TurnUsage{ - Turn: turn, - PromptTokens: usage.PromptTokens, - CompletionTokens: usage.CompletionTokens, - TotalTokens: usageTotalTokens(usage), - CacheReadTokens: usage.CacheReadTokens, - CacheWriteTokens: usage.CacheWriteTokens, - }) - t.totalUsage.PromptTokens += usage.PromptTokens - t.totalUsage.CompletionTokens += usage.CompletionTokens - t.totalUsage.TotalTokens += usageTotalTokens(usage) - t.totalUsage.CacheReadTokens += usage.CacheReadTokens - t.totalUsage.CacheWriteTokens += usage.CacheWriteTokens - t.contextTokens = usageTotalTokens(usage) + t.turnUsages = append(t.turnUsages, usage) + t.totalUsage.InputTokens += usage.InputTokens + t.totalUsage.OutputTokens += usage.OutputTokens + t.totalUsage.TotalTokens += usage.TotalTokens + t.totalUsage.Detail["cache_read"] += usage.Detail["cache_read"] + t.totalUsage.Detail["cache_write"] += usage.Detail["cache_write"] + t.contextTokens = provider.UsageTotalTokens(usage) // Provider usage covers the request plus the assistant response that will be // appended immediately after this call. t.usageMessageCount = len(t.messages) + 1 } -func (t *transcript) snapshot() ([]ChatMessage, []ChatMessage) { - return append([]ChatMessage(nil), t.messages...), append([]ChatMessage(nil), t.newMessages...) +func (t *transcript) snapshot() ([]*aop.Message, []*aop.Message) { + return append([]*aop.Message(nil), t.messages...), append([]*aop.Message(nil), t.newMessages...) } func (t *transcript) result(output string, turns int, err error) *Result { @@ -366,38 +428,38 @@ func (t *transcript) result(output string, turns int, err error) *Result { Messages: messages, Turns: turns, TotalUsage: t.totalUsage, - TurnUsages: append([]TurnUsage(nil), t.turnUsages...), + TurnUsages: append([]*aop.TokenUsage(nil), t.turnUsages...), ContextTokens: t.contextTokens, Err: err, } } type toolBatchResult struct { - messages []ChatMessage + messages []*aop.Message terminate bool } -func executeToolCalls(ctx context.Context, cfg Config, em *aopEmitter, assistantMsg ChatMessage, turn int) (toolBatchResult, error) { - toolCalls := assistantMsg.ToolCalls +func executeToolCalls(ctx context.Context, cfg Config, em *aopEmitter, assistant *assistantTurn, turn int) (toolBatchResult, error) { + toolCalls := assistant.toolCalls slots := make([]toolCallSlot, len(toolCalls)) for i, tc := range toolCalls { - slots[i] = toolCallSlot{tc: tc} + slots[i] = toolCallSlot{tc: tc, rejectedReason: assistant.rejected[i]} } for _, tc := range toolCalls { - em.toolCall(tc.ID, tc.Function.Name, parseToolArgs(tc.Function.Arguments), "") + em.toolCall(tc) } sem := make(chan struct{}, cfg.MaxParallelTools) var wg sync.WaitGroup for i := range slots { - if slots[i].tc.RejectedReason != "" { + if slots[i].rejectedReason != "" { slots[i].startedAt = time.Now() slots[i].result = toolExecution{ - result: slots[i].tc.RejectedReason, rawResult: slots[i].tc.RejectedReason, isError: true, + result: slots[i].rejectedReason, rawResult: slots[i].rejectedReason, isError: true, } cfg.Logger.Warnf("[turn %d] rejected unsafe tool call name=%s reason=%s", - turn, slots[i].tc.Function.Name, slots[i].tc.RejectedReason) + turn, slots[i].tc.Name, slots[i].rejectedReason) continue } wg.Add(1) @@ -406,25 +468,19 @@ func executeToolCalls(ctx context.Context, cfg Config, em *aopEmitter, assistant defer wg.Done() defer func() { <-sem }() slots[i].startedAt = time.Now() - slots[i].result = runToolCall(ctx, cfg, assistantMsg, slots[i].tc, turn) + slots[i].result = runToolCall(ctx, cfg, assistant.message, slots[i].tc, turn) }() } wg.Wait() // Emit results in original order. - messages := make([]ChatMessage, 0, len(slots)) + messages := make([]*aop.Message, 0, len(slots)) terminations := 0 for _, s := range slots { - var details any - if s.result.fullResult != nil { - details = s.result.fullResult.Details - } - em.toolResult(s.tc.ID, s.tc.Function.Name, s.result.eventContent(), details, s.result.flow == ToolFlowTerminate, s.result.isError, + em.toolResult(s.tc, s.result.eventContent(), s.result.fullResult, s.result.flow == ToolFlowTerminate, s.result.isError, int(time.Since(s.startedAt).Milliseconds())) - cfg.Logger.Debugf("[turn %d] tool_result name=%s bytes=%d", turn, s.tc.Function.Name, len(s.result.result)) - toolMsg := toolResultToMessage(s.tc.ID, s.result) - toolMsg.ToolResultIsError = s.tc.RejectedReason != "" - messages = append(messages, toolMsg) + cfg.Logger.Debugf("[turn %d] tool_result name=%s bytes=%d", turn, s.tc.Name, len(s.result.result)) + messages = append(messages, s.result.toMessage(s.tc.Id)) if s.result.flow == ToolFlowTerminate { terminations++ } @@ -448,62 +504,11 @@ func isOutputLimitFinishReason(reason string) bool { } } -func normalizeToolCalls(msg ChatMessage) ChatMessage { - if len(msg.ToolCalls) == 0 { - return msg - } - msg.ToolCalls = append([]ToolCall(nil), msg.ToolCalls...) - truncated := isOutputLimitFinishReason(msg.FinishReason) - for i := range msg.ToolCalls { - tc := &msg.ToolCalls[i] - rejected := truncated - reason := truncatedToolCallError - if tc.RejectedReason != "" { - rejected = true - reason = tc.RejectedReason - } - tc.ID = strings.TrimSpace(tc.ID) - tc.Function.Name = strings.TrimSpace(tc.Function.Name) - arguments := strings.TrimSpace(tc.Function.Arguments) - if arguments == "" { - arguments = "{}" - } - tc.Function.Arguments = arguments - if !rejected { - var args map[string]any - if tc.ID == "" || tc.Function.Name == "" || - json.Unmarshal([]byte(arguments), &args) != nil || args == nil { - rejected = true - reason = invalidToolCallError - } - } - if !rejected { - if tc.Type == "" { - tc.Type = "function" - } - continue - } - if tc.ID == "" { - tc.ID = fmt.Sprintf("rejected_tool_call_%d", i+1) - } - if tc.Type == "" { - tc.Type = "function" - } - if tc.Function.Name == "" { - tc.Function.Name = "unknown_tool" - } - // Invalid JSON would poison the next Anthropic request during history - // serialization. Rejected arguments are never safe to execute or retain. - tc.Function.Arguments = "{}" - tc.RejectedReason = reason - } - return msg -} - type toolCallSlot struct { - tc ToolCall - result toolExecution - startedAt time.Time + tc *aop.ToolCall + rejectedReason string + result toolExecution + startedAt time.Time } type toolExecution struct { @@ -515,26 +520,33 @@ type toolExecution struct { flow ToolFlowDecision } -func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall, turn int) toolExecution { +func runToolCall(ctx context.Context, cfg Config, assistantMsg *aop.Message, tc *aop.ToolCall, turn int) toolExecution { startedAt := time.Now() - toolCtx := output.ContextWithCallID(ctx, tc.ID) + toolCtx := output.ContextWithCallID(ctx, tc.Id) toolCtx = withToolAgentConfig(toolCtx, cfg) toolCtx = inbox.ContextWithInbox(toolCtx, cfg.Inbox) execution := beforeToolCall(toolCtx, cfg, assistantMsg, tc) if execution.result == "" && !execution.isError { - toolResult, execErr := cfg.Tools.ExecuteTool(toolCtx, tc.Function.Name, tc.Function.Arguments) - execution.result = toolResult.Text() + arguments := "" + if tc.Arguments != nil { + arguments = string(tc.Arguments.Data) + } + toolResult, execErr := cfg.Tools.ExecuteTool(toolCtx, tc.Name, arguments) + if toolResult == nil { + toolResult = &tool.Result{} + } + execution.result = tool.ResultText(toolResult) execution.err = execErr execution.isError = execErr != nil || toolResult.IsError if execErr != nil { execution.result = fmt.Sprintf("error: %s", execErr.Error()) - cfg.Logger.Warnf("[turn %d] tool_error name=%s error=%q", turn, tc.Function.Name, execErr.Error()) + cfg.Logger.Warnf("[turn %d] tool_error name=%s error=%q", turn, tc.Name, execErr.Error()) } if toolResult.Terminate { execution.flow = ToolFlowTerminate } - if toolResult.HasImages() || toolResult.Details != nil || toolResult.Terminate { - execution.fullResult = &toolResult + if tool.ResultHasImages(toolResult) || toolResult.Terminate { + execution.fullResult = toolResult } } if execution.rawResult == "" { @@ -553,13 +565,13 @@ func (e toolExecution) eventContent() []*aop.Content { if e.fullResult == nil { return content } - for _, block := range e.fullResult.Content { - if block.Type != "image" { + for _, block := range e.fullResult.Output { + media := block.GetMedia() + if media == nil || media.Kind != "image" || media.Resource == nil { continue } - data, err := base64.StdEncoding.DecodeString(block.Base64Data) - if err == nil { - content = append(content, aop.Image(block.MimeType, data)) + if data := media.Resource.GetData(); len(data) > 0 { + content = append(content, aop.Image(media.Resource.MediaType, data)) } } return content @@ -572,34 +584,31 @@ func (e toolExecution) eventResultText() string { return e.result } -func parseToolArgs(raw string) any { - if raw == "" { - return map[string]any{} +// toMessage converts the execution into the tool-role message appended to the +// transcript. Image outputs ride along as media parts; the result text is +// always present so text-only providers keep working. +func (e toolExecution) toMessage(toolCallID string) *aop.Message { + result := &aop.ToolResult{ + CallId: toolCallID, + IsError: e.isError, + Terminate: e.flow == ToolFlowTerminate, } - var m map[string]any - if err := json.Unmarshal([]byte(raw), &m); err == nil { - return m - } - return raw -} - -func toolResultToMessage(toolCallID string, exec toolExecution) ChatMessage { - if exec.fullResult != nil && exec.fullResult.HasImages() { - parts := make([]ContentPart, 0, len(exec.fullResult.Content)) - for _, block := range exec.fullResult.Content { - switch block.Type { - case "text": - parts = append(parts, TextPart(block.Text)) - case "image": - parts = append(parts, ImagePart(block.MimeType, block.Base64Data, "high")) + if e.fullResult != nil && tool.ResultHasImages(e.fullResult) { + for _, block := range e.fullResult.Output { + if text := block.GetText(); text != nil { + result.Output = append(result.Output, aop.Text(text.Text)) + } + if media := block.GetMedia(); media != nil && media.Kind == "image" && media.Resource != nil { + result.Output = append(result.Output, block) } } - return ChatMessage{Role: "tool", ToolCallID: toolCallID, ContentParts: parts} + } else { + result.Output = []*aop.Content{aop.Text(e.result)} } - return NewToolResultMessage(toolCallID, exec.result) + return &aop.Message{Role: "tool", Content: []*aop.Content{{Value: &aop.Content_ToolResult{ToolResult: result}}}} } -func beforeToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall) toolExecution { +func beforeToolCall(ctx context.Context, cfg Config, assistantMsg *aop.Message, tc *aop.ToolCall) toolExecution { if cfg.BeforeToolCall != nil { before, err := cfg.BeforeToolCall(ctx, BeforeToolCallContext{ AssistantMessage: assistantMsg, @@ -621,7 +630,7 @@ func beforeToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, t return beforeTypedToolCall(ctx, cfg, assistantMsg, tc) } -func afterToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall, execution toolExecution, durationMs int64) toolExecution { +func afterToolCall(ctx context.Context, cfg Config, assistantMsg *aop.Message, tc *aop.ToolCall, execution toolExecution, durationMs int64) toolExecution { if cfg.AfterToolCall != nil { after, err := cfg.AfterToolCall(ctx, AfterToolCallContext{ AssistantMessage: assistantMsg, @@ -653,24 +662,23 @@ func afterToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc return afterTypedToolCall(ctx, cfg, tc, execution, int(durationMs)) } -func requestMessages(ctx context.Context, cfg Config, systemPrompt string, messages []ChatMessage, turn int) []ChatMessage { - out := sanitizeMessages(append([]ChatMessage(nil), messages...)) +func requestMessages(ctx context.Context, cfg Config, systemPrompt string, messages []*aop.Message, turn int) []*aop.Message { + out := sanitizeMessages(append([]*aop.Message(nil), messages...)) if cfg.TransformContext != nil { out = cfg.TransformContext(out) } out = transformContextHook(ctx, cfg, out, turn) if systemPrompt != "" { - out = append([]ChatMessage{NewTextMessage("system", systemPrompt)}, out...) + out = append([]*aop.Message{provider.TextMessage("system", systemPrompt)}, out...) } return out } -func sanitizeMessages(msgs []ChatMessage) []ChatMessage { - out := make([]ChatMessage, 0, len(msgs)) +func sanitizeMessages(msgs []*aop.Message) []*aop.Message { + out := make([]*aop.Message, 0, len(msgs)) for _, m := range msgs { - if m.Role == "assistant" && len(m.ToolCalls) == 0 && - messageContent(m) == "" && len(m.ContentParts) == 0 && - (m.ReasoningContent == nil || *m.ReasoningContent == "") { + if m.Role == "assistant" && len(provider.MessageToolCalls(m)) == 0 && + provider.MessageText(m) == "" && provider.MessageReasoning(m) == "" { continue } out = append(out, m) @@ -678,22 +686,16 @@ func sanitizeMessages(msgs []ChatMessage) []ChatMessage { return out } -func messageContent(msg ChatMessage) string { - if msg.Content == nil { - return "" - } - return *msg.Content -} - -func logUsage(logger telemetry.Logger, usage *Usage) { +func logUsage(logger telemetry.Logger, usage *aop.TokenUsage) { if usage != nil { - if usage.CacheReadTokens > 0 || usage.CacheWriteTokens > 0 { + cacheRead := usage.Detail["cache_read"] + cacheWrite := usage.Detail["cache_write"] + if cacheRead > 0 || cacheWrite > 0 { logger.Debugf("usage prompt=%d completion=%d total=%d cache_read=%d cache_write=%d", - usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, - usage.CacheReadTokens, usage.CacheWriteTokens) + usage.InputTokens, usage.OutputTokens, usage.TotalTokens, cacheRead, cacheWrite) } else { logger.Debugf("usage prompt=%d completion=%d total=%d", - usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens) + usage.InputTokens, usage.OutputTokens, usage.TotalTokens) } } } @@ -705,60 +707,66 @@ func schedulerActive(s *LoopScheduler) int { return s.Active() } +// messageBuilder accumulates streamed deltas into one assistant message. type messageBuilder struct { - role string - content strings.Builder - reasoningContent strings.Builder - toolCalls map[int]*ToolCall + role string + content strings.Builder + reasoning strings.Builder + toolCalls map[int]*streamedToolCall +} + +type streamedToolCall struct { + id string + kind string + name string + arguments strings.Builder } func newMessageBuilder() *messageBuilder { return &messageBuilder{ role: "assistant", - toolCalls: make(map[int]*ToolCall), + toolCalls: make(map[int]*streamedToolCall), } } -func (b *messageBuilder) Apply(delta ChatMessageDelta) ChatMessage { - if delta.Role != "" { - b.role = delta.Role - } - if delta.Content != nil { - b.content.WriteString(*delta.Content) +func (b *messageBuilder) Apply(event ChatCompletionStreamEvent) { + if event.Role != "" { + b.role = event.Role } - if delta.ReasoningContent != nil { - b.reasoningContent.WriteString(*delta.ReasoningContent) + if delta := event.MessageDelta; delta != nil { + switch value := delta.Value.(type) { + case *aop.MessageDelta_Text: + b.content.WriteString(value.Text) + case *aop.MessageDelta_Reasoning: + b.reasoning.WriteString(value.Reasoning) + } } - for _, tcDelta := range delta.ToolCalls { - tc := b.toolCalls[tcDelta.Index] + for _, tcDelta := range event.ToolDeltas { + index := int(tcDelta.Index) + tc := b.toolCalls[index] if tc == nil { - tc = &ToolCall{Type: "function"} - b.toolCalls[tcDelta.Index] = tc - } - if tcDelta.ID != "" { - tc.ID = tcDelta.ID + tc = &streamedToolCall{kind: "function"} + b.toolCalls[index] = tc } - if tcDelta.Type != "" { - tc.Type = tcDelta.Type + if tcDelta.CallId != "" { + tc.id = tcDelta.CallId } - if tcDelta.Function.Name != "" { - tc.Function.Name = tcDelta.Function.Name + if tcDelta.Name != "" { + tc.name = tcDelta.Name } - if tcDelta.Function.Arguments != "" { - tc.Function.Arguments += tcDelta.Function.Arguments + if len(tcDelta.Arguments) > 0 { + tc.arguments.Write(tcDelta.Arguments) } } - return b.Message() } -func (b *messageBuilder) Message() ChatMessage { - content := b.content.String() - msg := ChatMessage{Role: b.role} - if content != "" { - msg.Content = &content +func (b *messageBuilder) Message() *aop.Message { + msg := &aop.Message{Role: b.role} + if reasoning := b.reasoning.String(); reasoning != "" { + msg.Content = append(msg.Content, aop.Reasoning(reasoning)) } - if reasoningContent := b.reasoningContent.String(); reasoningContent != "" { - msg.ReasoningContent = &reasoningContent + if content := b.content.String(); content != "" { + msg.Content = append(msg.Content, aop.Text(content)) } if len(b.toolCalls) > 0 { indexes := make([]int, 0, len(b.toolCalls)) @@ -766,10 +774,35 @@ func (b *messageBuilder) Message() ChatMessage { indexes = append(indexes, index) } sort.Ints(indexes) - msg.ToolCalls = make([]ToolCall, 0, len(indexes)) for _, index := range indexes { - msg.ToolCalls = append(msg.ToolCalls, *b.toolCalls[index]) + tc := b.toolCalls[index] + kind := tc.kind + if kind == "" { + kind = "function" + } + msg.Content = append(msg.Content, &aop.Content{Value: &aop.Content_ToolCall{ToolCall: &aop.ToolCall{ + Id: tc.id, + Name: tc.name, + Kind: kind, + Arguments: &aop.EncodedValue{ + Data: []byte(tc.arguments.String()), + MediaType: aop.JSONMediaType, + }, + }}}) } } return msg } + +// decodeToolArguments renders a tool call's arguments as a JSON value for +// event payloads. +func decodeToolArguments(call *aop.ToolCall) any { + if call == nil || call.Arguments == nil || len(call.Arguments.Data) == 0 { + return map[string]any{} + } + var m map[string]any + if err := json.Unmarshal(call.Arguments.Data, &m); err == nil { + return m + } + return string(call.Arguments.Data) +} diff --git a/agent/loop_test.go b/agent/loop_test.go index dae7e2bd..fd39076c 100644 --- a/agent/loop_test.go +++ b/agent/loop_test.go @@ -9,9 +9,11 @@ import ( "time" "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/agent/provider" "github.com/chainreactors/aiscan/agent/tmux" aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" + "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/pkg/commands" ) @@ -55,6 +57,7 @@ func TestRunEmitsTurnEndAfterToolResults(t *testing.T) { want := []string{ "message", "status", + "message", "tool.call", "tool.result", "status", @@ -77,7 +80,7 @@ func TestTransformContextAppliesOnlyToProviderRequest(t *testing.T) { Provider: llm, Tools: tools, Model: "test", - TransformContext: func(messages []ChatMessage) []ChatMessage { + TransformContext: func(messages []*aop.Message) []*aop.Message { if len(messages) <= 1 { return messages } @@ -91,7 +94,7 @@ func TestTransformContextAppliesOnlyToProviderRequest(t *testing.T) { t.Fatalf("second prompt error = %v", err) } requests := llm.requestsSnapshot() - if len(requests[1].Messages) != 1 || *requests[1].Messages[0].Content != "two" { + if len(requests[1].Messages) != 1 || provider.MessageText(requests[1].Messages[0]) != "two" { t.Fatalf("transform not applied to request: %#v", requests[1].Messages) } if got := len(a.state.Messages); got != 4 { @@ -140,9 +143,9 @@ func TestStreamingProviderEmitsMessageUpdates(t *testing.T) { tools := commands.NewRegistry() llm := &scriptedProvider{ streamEvents: []ChatCompletionStreamEvent{ - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{Content: strPtr("hel")}}, - {Delta: ChatMessageDelta{Content: strPtr("lo")}}, + roleDelta("assistant"), + textDelta("hel"), + textDelta("lo"), {Done: true}, }, } @@ -185,12 +188,12 @@ func TestStreamingMessageUpdateCarriesUsage(t *testing.T) { tools := commands.NewRegistry() llm := &scriptedProvider{ streamEvents: []ChatCompletionStreamEvent{ - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{Content: strPtr("done")}}, - {Done: true, Usage: &Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12}}, + roleDelta("assistant"), + textDelta("done"), + {Done: true, Usage: provider.TokenUsage(10, 2, 12, 0, 0)}, }, } - var updateUsage *Usage + var updateUsage *aop.TokenUsage result, err := (NewAgent(Config{ Provider: llm, Tools: tools, @@ -204,11 +207,7 @@ func TestStreamingMessageUpdateCarriesUsage(t *testing.T) { if data == nil { return } - updateUsage = &Usage{ - PromptTokens: int(data.InputTokens), - CompletionTokens: int(data.OutputTokens), - TotalTokens: int(data.TotalTokens), - } + updateUsage = data }), })).Run(context.Background(), TextInput("stream")) if err != nil { @@ -226,9 +225,9 @@ func TestStatefulAgentTracksStreamingMessage(t *testing.T) { tools := commands.NewRegistry() llm := &scriptedProvider{ streamEvents: []ChatCompletionStreamEvent{ - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{Content: strPtr("hel")}}, - {Delta: ChatMessageDelta{Content: strPtr("lo")}}, + roleDelta("assistant"), + textDelta("hel"), + textDelta("lo"), {Done: true}, }, } @@ -264,25 +263,14 @@ func TestStreamingToolCallDeltasAreAggregated(t *testing.T) { llm := &scriptedProvider{ streamEventBatches: [][]ChatCompletionStreamEvent{ { - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{ToolCalls: []ToolCallDelta{{ - Index: 0, - ID: "call-1", - Type: "function", - Function: FunctionCallDelta{ - Name: "echo", - Arguments: `{"value":`, - }, - }}}}, - {Delta: ChatMessageDelta{ToolCalls: []ToolCallDelta{{ - Index: 0, - Function: FunctionCallDelta{Arguments: `"x"}`}, - }}}}, + roleDelta("assistant"), + toolCallDelta(0, "call-1", "echo", `{"value":`), + toolCallDelta(0, "", "", `"x"}`), {Done: true}, }, { - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{Content: strPtr("final")}}, + roleDelta("assistant"), + textDelta("final"), {Done: true}, }, }, @@ -318,7 +306,7 @@ func TestOutputLimitToolCallIsRejectedAndRetried(t *testing.T) { ID: "call-truncated", Type: "function", Function: FunctionCall{Name: "echo", Arguments: `{"value":"cut off`}, }}, - }, + }.toAOP(), FinishReason: "max_tokens", }}}, chatResponse(NewTextMessage("assistant", "recovered")), @@ -354,23 +342,26 @@ func TestOutputLimitToolCallIsRejectedAndRetried(t *testing.T) { if len(requests) != 2 { t.Fatalf("provider requests = %d, want 2", len(requests)) } - var truncated ChatMessage - var errorResult ChatMessage + var truncated *aop.Message + var errorResult *aop.ToolResult for _, msg := range requests[1].Messages { - if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + if msg.Role == "assistant" && len(provider.MessageToolCalls(msg)) > 0 { truncated = msg } - if msg.Role == "tool" && msg.ToolCallID == "call-truncated" { - errorResult = msg + if msg.Role == "tool" { + if r := provider.MessageToolResult(msg); r != nil && r.CallId == "call-truncated" { + errorResult = r + } } } - if truncated.FinishReason != "max_tokens" { - t.Fatalf("finish reason = %q, want max_tokens", truncated.FinishReason) + if truncated == nil { + t.Fatal("assistant message with tool call not found") } - if got := truncated.ToolCalls[0].Function.Arguments; got != "{}" { + truncatedCalls := provider.MessageToolCalls(truncated) + if got := string(truncatedCalls[0].GetArguments().GetData()); got != "{}" { t.Fatalf("sanitized arguments = %q, want {}", got) } - if !errorResult.ToolResultIsError || errorResult.Content == nil || !strings.Contains(*errorResult.Content, "Retry") { + if errorResult == nil || !errorResult.IsError || !strings.Contains(tool.ResultText(errorResult), "Retry") { t.Fatalf("error tool result = %#v", errorResult) } } @@ -381,17 +372,14 @@ func TestStreamingOutputLimitToolCallPreservesFinishReason(t *testing.T) { tools.RegisterTool(echo) llm := &scriptedProvider{streamEventBatches: [][]ChatCompletionStreamEvent{ { - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{ToolCalls: []ToolCallDelta{{ - Index: 0, ID: "stream-truncated", Type: "function", - Function: FunctionCallDelta{Name: "echo", Arguments: `{"value":"partial`}, - }}}}, + roleDelta("assistant"), + toolCallDelta(0, "stream-truncated", "echo", `{"value":"partial`), {FinishReason: "length"}, {Done: true}, }, { - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{Content: strPtr("recovered")}}, + roleDelta("assistant"), + textDelta("recovered"), {FinishReason: "stop"}, {Done: true}, }, @@ -412,15 +400,21 @@ func TestStreamingOutputLimitToolCallPreservesFinishReason(t *testing.T) { if calls := echo.callsSnapshot(); len(calls) != 0 { t.Fatalf("truncated tool was executed: %#v", calls) } - var finishReason string + // A "length" finish reason marks the streamed tool call truncated: the call + // is rejected and its arguments are sanitized to "{}" in the transcript. + var sanitized string for _, msg := range result.Messages { - if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { - finishReason = msg.FinishReason - break + if msg.Role != "assistant" { + continue + } + for _, call := range provider.MessageToolCalls(msg) { + if call.Id == "stream-truncated" { + sanitized = string(call.GetArguments().GetData()) + } } } - if finishReason != "length" { - t.Fatalf("stream finish reason = %q, want length", finishReason) + if sanitized != "{}" { + t.Fatalf("truncated stream tool call arguments = %q, want {}", sanitized) } } @@ -430,17 +424,14 @@ func TestStreamingMalformedToolCallIsRejectedAfterNormalTerminalMarker(t *testin tools.RegisterTool(echo) llm := &scriptedProvider{streamEventBatches: [][]ChatCompletionStreamEvent{ { - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{ToolCalls: []ToolCallDelta{{ - Index: 0, ID: "stream-malformed", Type: "function", - Function: FunctionCallDelta{Name: "echo", Arguments: `{"value":"partial`}, - }}}}, + roleDelta("assistant"), + toolCallDelta(0, "stream-malformed", "echo", `{"value":"partial`), {FinishReason: "tool_calls"}, {Done: true}, }, { - {Delta: ChatMessageDelta{Role: "assistant"}}, - {Delta: ChatMessageDelta{Content: strPtr("recovered")}}, + roleDelta("assistant"), + textDelta("recovered"), {FinishReason: "stop"}, {Done: true}, }, @@ -462,20 +453,26 @@ func TestStreamingMalformedToolCallIsRejectedAfterNormalTerminalMarker(t *testin if len(requests) != 2 { t.Fatalf("provider requests = %d, want 2", len(requests)) } - var rejectedCall ChatMessage - var errorResult ChatMessage + var rejectedCall *aop.Message + var errorResult *aop.ToolResult for _, message := range requests[1].Messages { - if message.Role == "assistant" && len(message.ToolCalls) > 0 { + if message.Role == "assistant" && len(provider.MessageToolCalls(message)) > 0 { rejectedCall = message } - if message.Role == "tool" && message.ToolCallID == "stream-malformed" { - errorResult = message + if message.Role == "tool" { + if r := provider.MessageToolResult(message); r != nil && r.CallId == "stream-malformed" { + errorResult = r + } } } - if len(rejectedCall.ToolCalls) != 1 || rejectedCall.ToolCalls[0].Function.Arguments != "{}" { - t.Fatalf("rejected tool call = %#v", rejectedCall) + if rejectedCall == nil { + t.Fatal("assistant message with rejected tool call not found") } - if !errorResult.ToolResultIsError || errorResult.Content == nil || !strings.Contains(*errorResult.Content, "invalid") { + rejectedCalls := provider.MessageToolCalls(rejectedCall) + if len(rejectedCalls) != 1 || string(rejectedCalls[0].GetArguments().GetData()) != "{}" { + t.Fatalf("rejected tool call = %#v", rejectedCalls) + } + if errorResult == nil || !errorResult.IsError || !strings.Contains(tool.ResultText(errorResult), "invalid") { t.Fatalf("error tool result = %#v", errorResult) } } @@ -562,8 +559,8 @@ func TestTokenBudgetWarning(t *testing.T) { llm := &callbackProvider{ fn: func(_ context.Context, req *ChatCompletionRequest) (*ChatCompletionResponse, error) { return &ChatCompletionResponse{ - Choices: []Choice{{Message: NewTextMessage("assistant", "done")}}, - Usage: &Usage{PromptTokens: 700, CompletionTokens: 200, TotalTokens: 900}, + Choices: []Choice{{Message: NewTextMessage("assistant", "done").toAOP()}}, + Usage: provider.TokenUsage(700, 200, 900, 0, 0), }, nil }, } @@ -607,13 +604,13 @@ func TestTokenBudgetExceeded(t *testing.T) { Type: "function", Function: FunctionCall{Name: "echo", Arguments: `{}`}, }}, - }}}, - Usage: &Usage{TotalTokens: 600}, + }.toAOP()}}, + Usage: provider.TokenUsage(0, 0, 600, 0, 0), }, nil } return &ChatCompletionResponse{ - Choices: []Choice{{Message: NewTextMessage("assistant", "done")}}, - Usage: &Usage{TotalTokens: 500}, + Choices: []Choice{{Message: NewTextMessage("assistant", "done").toAOP()}}, + Usage: provider.TokenUsage(0, 0, 500, 0, 0), }, nil }, } @@ -646,8 +643,8 @@ func TestBudgetExhaustionDoesNotKeepUnpairedToolCall(t *testing.T) { ToolCalls: []ToolCall{{ID: "cut-off", Type: "function", Function: FunctionCall{ Name: "echo", Arguments: `{"value":"partial`}, }}, - }, FinishReason: "max_tokens"}}, - Usage: &Usage{TotalTokens: 1000}, + }.toAOP(), FinishReason: "max_tokens"}}, + Usage: provider.TokenUsage(0, 0, 1000, 0, 0), }}} result, err := NewAgent(Config{ @@ -679,8 +676,8 @@ func TestResultIncludesTotalUsage(t *testing.T) { llm := &callbackProvider{ fn: func(_ context.Context, req *ChatCompletionRequest) (*ChatCompletionResponse, error) { return &ChatCompletionResponse{ - Choices: []Choice{{Message: NewTextMessage("assistant", "done")}}, - Usage: &Usage{PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150}, + Choices: []Choice{{Message: NewTextMessage("assistant", "done").toAOP()}}, + Usage: provider.TokenUsage(100, 50, 150, 0, 0), }, nil }, } @@ -714,13 +711,13 @@ func TestResultIncludesPerTurnUsageAndContextTokens(t *testing.T) { ID: "call-1", Type: "function", Function: FunctionCall{Name: "echo", Arguments: `{}`}, }}, - }}}, - Usage: &Usage{PromptTokens: 200, CompletionTokens: 30, TotalTokens: 230}, + }.toAOP()}}, + Usage: provider.TokenUsage(200, 30, 230, 0, 0), }, nil } return &ChatCompletionResponse{ - Choices: []Choice{{Message: NewTextMessage("assistant", "done")}}, - Usage: &Usage{PromptTokens: 280, CompletionTokens: 20, TotalTokens: 300}, + Choices: []Choice{{Message: NewTextMessage("assistant", "done").toAOP()}}, + Usage: provider.TokenUsage(280, 20, 300, 0, 0), }, nil }, } @@ -737,17 +734,17 @@ func TestResultIncludesPerTurnUsageAndContextTokens(t *testing.T) { if len(result.TurnUsages) != 2 { t.Fatalf("TurnUsages length = %d, want 2", len(result.TurnUsages)) } - if result.TurnUsages[0].Turn != 1 || result.TurnUsages[0].TotalTokens != 230 { - t.Errorf("TurnUsages[0] = %+v, want turn=1 total=230", result.TurnUsages[0]) + if result.TurnUsages[0].TotalTokens != 230 { + t.Errorf("TurnUsages[0] = %+v, want total=230", result.TurnUsages[0]) } - if result.TurnUsages[1].Turn != 2 || result.TurnUsages[1].TotalTokens != 300 { - t.Errorf("TurnUsages[1] = %+v, want turn=2 total=300", result.TurnUsages[1]) + if result.TurnUsages[1].TotalTokens != 300 { + t.Errorf("TurnUsages[1] = %+v, want total=300", result.TurnUsages[1]) } if result.TotalUsage.TotalTokens != 530 { t.Errorf("TotalUsage.TotalTokens = %d, want 530", result.TotalUsage.TotalTokens) } - if result.TotalUsage.PromptTokens != 480 { - t.Errorf("TotalUsage.PromptTokens = %d, want 480", result.TotalUsage.PromptTokens) + if result.TotalUsage.InputTokens != 480 { + t.Errorf("TotalUsage.InputTokens = %d, want 480", result.TotalUsage.InputTokens) } if result.ContextTokens != 300 { t.Errorf("ContextTokens = %d, want 300 (last turn input + output)", result.ContextTokens) @@ -759,8 +756,8 @@ func TestTurnEndEventCarriesUsage(t *testing.T) { llm := &callbackProvider{ fn: func(_ context.Context, req *ChatCompletionRequest) (*ChatCompletionResponse, error) { return &ChatCompletionResponse{ - Choices: []Choice{{Message: NewTextMessage("assistant", "done")}}, - Usage: &Usage{PromptTokens: 500, CompletionTokens: 40, TotalTokens: 540}, + Choices: []Choice{{Message: NewTextMessage("assistant", "done").toAOP()}}, + Usage: provider.TokenUsage(500, 40, 540, 0, 0), }, nil }, } @@ -806,11 +803,11 @@ func TestSanitizeMessagesFiltersStaleEmptyAssistant(t *testing.T) { Logger: telemetry.NopLogger(), }) - a.LoadMessages([]ChatMessage{ - NewTextMessage("user", "first question"), - NewTextMessage("assistant", "first answer"), - NewTextMessage("user", "second question"), - NewTextMessage("assistant", ""), + a.LoadMessages([]*aop.Message{ + textMessage("user", "first question"), + textMessage("assistant", "first answer"), + textMessage("user", "second question"), + textMessage("assistant", ""), }) result, err := a.Run(context.Background(), TextInput("continue")) @@ -824,7 +821,7 @@ func TestSanitizeMessagesFiltersStaleEmptyAssistant(t *testing.T) { t.Fatal("no requests captured") } for _, msg := range captured[0].Messages { - if msg.Role == "assistant" && messageContent(msg) == "" && len(msg.ToolCalls) == 0 { + if msg.Role == "assistant" && messageContent(msg) == "" && len(provider.MessageToolCalls(msg)) == 0 { t.Error("empty assistant message was NOT filtered from LLM request") } } @@ -1075,10 +1072,10 @@ func TestSessionCompletionInjectedIntoAgentLoop(t *testing.T) { turn2Msgs := requests[1].Messages found := false for _, m := range turn2Msgs { - if m.Content != nil && strings.Contains(*m.Content, "session_completion") { + if text := provider.MessageText(m); strings.Contains(text, "session_completion") { found = true - if !strings.Contains(*m.Content, "background-result") { - t.Errorf("session completion should contain stdout, got: %s", *m.Content) + if !strings.Contains(text, "background-result") { + t.Errorf("session completion should contain stdout, got: %s", text) } break } @@ -1086,9 +1083,7 @@ func TestSessionCompletionInjectedIntoAgentLoop(t *testing.T) { if !found { var contents []string for _, m := range turn2Msgs { - if m.Content != nil { - contents = append(contents, *m.Content) - } + contents = append(contents, provider.MessageText(m)) } t.Fatalf("turn 2 missing session_completion message.\nMessages:\n%s", strings.Join(contents, "\n---\n")) } @@ -1136,26 +1131,20 @@ func TestSessionCompletionMetadata(t *testing.T) { t.Errorf("exit_code = %v, want 0", msg.Meta["exit_code"]) } - cms := msg.ToChatMessages() + cms := msg.ToMessages() if len(cms) != 1 { t.Fatalf("expected 1 chat message, got %d", len(cms)) } - if !strings.Contains(*cms[0].Content, "session_completion") { - t.Errorf("chat message should contain session_completion XML, got: %s", *cms[0].Content) + if !strings.Contains(provider.MessageText(cms[0]), "session_completion") { + t.Errorf("chat message should contain session_completion XML, got: %s", provider.MessageText(cms[0])) } } // --- Cache usage tests --- func TestTurnUsageCacheAccumulation(t *testing.T) { - usage1 := &Usage{ - PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120, - CacheReadTokens: 0, CacheWriteTokens: 80, - } - usage2 := &Usage{ - PromptTokens: 150, CompletionTokens: 15, TotalTokens: 165, - CacheReadTokens: 80, CacheWriteTokens: 0, - } + usage1 := provider.TokenUsage(100, 20, 120, 0, 80) + usage2 := provider.TokenUsage(150, 15, 165, 80, 0) llm := &scriptedProvider{ responses: []*ChatCompletionResponse{ @@ -1166,10 +1155,10 @@ func TestTurnUsageCacheAccumulation(t *testing.T) { ID: "call_1", Type: "function", Function: FunctionCall{Name: "read", Arguments: `{}`}, }}, - }, + }.toAOP(), }}, Usage: usage1}, {Choices: []Choice{{ - Message: NewTextMessage("assistant", "done"), + Message: NewTextMessage("assistant", "done").toAOP(), }}, Usage: usage2}, }, } @@ -1189,40 +1178,37 @@ func TestTurnUsageCacheAccumulation(t *testing.T) { t.Fatal(err) } - if result.TotalUsage.CacheReadTokens != 80 { - t.Errorf("TotalUsage.CacheReadTokens = %d, want 80", result.TotalUsage.CacheReadTokens) + if result.TotalUsage.Detail["cache_read"] != 80 { + t.Errorf("TotalUsage cache_read = %d, want 80", result.TotalUsage.Detail["cache_read"]) } - if result.TotalUsage.CacheWriteTokens != 80 { - t.Errorf("TotalUsage.CacheWriteTokens = %d, want 80", result.TotalUsage.CacheWriteTokens) + if result.TotalUsage.Detail["cache_write"] != 80 { + t.Errorf("TotalUsage cache_write = %d, want 80", result.TotalUsage.Detail["cache_write"]) } - if result.TotalUsage.PromptTokens != 250 { - t.Errorf("TotalUsage.PromptTokens = %d, want 250", result.TotalUsage.PromptTokens) + if result.TotalUsage.InputTokens != 250 { + t.Errorf("TotalUsage.InputTokens = %d, want 250", result.TotalUsage.InputTokens) } if len(result.TurnUsages) != 2 { t.Fatalf("expected 2 TurnUsages, got %d", len(result.TurnUsages)) } - if result.TurnUsages[0].CacheWriteTokens != 80 { - t.Errorf("Turn 1 CacheWriteTokens = %d, want 80", result.TurnUsages[0].CacheWriteTokens) + if result.TurnUsages[0].Detail["cache_write"] != 80 { + t.Errorf("Turn 1 cache_write = %d, want 80", result.TurnUsages[0].Detail["cache_write"]) } - if result.TurnUsages[1].CacheReadTokens != 80 { - t.Errorf("Turn 2 CacheReadTokens = %d, want 80", result.TurnUsages[1].CacheReadTokens) + if result.TurnUsages[1].Detail["cache_read"] != 80 { + t.Errorf("Turn 2 cache_read = %d, want 80", result.TurnUsages[1].Detail["cache_read"]) } t.Logf("Accumulation OK: total prompt=%d cache_read=%d cache_write=%d", - result.TotalUsage.PromptTokens, result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheWriteTokens) + result.TotalUsage.InputTokens, result.TotalUsage.Detail["cache_read"], result.TotalUsage.Detail["cache_write"]) } func TestEventCarriesCacheUsage(t *testing.T) { - usage := &Usage{ - PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, - CacheReadTokens: 60, CacheWriteTokens: 20, - } + usage := provider.TokenUsage(100, 10, 110, 60, 20) llm := &scriptedProvider{ responses: []*ChatCompletionResponse{ {Choices: []Choice{{ - Message: NewTextMessage("assistant", "hi"), + Message: NewTextMessage("assistant", "hi").toAOP(), }}, Usage: usage}, }, } diff --git a/agent/overflow.go b/agent/overflow.go index 376c4e36..09b6b348 100644 --- a/agent/overflow.go +++ b/agent/overflow.go @@ -1,6 +1,10 @@ package agent -import "strings" +import ( + "strings" + + aop "github.com/chainreactors/aiscan/aop" +) var contextOverflowPatterns = []string{ "prompt is too long", @@ -45,12 +49,12 @@ func isContextOverflowError(err error) bool { return false } -func isLengthContextOverflow(finishReason string, usage *Usage, contextWindow int) bool { +func isLengthContextOverflow(finishReason string, usage *aop.TokenUsage, contextWindow int) bool { if !isOutputLimitFinishReason(finishReason) || usage == nil || contextWindow <= 0 { return false } - if usage.CompletionTokens != 0 { + if usage.OutputTokens != 0 { return false } - return usage.PromptTokens >= contextWindow*99/100 + return usage.InputTokens >= uint64(contextWindow)*99/100 } diff --git a/agent/probe/llm.go b/agent/probe/llm.go index 63a808de..9306965c 100644 --- a/agent/probe/llm.go +++ b/agent/probe/llm.go @@ -7,6 +7,8 @@ import ( "time" "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" ) // LLMProbeRequest carries the connection parameters the user wants to verify @@ -148,7 +150,7 @@ func TestLLM(ctx context.Context, req LLMProbeRequest, storedAPIKey string) (LLM start := time.Now() resp, err := prov.ChatCompletion(probeCtx, &agent.ChatCompletionRequest{ Model: cfg.Model, - Messages: []agent.ChatMessage{agent.NewTextMessage("user", "ping")}, + Messages: []*aop.Message{provider.TextMessage("user", "ping")}, MaxTokens: maxTokens, }) result.LatencyMs = time.Since(start).Milliseconds() @@ -162,8 +164,6 @@ func TestLLM(ctx context.Context, req LLMProbeRequest, storedAPIKey string) (LLM } result.OK = true - if msg := resp.Choices[0].Message; msg.Content != nil { - result.Reply = strings.TrimSpace(*msg.Content) - } + result.Reply = strings.TrimSpace(provider.MessageText(resp.Choices[0].Message)) return result, nil } diff --git a/agent/provider/anthropic.go b/agent/provider/anthropic.go index 9e1c5adb..02929a0f 100644 --- a/agent/provider/anthropic.go +++ b/agent/provider/anthropic.go @@ -2,11 +2,14 @@ package provider import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "strings" + + aop "github.com/chainreactors/aiscan/aop" ) const ( @@ -173,14 +176,17 @@ func (p *AnthropicProvider) marshalRequest(req *ChatCompletionRequest) ([]byte, var tools []anthropicTool official := strings.Contains(p.config.BaseURL, "anthropic.com") - for _, t := range req.Tools { - inputSchema := t.Function.Parameters - if inputSchema == nil { - inputSchema = map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} + for _, def := range req.Tools { + inputSchema := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} + if def.InputSchema != nil { + var schema map[string]interface{} + if err := json.Unmarshal(def.InputSchema.Data, &schema); err == nil && schema != nil { + inputSchema = schema + } } at := anthropicTool{ - Name: t.Function.Name, - Description: t.Function.Description, + Name: def.Name, + Description: def.Description, InputSchema: inputSchema, } if official { @@ -195,29 +201,35 @@ func (p *AnthropicProvider) marshalRequest(req *ChatCompletionRequest) ([]byte, var systemParts []string var messages []aMsg for _, m := range req.Messages { + if m == nil { + continue + } switch m.Role { case "system": - if m.Content != nil { - systemParts = append(systemParts, *m.Content) + if text := MessageText(m); text != "" { + systemParts = append(systemParts, text) } case "assistant": var blocks []map[string]interface{} - if m.Content != nil && *m.Content != "" { - blocks = append(blocks, map[string]interface{}{"type": "text", "text": *m.Content}) + if text := MessageText(m); text != "" { + blocks = append(blocks, map[string]interface{}{"type": "text", "text": text}) } - for _, tc := range m.ToolCalls { + for _, call := range MessageToolCalls(m) { var input interface{} - args := strings.TrimSpace(tc.Function.Arguments) + args := "" + if call.Arguments != nil { + args = strings.TrimSpace(string(call.Arguments.Data)) + } if args == "" { input = map[string]interface{}{} } else if err := json.Unmarshal([]byte(args), &input); err != nil { - return nil, fmt.Errorf("anthropic tool call %q has invalid JSON arguments: %w", tc.Function.Name, err) + return nil, fmt.Errorf("anthropic tool call %q has invalid JSON arguments: %w", call.Name, err) } blocks = append(blocks, map[string]interface{}{ "type": "tool_use", - "id": tc.ID, - "name": tc.Function.Name, + "id": call.Id, + "name": call.Name, "input": input, }) } @@ -227,18 +239,16 @@ func (p *AnthropicProvider) marshalRequest(req *ChatCompletionRequest) ([]byte, messages = append(messages, aMsg{Role: "assistant", Content: blocks}) case "tool": - var resultContent interface{} - if len(m.ContentParts) > 0 { - resultContent = contentPartsToAnthropicBlocks(m.ContentParts) - } else { - resultContent = deref(m.Content) + result := MessageToolResult(m) + if result == nil { + continue } resultBlock := map[string]interface{}{ "type": "tool_result", - "tool_use_id": m.ToolCallID, - "content": resultContent, + "tool_use_id": result.CallId, + "content": toolResultToAnthropicContent(result), } - if m.ToolResultIsError { + if result.IsError { resultBlock["is_error"] = true } messages = append(messages, aMsg{ @@ -247,21 +257,8 @@ func (p *AnthropicProvider) marshalRequest(req *ChatCompletionRequest) ([]byte, }) default: - if len(m.ContentParts) > 0 { - messages = append(messages, aMsg{ - Role: m.Role, - Content: contentPartsToAnthropicBlocks(m.ContentParts), - }) - } else { - text := "" - if m.Content != nil { - text = *m.Content - } - messages = append(messages, aMsg{ - Role: m.Role, - Content: []map[string]interface{}{{"type": "text", "text": text}}, - }) - } + blocks := messageContentToAnthropicBlocks(m) + messages = append(messages, aMsg{Role: m.Role, Content: blocks}) } } @@ -314,6 +311,59 @@ func (p *AnthropicProvider) marshalRequest(req *ChatCompletionRequest) ([]byte, return json.Marshal(wrapper) } +func toolResultToAnthropicContent(result *aop.ToolResult) interface{} { + blocks := messageBlocksFromContents(result.Output) + if len(blocks) == 0 { + return "" + } + if len(blocks) == 1 && blocks[0]["type"] == "text" { + return blocks[0]["text"] + } + return blocks +} + +func messageContentToAnthropicBlocks(m *aop.Message) []map[string]interface{} { + blocks := messageBlocksFromContents(m.Content) + if len(blocks) == 0 { + return []map[string]interface{}{{"type": "text", "text": ""}} + } + return blocks +} + +func messageBlocksFromContents(contents []*aop.Content) []map[string]interface{} { + var blocks []map[string]interface{} + for _, content := range contents { + switch value := content.Value.(type) { + case *aop.Content_Text: + blocks = append(blocks, map[string]interface{}{"type": "text", "text": value.Text.Text}) + case *aop.Content_Media: + media := value.Media + if media.Kind != "image" || media.Resource == nil { + continue + } + data := media.Resource.GetData() + if len(data) == 0 { + if uri := media.Resource.GetUri(); uri != "" { + blocks = append(blocks, map[string]interface{}{ + "type": "image", + "source": map[string]interface{}{"type": "url", "url": uri}, + }) + } + continue + } + blocks = append(blocks, map[string]interface{}{ + "type": "image", + "source": map[string]interface{}{ + "type": "base64", + "media_type": media.Resource.MediaType, + "data": base64.StdEncoding.EncodeToString(data), + }, + }) + } + } + return blocks +} + // --- Anthropic response types and parsing --- type aMsg struct { @@ -337,29 +387,6 @@ func mergeConsecutive(msgs []aMsg) []aMsg { return merged } -func contentPartsToAnthropicBlocks(parts []ContentPart) []map[string]interface{} { - blocks := make([]map[string]interface{}, 0, len(parts)) - for _, part := range parts { - switch part.Type { - case "text": - blocks = append(blocks, map[string]interface{}{"type": "text", "text": part.Text}) - case "image_url": - if part.ImageURL != nil { - mediaType, data := ParseDataURI(part.ImageURL.URL) - blocks = append(blocks, map[string]interface{}{ - "type": "image", - "source": map[string]interface{}{ - "type": "base64", - "media_type": mediaType, - "data": data, - }, - }) - } - } - } - return blocks -} - type anthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` @@ -417,12 +444,12 @@ func parseAnthropicResponse(data []byte) (*ChatCompletionResponse, error) { }, nil } -func anthropicBlocksToMessage(role string, blocks []anthropicContentBlock) ChatMessage { +func anthropicBlocksToMessage(role string, blocks []anthropicContentBlock) *aop.Message { if role == "" { role = "assistant" } + msg := &aop.Message{Role: role} var text, thinking strings.Builder - toolCalls := make([]ToolCall, 0) for _, block := range blocks { switch block.Type { case "thinking": @@ -431,26 +458,23 @@ func anthropicBlocksToMessage(role string, blocks []anthropicContentBlock) ChatM text.WriteString(block.Text) case "tool_use": args := anthropicToolArguments(block.Input) - toolCalls = append(toolCalls, ToolCall{ - ID: block.ID, - Type: "function", - Function: FunctionCall{ - Name: block.Name, - Arguments: args, - }, - }) + msg.Content = append(msg.Content, &aop.Content{Value: &aop.Content_ToolCall{ToolCall: &aop.ToolCall{ + Id: block.ID, + Name: block.Name, + Kind: "function", + Arguments: &aop.EncodedValue{Data: []byte(args), MediaType: aop.JSONMediaType}, + }}}) } } - - msg := ChatMessage{Role: role} if content := thinking.String(); content != "" { - msg.ReasoningContent = &content + msg.Content = append([]*aop.Content{aop.Reasoning(content)}, msg.Content...) } if content := text.String(); content != "" { - msg.Content = &content - } - if len(toolCalls) > 0 { - msg.ToolCalls = toolCalls + insertAt := 0 + if len(msg.Content) > 0 && msg.Content[0].GetReasoning() != nil { + insertAt = 1 + } + msg.Content = append(msg.Content[:insertAt], append([]*aop.Content{aop.Text(content)}, msg.Content[insertAt:]...)...) } return msg } @@ -476,19 +500,13 @@ func mapAnthropicStopReason(reason string) string { } } -func convertAnthropicUsage(usage *anthropicUsage) *Usage { +func convertAnthropicUsage(usage *anthropicUsage) *aop.TokenUsage { if usage == nil { return nil } promptTokens := usage.InputTokens + usage.CacheCreationInputTokens + usage.CacheReadInputTokens completionTokens := usage.OutputTokens - return &Usage{ - PromptTokens: promptTokens, - CompletionTokens: completionTokens, - TotalTokens: promptTokens + completionTokens, - CacheReadTokens: usage.CacheReadInputTokens, - CacheWriteTokens: usage.CacheCreationInputTokens, - } + return TokenUsage(promptTokens, completionTokens, promptTokens+completionTokens, usage.CacheReadInputTokens, usage.CacheCreationInputTokens) } // --- Anthropic streaming --- @@ -530,7 +548,7 @@ func (p *anthropicStreamParser) parse(eventName string, data []byte) (ChatComple role = "assistant" } return ChatCompletionStreamEvent{ - Delta: ChatMessageDelta{Role: role}, + Role: role, Usage: p.usageSnapshot(), }, nil @@ -547,26 +565,20 @@ func (p *anthropicStreamParser) parse(eventName string, data []byte) (ChatComple if event.ContentBlock.Text == "" { return ChatCompletionStreamEvent{}, nil } - text := event.ContentBlock.Text - return ChatCompletionStreamEvent{Delta: ChatMessageDelta{Content: &text}}, nil + return ChatCompletionStreamEvent{MessageDelta: &aop.MessageDelta{ + Operation: aop.DeltaOperation_DELTA_OPERATION_APPEND, + Value: &aop.MessageDelta_Text{Text: event.ContentBlock.Text}, + }}, nil case "tool_use": - args := anthropicToolArguments(event.ContentBlock.Input) - delta := ToolCallDelta{ - Index: event.Index, - ID: event.ContentBlock.ID, - Type: "function", - Function: FunctionCallDelta{ - Name: event.ContentBlock.Name, - }, + delta := &aop.ToolCallDelta{ + Index: uint32(event.Index), + CallId: event.ContentBlock.ID, + Name: event.ContentBlock.Name, } - if args != "{}" { - delta.Function.Arguments = args + if args := anthropicToolArguments(event.ContentBlock.Input); args != "{}" { + delta.Arguments = []byte(args) } - return ChatCompletionStreamEvent{ - Delta: ChatMessageDelta{ - ToolCalls: []ToolCallDelta{delta}, - }, - }, nil + return ChatCompletionStreamEvent{ToolDeltas: []*aop.ToolCallDelta{delta}}, nil default: return ChatCompletionStreamEvent{}, nil } @@ -586,22 +598,22 @@ func (p *anthropicStreamParser) parse(eventName string, data []byte) (ChatComple } switch event.Delta.Type { case "text_delta": - text := event.Delta.Text - return ChatCompletionStreamEvent{Delta: ChatMessageDelta{Content: &text}}, nil + return ChatCompletionStreamEvent{MessageDelta: &aop.MessageDelta{ + Operation: aop.DeltaOperation_DELTA_OPERATION_APPEND, + Value: &aop.MessageDelta_Text{Text: event.Delta.Text}, + }}, nil case "input_json_delta": return ChatCompletionStreamEvent{ - Delta: ChatMessageDelta{ - ToolCalls: []ToolCallDelta{{ - Index: event.Index, - Function: FunctionCallDelta{ - Arguments: event.Delta.PartialJSON, - }, - }}, - }, + ToolDeltas: []*aop.ToolCallDelta{{ + Index: uint32(event.Index), + Arguments: []byte(event.Delta.PartialJSON), + }}, }, nil case "thinking_delta": - thinking := event.Delta.Thinking - return ChatCompletionStreamEvent{Delta: ChatMessageDelta{ReasoningContent: &thinking}}, nil + return ChatCompletionStreamEvent{MessageDelta: &aop.MessageDelta{ + Operation: aop.DeltaOperation_DELTA_OPERATION_APPEND, + Value: &aop.MessageDelta_Reasoning{Reasoning: event.Delta.Thinking}, + }}, nil default: return ChatCompletionStreamEvent{}, nil } @@ -657,7 +669,7 @@ func (p *anthropicStreamParser) mergeUsage(usage *anthropicUsage) { } } -func (p *anthropicStreamParser) usageSnapshot() *Usage { +func (p *anthropicStreamParser) usageSnapshot() *aop.TokenUsage { if p.usage.InputTokens == 0 && p.usage.OutputTokens == 0 && p.usage.CacheCreationInputTokens == 0 && diff --git a/agent/provider/cache_test.go b/agent/provider/cache_test.go index 4e7da411..7e112895 100644 --- a/agent/provider/cache_test.go +++ b/agent/provider/cache_test.go @@ -11,8 +11,46 @@ import ( "strings" "sync" "testing" + + aop "github.com/chainreactors/aiscan/aop" ) +// toolDef builds an aop.ToolDefinition for tests. +func toolDef(name, description string, parameters map[string]interface{}) *aop.ToolDefinition { + def := &aop.ToolDefinition{Type: "function", Name: name, Description: description} + if parameters != nil { + schema, err := aop.JSONValue(parameters) + if err == nil { + def.InputSchema = schema + } + } + return def +} + +// newToolCall builds an aop.ToolCall for tests. +func newToolCall(id, name, arguments string) *aop.ToolCall { + return &aop.ToolCall{ + Id: id, + Name: name, + Kind: "function", + Arguments: &aop.EncodedValue{Data: []byte(arguments), MediaType: aop.JSONMediaType}, + } +} + +// assistantToolCallMsg builds an assistant aop message carrying tool calls. +func assistantToolCallMsg(calls ...*aop.ToolCall) *aop.Message { + msg := &aop.Message{Role: "assistant"} + for _, call := range calls { + msg.Content = append(msg.Content, &aop.Content{Value: &aop.Content_ToolCall{ToolCall: call}}) + } + return msg +} + +// toolResultMsg builds a tool-role aop message carrying a text tool result. +func toolResultMsg(callID, text string) *aop.Message { + return ToolResultMessage(callID, &aop.ToolResult{Output: []*aop.Content{aop.Text(text)}}) +} + // ============================================================================= // Tests from cache_test.go (original) // ============================================================================= @@ -43,13 +81,13 @@ func TestLiveCacheMetrics(t *testing.T) { // Build a substantial system prompt to exceed provider's minimum cache threshold systemPrompt := "You are a helpful security analysis assistant. " + strings.Repeat("You have deep expertise in vulnerability assessment, penetration testing, and secure code review. ", 40) - sysMsg := NewTextMessage("system", systemPrompt) - userMsg1 := NewTextMessage("user", "What is 2+2? Answer in one word.") + sysMsg := TextMessage("system", systemPrompt) + userMsg1 := TextMessage("user", "What is 2+2? Answer in one word.") // Turn 1 req1 := &ChatCompletionRequest{ Model: model, - Messages: []ChatMessage{sysMsg, userMsg1}, + Messages: []*aop.Message{sysMsg, userMsg1}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "test-cache-session-001", @@ -62,16 +100,16 @@ func TestLiveCacheMetrics(t *testing.T) { } t.Logf("=== Turn 1 ===") - t.Logf("Response: %s", deref(resp1.Choices[0].Message.Content)) + t.Logf("Response: %s", MessageText(resp1.Choices[0].Message)) logUsage(t, resp1.Usage) // Turn 2 — same prefix, new user message assistantReply := resp1.Choices[0].Message - userMsg2 := NewTextMessage("user", "What is 3+3? Answer in one word.") + userMsg2 := TextMessage("user", "What is 3+3? Answer in one word.") req2 := &ChatCompletionRequest{ Model: model, - Messages: []ChatMessage{sysMsg, userMsg1, assistantReply, userMsg2}, + Messages: []*aop.Message{sysMsg, userMsg1, assistantReply, userMsg2}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "test-cache-session-001", @@ -83,16 +121,16 @@ func TestLiveCacheMetrics(t *testing.T) { } t.Logf("=== Turn 2 ===") - t.Logf("Response: %s", deref(resp2.Choices[0].Message.Content)) + t.Logf("Response: %s", MessageText(resp2.Choices[0].Message)) logUsage(t, resp2.Usage) // Turn 3 — even longer prefix assistantReply2 := resp2.Choices[0].Message - userMsg3 := NewTextMessage("user", "What is 4+4? Answer in one word.") + userMsg3 := TextMessage("user", "What is 4+4? Answer in one word.") req3 := &ChatCompletionRequest{ Model: model, - Messages: []ChatMessage{sysMsg, userMsg1, assistantReply, userMsg2, assistantReply2, userMsg3}, + Messages: []*aop.Message{sysMsg, userMsg1, assistantReply, userMsg2, assistantReply2, userMsg3}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "test-cache-session-001", @@ -104,7 +142,7 @@ func TestLiveCacheMetrics(t *testing.T) { } t.Logf("=== Turn 3 ===") - t.Logf("Response: %s", deref(resp3.Choices[0].Message.Content)) + t.Logf("Response: %s", MessageText(resp3.Choices[0].Message)) logUsage(t, resp3.Usage) // Summary @@ -112,16 +150,16 @@ func TestLiveCacheMetrics(t *testing.T) { for i, resp := range []*ChatCompletionResponse{resp1, resp2, resp3} { if resp.Usage != nil { ratio := 0.0 - if resp.Usage.PromptTokens > 0 { - ratio = float64(resp.Usage.CacheReadTokens) / float64(resp.Usage.PromptTokens) * 100 + if resp.Usage.InputTokens > 0 { + ratio = float64(resp.Usage.Detail["cache_read"]) / float64(resp.Usage.InputTokens) * 100 } t.Logf("Turn %d: prompt=%d cache_read=%d cache_write=%d hit_ratio=%.1f%%", - i+1, resp.Usage.PromptTokens, resp.Usage.CacheReadTokens, resp.Usage.CacheWriteTokens, ratio) + i+1, resp.Usage.InputTokens, resp.Usage.Detail["cache_read"], resp.Usage.Detail["cache_write"], ratio) } } } -func logUsage(t *testing.T, u *Usage) { +func logUsage(t *testing.T, u *aop.TokenUsage) { if u == nil { t.Log("Usage: nil") return @@ -129,7 +167,7 @@ func logUsage(t *testing.T, u *Usage) { raw, _ := json.Marshal(u) t.Logf("Usage: %s", raw) t.Logf(" prompt=%d completion=%d total=%d cache_read=%d cache_write=%d", - u.PromptTokens, u.CompletionTokens, u.TotalTokens, u.CacheReadTokens, u.CacheWriteTokens) + u.InputTokens, u.OutputTokens, u.TotalTokens, u.Detail["cache_read"], u.Detail["cache_write"]) } // Also test that the marshalRequest correctly adds cache_control for Anthropic @@ -145,13 +183,13 @@ func TestAnthropicMarshalCacheControl(t *testing.T) { t.Fatal(err) } - sysMsg := NewTextMessage("system", "You are a helpful assistant.") - userMsg := NewTextMessage("user", "Hello") + sysMsg := TextMessage("system", "You are a helpful assistant.") + userMsg := TextMessage("user", "Hello") // Without cache req := &ChatCompletionRequest{ Model: "claude-sonnet-4-20250514", - Messages: []ChatMessage{sysMsg, userMsg}, + Messages: []*aop.Message{sysMsg, userMsg}, CacheRetention: CacheNone, } data, err := prov.marshalRequest(req) @@ -208,17 +246,17 @@ func TestAnthropicMarshalCacheControlWithTools(t *testing.T) { t.Fatal(err) } - sysMsg := NewTextMessage("system", "You are a helpful assistant.") - userMsg := NewTextMessage("user", "Hello") + sysMsg := TextMessage("system", "You are a helpful assistant.") + userMsg := TextMessage("user", "Hello") - tools := []ToolDefinition{ - {Type: "function", Function: FunctionDefinition{Name: "tool_a", Description: "first tool"}}, - {Type: "function", Function: FunctionDefinition{Name: "tool_b", Description: "second tool"}}, + tools := []*aop.ToolDefinition{ + toolDef("tool_a", "first tool", nil), + toolDef("tool_b", "second tool", nil), } req := &ChatCompletionRequest{ Model: "claude-sonnet-4-20250514", - Messages: []ChatMessage{sysMsg, userMsg}, + Messages: []*aop.Message{sysMsg, userMsg}, Tools: tools, CacheRetention: CacheShort, } @@ -251,7 +289,7 @@ func TestAnthropicMarshalCacheControlWithTools(t *testing.T) { func TestOpenAIMarshalCacheKey(t *testing.T) { req := &ChatCompletionRequest{ Model: "gpt-4o", - Messages: []ChatMessage{NewTextMessage("user", "Hello")}, + Messages: []*aop.Message{TextMessage("user", "Hello")}, CacheRetention: CacheShort, SessionID: "sess-123", } @@ -301,7 +339,7 @@ func TestOpenAIMarshalCacheKey(t *testing.T) { func TestOpenAIStreamRequestIncludesUsage(t *testing.T) { req := &ChatCompletionRequest{ Model: "gpt-4o", - Messages: []ChatMessage{NewTextMessage("user", "Hello")}, + Messages: []*aop.Message{TextMessage("user", "Hello")}, Stream: true, } @@ -325,7 +363,7 @@ func TestOpenAIStreamRequestIncludesUsage(t *testing.T) { func TestUsageUnmarshalDeepSeek(t *testing.T) { raw := `{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}` - var u Usage + var u openAIUsage if err := json.Unmarshal([]byte(raw), &u); err != nil { t.Fatal(err) } @@ -339,7 +377,7 @@ func TestUsageUnmarshalDeepSeek(t *testing.T) { func TestUsageUnmarshalOpenAI(t *testing.T) { raw := `{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_tokens_details":{"cached_tokens":60,"cache_write_tokens":10}}` - var u Usage + var u openAIUsage if err := json.Unmarshal([]byte(raw), &u); err != nil { t.Fatal(err) } @@ -353,7 +391,7 @@ func TestUsageUnmarshalOpenAI(t *testing.T) { func TestUsageUnmarshalNoCacheFields(t *testing.T) { raw := `{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60}` - var u Usage + var u openAIUsage if err := json.Unmarshal([]byte(raw), &u); err != nil { t.Fatal(err) } @@ -372,14 +410,14 @@ func TestConvertAnthropicUsageCacheFields(t *testing.T) { CacheCreationInputTokens: 50, CacheReadInputTokens: 30, }) - if u.PromptTokens != 180 { - t.Errorf("PromptTokens: want 180, got %d", u.PromptTokens) + if u.InputTokens != 180 { + t.Errorf("InputTokens: want 180, got %d", u.InputTokens) } - if u.CacheReadTokens != 30 { - t.Errorf("CacheReadTokens: want 30, got %d", u.CacheReadTokens) + if u.Detail["cache_read"] != 30 { + t.Errorf("cache_read: want 30, got %d", u.Detail["cache_read"]) } - if u.CacheWriteTokens != 50 { - t.Errorf("CacheWriteTokens: want 50, got %d", u.CacheWriteTokens) + if u.Detail["cache_write"] != 50 { + t.Errorf("cache_write: want 50, got %d", u.Detail["cache_write"]) } fmt.Println("usage:", mustJSON(u)) } @@ -394,17 +432,17 @@ func TestConvertAnthropicUsageCacheFields(t *testing.T) { func TestCacheBreakpointPlacementMultiTurn(t *testing.T) { prov := mustAnthropicProvider(t) - sysMsg := NewTextMessage("system", "You are a helpful assistant.") - tools := []ToolDefinition{ - {Type: "function", Function: FunctionDefinition{Name: "read", Description: "read file"}}, - {Type: "function", Function: FunctionDefinition{Name: "write", Description: "write file"}}, + sysMsg := TextMessage("system", "You are a helpful assistant.") + tools := []*aop.ToolDefinition{ + toolDef("read", "read file", nil), + toolDef("write", "write file", nil), } // --- Turn 1: system + user1 --- - user1 := NewTextMessage("user", "Hello turn 1") + user1 := TextMessage("user", "Hello turn 1") req1 := &ChatCompletionRequest{ Model: "claude-sonnet-4-20250514", - Messages: []ChatMessage{sysMsg, user1}, + Messages: []*aop.Message{sysMsg, user1}, Tools: tools, CacheRetention: CacheShort, } @@ -419,11 +457,11 @@ func TestCacheBreakpointPlacementMultiTurn(t *testing.T) { t.Log(prettyJSON(p1)) // --- Turn 2: system + user1 + assistant1 + user2 --- - assistant1 := NewTextMessage("assistant", "Hi there") - user2 := NewTextMessage("user", "Hello turn 2") + assistant1 := TextMessage("assistant", "Hi there") + user2 := TextMessage("user", "Hello turn 2") req2 := &ChatCompletionRequest{ Model: "claude-sonnet-4-20250514", - Messages: []ChatMessage{sysMsg, user1, assistant1, user2}, + Messages: []*aop.Message{sysMsg, user1, assistant1, user2}, Tools: tools, CacheRetention: CacheShort, } @@ -455,14 +493,14 @@ func TestCacheBreakpointPlacementMultiTurn(t *testing.T) { assertPrefixStable(t, "tools", p1, p2) // --- Turn 3: with tool_result (maps to user role) --- - tc := ToolCall{ID: "call_1", Type: "function", Function: FunctionCall{Name: "read", Arguments: `{"path":"test.go"}`}} - assistant2 := ChatMessage{Role: "assistant", ToolCalls: []ToolCall{tc}} - toolResult := NewToolResultMessage("call_1", "file contents here") - user3 := NewTextMessage("user", "Now what?") + tc := newToolCall("call_1", "read", `{"path":"test.go"}`) + assistant2 := assistantToolCallMsg(tc) + toolResult := toolResultMsg("call_1", "file contents here") + user3 := TextMessage("user", "Now what?") req3 := &ChatCompletionRequest{ Model: "claude-sonnet-4-20250514", - Messages: []ChatMessage{sysMsg, user1, assistant1, user2, assistant2, toolResult, user3}, + Messages: []*aop.Message{sysMsg, user1, assistant1, user2, assistant2, toolResult, user3}, Tools: tools, CacheRetention: CacheShort, } @@ -504,28 +542,28 @@ func TestCacheBreakpointPlacementMultiTurn(t *testing.T) { func TestCacheBreakpointSubagentFork(t *testing.T) { prov := mustAnthropicProvider(t) - sysMsg := NewTextMessage("system", "You are a security scanner.") - tools := []ToolDefinition{ - {Type: "function", Function: FunctionDefinition{Name: "scan", Description: "scan target"}}, + sysMsg := TextMessage("system", "You are a security scanner.") + tools := []*aop.ToolDefinition{ + toolDef("scan", "scan target", nil), } // Parent conversation: system + user1 + assistant1 + user2 + assistant2 - user1 := NewTextMessage("user", "Scan target.com") - assistant1 := NewTextMessage("assistant", "Starting scan...") - user2 := NewTextMessage("user", "Check port 443") - assistant2 := NewTextMessage("assistant", "Port 443 is open") + user1 := TextMessage("user", "Scan target.com") + assistant1 := TextMessage("assistant", "Starting scan...") + user2 := TextMessage("user", "Check port 443") + assistant2 := TextMessage("assistant", "Port 443 is open") - parentMessages := []ChatMessage{user1, assistant1, user2, assistant2} + parentMessages := []*aop.Message{user1, assistant1, user2, assistant2} // Fork child: inherits parent messages, adds child prompt as new user message - childPrompt := NewTextMessage("user", "Analyze the SSL certificate on port 443") - childMessages := append([]ChatMessage{sysMsg}, parentMessages...) + childPrompt := TextMessage("user", "Analyze the SSL certificate on port 443") + childMessages := append([]*aop.Message{sysMsg}, parentMessages...) childMessages = append(childMessages, childPrompt) // Parent's last request (before forking) parentReq := &ChatCompletionRequest{ Model: "claude-sonnet-4-20250514", - Messages: append([]ChatMessage{sysMsg}, append(parentMessages, NewTextMessage("user", "fork a subagent"))...), + Messages: append([]*aop.Message{sysMsg}, append(parentMessages, TextMessage("user", "fork a subagent"))...), Tools: tools, CacheRetention: CacheShort, } @@ -590,14 +628,14 @@ func TestCacheNoneProducesNoCacheControl(t *testing.T) { req := &ChatCompletionRequest{ Model: "claude-sonnet-4-20250514", - Messages: []ChatMessage{ - NewTextMessage("system", "system prompt"), - NewTextMessage("user", "hello"), - NewTextMessage("assistant", "hi"), - NewTextMessage("user", "bye"), + Messages: []*aop.Message{ + TextMessage("system", "system prompt"), + TextMessage("user", "hello"), + TextMessage("assistant", "hi"), + TextMessage("user", "bye"), }, - Tools: []ToolDefinition{ - {Type: "function", Function: FunctionDefinition{Name: "tool1", Description: "t1"}}, + Tools: []*aop.ToolDefinition{ + toolDef("tool1", "t1", nil), }, CacheRetention: CacheNone, } @@ -614,17 +652,17 @@ func TestCacheNoneProducesNoCacheControl(t *testing.T) { func TestCacheBreakpointToolResultMerge(t *testing.T) { prov := mustAnthropicProvider(t) - sysMsg := NewTextMessage("system", "system prompt") - user1 := NewTextMessage("user", "call the tool") - tc := ToolCall{ID: "c1", Type: "function", Function: FunctionCall{Name: "read", Arguments: `{}`}} - assistant1 := ChatMessage{Role: "assistant", ToolCalls: []ToolCall{tc}} - toolResult := NewToolResultMessage("c1", "file content here") + sysMsg := TextMessage("system", "system prompt") + user1 := TextMessage("user", "call the tool") + tc := newToolCall("c1", "read", `{}`) + assistant1 := assistantToolCallMsg(tc) + toolResult := toolResultMsg("c1", "file content here") // Case A: tool_result is the LAST message (no user msg after it) // tool_result maps to user role → it becomes the "last user message" reqA := &ChatCompletionRequest{ Model: "test", - Messages: []ChatMessage{sysMsg, user1, assistant1, toolResult}, + Messages: []*aop.Message{sysMsg, user1, assistant1, toolResult}, CacheRetention: CacheShort, } jA := mustMarshal(t, prov, reqA) @@ -645,10 +683,10 @@ func TestCacheBreakpointToolResultMerge(t *testing.T) { len(blocksA), lastBlockA["type"]) // Case B: tool_result followed by user message → they merge (consecutive user role) - user2 := NewTextMessage("user", "now analyze it") + user2 := TextMessage("user", "now analyze it") reqB := &ChatCompletionRequest{ Model: "test", - Messages: []ChatMessage{sysMsg, user1, assistant1, toolResult, user2}, + Messages: []*aop.Message{sysMsg, user1, assistant1, toolResult, user2}, CacheRetention: CacheShort, } jB := mustMarshal(t, prov, reqB) @@ -666,14 +704,14 @@ func TestCacheBreakpointToolResultMerge(t *testing.T) { len(blocksB), lastBlockB["type"], lastBlockB["text"]) // Case C: multiple tool calls → multiple tool_results merge into one user message - tc2 := ToolCall{ID: "c2", Type: "function", Function: FunctionCall{Name: "write", Arguments: `{}`}} - assistant2 := ChatMessage{Role: "assistant", ToolCalls: []ToolCall{tc, tc2}} - toolResult1 := NewToolResultMessage("c1", "result1") - toolResult2 := NewToolResultMessage("c2", "result2") + tc2 := newToolCall("c2", "write", `{}`) + assistant2 := assistantToolCallMsg(tc, tc2) + toolResult1 := toolResultMsg("c1", "result1") + toolResult2 := toolResultMsg("c2", "result2") reqC := &ChatCompletionRequest{ Model: "test", - Messages: []ChatMessage{sysMsg, user1, assistant2, toolResult1, toolResult2}, + Messages: []*aop.Message{sysMsg, user1, assistant2, toolResult1, toolResult2}, CacheRetention: CacheShort, } jC := mustMarshal(t, prov, reqC) @@ -701,18 +739,18 @@ func TestCacheBreakpointToolResultMerge(t *testing.T) { func TestCacheBreakpointStabilityAcrossTurns(t *testing.T) { prov := mustAnthropicProvider(t) - sys := NewTextMessage("system", "system prompt here") - tools := []ToolDefinition{ - {Type: "function", Function: FunctionDefinition{Name: "tool1", Description: "desc"}}, + sys := TextMessage("system", "system prompt here") + tools := []*aop.ToolDefinition{ + toolDef("tool1", "desc", nil), } // Build 5 turns of conversation - msgs := []ChatMessage{sys} + msgs := []*aop.Message{sys} for turn := 1; turn <= 5; turn++ { - msgs = append(msgs, NewTextMessage("user", fmt.Sprintf("question %d", turn))) - msgs = append(msgs, NewTextMessage("assistant", fmt.Sprintf("answer %d", turn))) + msgs = append(msgs, TextMessage("user", fmt.Sprintf("question %d", turn))) + msgs = append(msgs, TextMessage("assistant", fmt.Sprintf("answer %d", turn))) } - msgs = append(msgs, NewTextMessage("user", "final question")) + msgs = append(msgs, TextMessage("user", "final question")) // Marshal the full request reqFull := &ChatCompletionRequest{ @@ -722,7 +760,7 @@ func TestCacheBreakpointStabilityAcrossTurns(t *testing.T) { pFull := mustParse(t, jFull) // Marshal a shorter prefix (first 3 turns + new question) - shortMsgs := append(msgs[:7], NewTextMessage("user", "different question")) // sys + 3 turns + new user + shortMsgs := append(msgs[:7], TextMessage("user", "different question")) // sys + 3 turns + new user reqShort := &ChatCompletionRequest{ Model: "test", Messages: shortMsgs, Tools: tools, CacheRetention: CacheShort, } @@ -1085,17 +1123,17 @@ func TestAnthropicProtocol_ToolCallCache(t *testing.T) { }) ctx := testContext() - sys := NewTextMessage("system", "You are a tool-using assistant.") - user1 := NewTextMessage("user", "Read test.go") - tools := []ToolDefinition{ - {Type: "function", Function: FunctionDefinition{Name: "read", Description: "read file", - Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"path": map[string]interface{}{"type": "string"}}}}}, - {Type: "function", Function: FunctionDefinition{Name: "write", Description: "write file"}}, + sys := TextMessage("system", "You are a tool-using assistant.") + user1 := TextMessage("user", "Read test.go") + tools := []*aop.ToolDefinition{ + toolDef("read", "read file", + map[string]interface{}{"type": "object", "properties": map[string]interface{}{"path": map[string]interface{}{"type": "string"}}}), + toolDef("write", "write file", nil), } // Turn 1: triggers tool_use req1 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1}, + Messages: []*aop.Message{sys, user1}, Tools: tools, CacheRetention: CacheShort, SessionID: "sess-tool", } resp1, err := prov.ChatCompletion(ctx, req1) @@ -1106,11 +1144,11 @@ func TestAnthropicProtocol_ToolCallCache(t *testing.T) { // Turn 2: tool_result + follow-up (simulates the agent loop) assistant1 := resp1.Choices[0].Message - toolResult := NewToolResultMessage("call_abc", "package main...") - user2 := NewTextMessage("user", "What does it do?") + toolResult := toolResultMsg("call_abc", "package main...") + user2 := TextMessage("user", "What does it do?") req2 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1, assistant1, toolResult, user2}, + Messages: []*aop.Message{sys, user1, assistant1, toolResult, user2}, Tools: tools, CacheRetention: CacheShort, SessionID: "sess-tool", } resp2, err := prov.ChatCompletion(ctx, req2) @@ -1119,7 +1157,7 @@ func TestAnthropicProtocol_ToolCallCache(t *testing.T) { } assertCacheFields(t, "tool turn 2", resp2.Usage) - if resp2.Usage.CacheReadTokens == 0 { + if resp2.Usage.Detail["cache_read"] == 0 { t.Error("tool turn 2: expected cache_read > 0") } @@ -1167,12 +1205,12 @@ func TestLive_OpenAIProtocol_AllScenarios(t *testing.T) { func runMultiTurnScenario(t *testing.T, prov Provider, label string) { t.Helper() ctx := testContext() - sys := NewTextMessage("system", "You are a helpful assistant. "+strings.Repeat("You have deep expertise in mathematics and always answer with just the numeric result. ", 30)) - user1 := NewTextMessage("user", "What is 2+2?") + sys := TextMessage("system", "You are a helpful assistant. "+strings.Repeat("You have deep expertise in mathematics and always answer with just the numeric result. ", 30)) + user1 := TextMessage("user", "What is 2+2?") // Turn 1 req1 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1}, + Messages: []*aop.Message{sys, user1}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-mt", } resp1, err := prov.ChatCompletion(ctx, req1) @@ -1183,9 +1221,9 @@ func runMultiTurnScenario(t *testing.T, prov Provider, label string) { // Turn 2 a1 := resp1.Choices[0].Message - user2 := NewTextMessage("user", "What is 3+3?") + user2 := TextMessage("user", "What is 3+3?") req2 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1, a1, user2}, + Messages: []*aop.Message{sys, user1, a1, user2}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-mt", } resp2, err := prov.ChatCompletion(ctx, req2) @@ -1196,9 +1234,9 @@ func runMultiTurnScenario(t *testing.T, prov Provider, label string) { // Turn 3 a2 := resp2.Choices[0].Message - user3 := NewTextMessage("user", "What is 4+4?") + user3 := TextMessage("user", "What is 4+4?") req3 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1, a1, user2, a2, user3}, + Messages: []*aop.Message{sys, user1, a1, user2, a2, user3}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-mt", } resp3, err := prov.ChatCompletion(ctx, req3) @@ -1208,13 +1246,13 @@ func runMultiTurnScenario(t *testing.T, prov Provider, label string) { assertCacheFields(t, label+" turn 3", resp3.Usage) // Context should grow - if resp3.Usage.PromptTokens <= resp1.Usage.PromptTokens { + if resp3.Usage.InputTokens <= resp1.Usage.InputTokens { t.Errorf("%s: prompt tokens should grow (turn1=%d turn3=%d)", - label, resp1.Usage.PromptTokens, resp3.Usage.PromptTokens) + label, resp1.Usage.InputTokens, resp3.Usage.InputTokens) } // Cache should improve (may be 0 if prompt is below provider's minimum cache threshold) - if resp2.Usage.CacheReadTokens == 0 && resp3.Usage.CacheReadTokens == 0 { + if resp2.Usage.Detail["cache_read"] == 0 && resp3.Usage.Detail["cache_read"] == 0 { t.Logf("%s: WARNING cache_read=0 in turn 2 and 3 — prompt may be below provider minimum cache threshold", label) } @@ -1231,21 +1269,21 @@ func runStreamingMultiTurnScenario(t *testing.T, prov Provider, label string) { t.Skipf("%s: provider does not support streaming", label) } ctx := testContext() - sys := NewTextMessage("system", "You translate to French. "+strings.Repeat("Always respond with just the translation. ", 30)) + sys := TextMessage("system", "You translate to French. "+strings.Repeat("Always respond with just the translation. ", 30)) // Turn 1 - user1 := NewTextMessage("user", "Hello") + user1 := TextMessage("user", "Hello") req1 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1}, + Messages: []*aop.Message{sys, user1}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-stream", Stream: true, } msg1, usage1 := collectStream(t, sp, ctx, req1) // Turn 2 - a1 := NewTextMessage("assistant", msg1) - user2 := NewTextMessage("user", "Goodbye") + a1 := TextMessage("assistant", msg1) + user2 := TextMessage("user", "Goodbye") req2 := &ChatCompletionRequest{ - Messages: []ChatMessage{sys, user1, a1, user2}, + Messages: []*aop.Message{sys, user1, a1, user2}, MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-stream", Stream: true, } _, usage2 := collectStream(t, sp, ctx, req2) @@ -1254,7 +1292,7 @@ func runStreamingMultiTurnScenario(t *testing.T, prov Provider, label string) { t.Fatalf("%s: streaming did not return usage", label) } - if usage2.CacheReadTokens == 0 { + if usage2.Detail["cache_read"] == 0 { t.Errorf("%s: expected cache_read > 0 in stream turn 2", label) } @@ -1266,20 +1304,20 @@ func runStreamingMultiTurnScenario(t *testing.T, prov Provider, label string) { func runForkScenario(t *testing.T, prov Provider, label string) { t.Helper() ctx := testContext() - sys := NewTextMessage("system", "You are a scanner. "+strings.Repeat("Analyze targets. ", 30)) + sys := TextMessage("system", "You are a scanner. "+strings.Repeat("Analyze targets. ", 30)) // Build parent conversation (3 exchanges) - parentMsgs := []ChatMessage{sys} + parentMsgs := []*aop.Message{sys} for i := 1; i <= 3; i++ { parentMsgs = append(parentMsgs, - NewTextMessage("user", fmt.Sprintf("question %d", i)), - NewTextMessage("assistant", fmt.Sprintf("answer %d", i)), + TextMessage("user", fmt.Sprintf("question %d", i)), + TextMessage("assistant", fmt.Sprintf("answer %d", i)), ) } // Parent's next request parentReq := &ChatCompletionRequest{ - Messages: append(append([]ChatMessage(nil), parentMsgs...), NewTextMessage("user", "parent question 4")), + Messages: append(append([]*aop.Message(nil), parentMsgs...), TextMessage("user", "parent question 4")), MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-fork", } parentResp, err := prov.ChatCompletion(ctx, parentReq) @@ -1289,7 +1327,7 @@ func runForkScenario(t *testing.T, prov Provider, label string) { // Fork child: inherits parent messages, new prompt childReq := &ChatCompletionRequest{ - Messages: append(append([]ChatMessage(nil), parentMsgs...), NewTextMessage("user", "forked child task")), + Messages: append(append([]*aop.Message(nil), parentMsgs...), TextMessage("user", "forked child task")), MaxTokens: 50, CacheRetention: CacheShort, SessionID: "sess-fork", } childResp, err := prov.ChatCompletion(ctx, childReq) @@ -1298,17 +1336,17 @@ func runForkScenario(t *testing.T, prov Provider, label string) { } // Both should have cache reads (shared prefix) - if childResp.Usage.CacheReadTokens == 0 { + if childResp.Usage.Detail["cache_read"] == 0 { t.Errorf("%s: fork child expected cache_read > 0", label) } t.Logf("\n=== %s Fork Summary ===", label) t.Logf(" Parent: prompt=%d cache_read=%d cache_write=%d (%.0f%%)", - parentResp.Usage.PromptTokens, parentResp.Usage.CacheReadTokens, parentResp.Usage.CacheWriteTokens, - parentResp.Usage.CacheHitRatio()*100) + parentResp.Usage.InputTokens, parentResp.Usage.Detail["cache_read"], parentResp.Usage.Detail["cache_write"], + CacheHitRatio(parentResp.Usage)*100) t.Logf(" Child: prompt=%d cache_read=%d cache_write=%d (%.0f%%)", - childResp.Usage.PromptTokens, childResp.Usage.CacheReadTokens, childResp.Usage.CacheWriteTokens, - childResp.Usage.CacheHitRatio()*100) + childResp.Usage.InputTokens, childResp.Usage.Detail["cache_read"], childResp.Usage.Detail["cache_write"], + CacheHitRatio(childResp.Usage)*100) } // ============================================================================= @@ -1498,14 +1536,14 @@ func skipLive(t *testing.T) (*ProviderConfig, Provider) { return cfg, p } -func collectStream(t *testing.T, sp StreamingProvider, ctx context.Context, req *ChatCompletionRequest) (string, *Usage) { +func collectStream(t *testing.T, sp StreamingProvider, ctx context.Context, req *ChatCompletionRequest) (string, *aop.TokenUsage) { t.Helper() ch, err := sp.ChatCompletionStream(ctx, req) if err != nil { t.Fatal(err) } var content strings.Builder - var lastUsage *Usage + var lastUsage *aop.TokenUsage for event := range ch { if event.Err != nil { t.Fatal(event.Err) @@ -1513,8 +1551,8 @@ func collectStream(t *testing.T, sp StreamingProvider, ctx context.Context, req if event.Usage != nil { lastUsage = event.Usage } - if event.Delta.Content != nil { - content.WriteString(*event.Delta.Content) + if delta := event.MessageDelta; delta != nil { + content.WriteString(delta.GetText()) } if event.Done { break @@ -1523,28 +1561,24 @@ func collectStream(t *testing.T, sp StreamingProvider, ctx context.Context, req return content.String(), lastUsage } -func assertCacheFields(t *testing.T, label string, u *Usage) { +func assertCacheFields(t *testing.T, label string, u *aop.TokenUsage) { t.Helper() if u == nil { t.Errorf("%s: usage is nil", label) return } - if u.PromptTokens == 0 { + if u.InputTokens == 0 { t.Errorf("%s: prompt_tokens = 0", label) } - // Cache fields should be non-negative (0 is fine for first turn) - if u.CacheReadTokens < 0 || u.CacheWriteTokens < 0 { - t.Errorf("%s: negative cache tokens: read=%d write=%d", label, u.CacheReadTokens, u.CacheWriteTokens) - } } -func logTurn(t *testing.T, turn int, u *Usage) { +func logTurn(t *testing.T, turn int, u *aop.TokenUsage) { t.Helper() if u == nil { t.Logf(" Turn %d: usage=nil", turn) return } t.Logf(" Turn %d: prompt=%d completion=%d cache_read=%d cache_write=%d hit_ratio=%.0f%%", - turn, u.PromptTokens, u.CompletionTokens, - u.CacheReadTokens, u.CacheWriteTokens, u.CacheHitRatio()*100) + turn, u.InputTokens, u.OutputTokens, + u.Detail["cache_read"], u.Detail["cache_write"], CacheHitRatio(u)*100) } diff --git a/agent/provider/endpoint_hint_test.go b/agent/provider/endpoint_hint_test.go index b14e0b77..15ff5d93 100644 --- a/agent/provider/endpoint_hint_test.go +++ b/agent/provider/endpoint_hint_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "strings" "testing" + + aop "github.com/chainreactors/aiscan/aop" ) // A 404 on the chat endpoint must surface as an actionable protocol-mismatch @@ -34,7 +36,7 @@ func TestChatCompletion404GivesProtocolHint(t *testing.T) { t.Fatalf("NewProvider: %v", err) } _, err = p.ChatCompletion(context.Background(), &ChatCompletionRequest{ - Messages: []ChatMessage{NewTextMessage("user", "hi")}, + Messages: []*aop.Message{TextMessage("user", "hi")}, }) if err == nil { t.Fatal("expected a 404 error") diff --git a/agent/provider/http.go b/agent/provider/http.go index d2a86262..7edc04b8 100644 --- a/agent/provider/http.go +++ b/agent/provider/http.go @@ -213,8 +213,8 @@ func streamSSE( sseSend(ctx, events, event) return } - if event.Delta.Role != "" || event.Delta.Content != nil || - event.Delta.ReasoningContent != nil || len(event.Delta.ToolCalls) > 0 || + if event.Role != "" || event.MessageDelta != nil || + len(event.ToolDeltas) > 0 || event.FinishReason != "" || event.Usage != nil { select { case events <- event: diff --git a/agent/provider/openai.go b/agent/provider/openai.go index 6baa23f3..e5eef218 100644 --- a/agent/provider/openai.go +++ b/agent/provider/openai.go @@ -2,10 +2,13 @@ package provider import ( "context" + "encoding/base64" "encoding/json" "fmt" "net/http" "strings" + + aop "github.com/chainreactors/aiscan/aop" ) type OpenAIProvider struct { @@ -62,14 +65,7 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *ChatCompletion } captureFrame(ctx, RawFrame{Provider: p.Name(), Protocol: ProviderOpenAI, Direction: "response", Transport: "http", Payload: data, MediaType: "application/json"}) - var result ChatCompletionResponse - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("unmarshal response: %w", err) - } - if result.Error != nil { - return nil, result.Error - } - return &result, nil + return parseOpenAIResponse(data) } func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *ChatCompletionRequest) (<-chan ChatCompletionStreamEvent, error) { @@ -142,27 +138,336 @@ func (p *OpenAIProvider) setAuthHeaders(req *http.Request) { } } +// --- OpenAI wire format --- + +type openAIMessage struct { + Name string `json:"name,omitempty"` + Role string `json:"role"` + Content any `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openAIContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` + } `json:"image_url,omitempty"` +} + +type openAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type openAITool struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` + } `json:"function"` +} + +// aopToOpenAIMessages flattens aop messages into the OpenAI chat format. A +// tool-role aop message (carrying a ToolResult) maps to a tool message; media +// and text parts map to content arrays. +func aopToOpenAIMessages(messages []*aop.Message) []openAIMessage { + out := make([]openAIMessage, 0, len(messages)) + for _, m := range messages { + if m == nil { + continue + } + wire := openAIMessage{Role: m.Role, Name: m.Name} + var text strings.Builder + var parts []openAIContentPart + for _, content := range m.Content { + switch value := content.Value.(type) { + case *aop.Content_Text: + if len(parts) > 0 { + parts = append(parts, openAIContentPart{Type: "text", Text: value.Text.Text}) + } else { + text.WriteString(value.Text.Text) + } + case *aop.Content_Reasoning: + wire.ReasoningContent = value.Reasoning.Text + case *aop.Content_Media: + if media := value.Media; media.Kind == "image" && media.Resource != nil { + if data := media.Resource.GetData(); len(data) > 0 { + url := "data:" + media.Resource.MediaType + ";base64," + base64.StdEncoding.EncodeToString(data) + parts = append(parts, openAIContentPart{Type: "image_url", ImageURL: &struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` + }{URL: url, Detail: "high"}}) + } + } + case *aop.Content_ToolCall: + call := value.ToolCall + args := "" + if call.Arguments != nil { + args = string(call.Arguments.Data) + } + var tc openAIToolCall + tc.ID = call.Id + tc.Type = "function" + tc.Function.Name = call.Name + tc.Function.Arguments = args + wire.ToolCalls = append(wire.ToolCalls, tc) + case *aop.Content_ToolResult: + result := value.ToolResult + wire.Role = "tool" + wire.ToolCallID = result.CallId + for _, block := range result.Output { + if t := block.GetText(); t != nil { + text.WriteString(t.Text) + } + if media := block.GetMedia(); media != nil && media.Kind == "image" && media.Resource != nil { + if data := media.Resource.GetData(); len(data) > 0 { + url := "data:" + media.Resource.MediaType + ";base64," + base64.StdEncoding.EncodeToString(data) + parts = append(parts, openAIContentPart{Type: "image_url", ImageURL: &struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` + }{URL: url, Detail: "high"}}) + } + } + } + } + } + if len(parts) > 0 { + all := make([]openAIContentPart, 0, len(parts)+1) + if text.Len() > 0 { + all = append(all, openAIContentPart{Type: "text", Text: text.String()}) + } + wire.Content = append(all, parts...) + } else if wire.ToolCallID == "" || text.Len() > 0 { + wire.Content = text.String() + } + out = append(out, wire) + } + return out +} + func marshalOpenAIRequest(req *ChatCompletionRequest) ([]byte, error) { - type streamOptions struct { - IncludeUsage bool `json:"include_usage"` + messages := aopToOpenAIMessages(req.Messages) + var tools []openAITool + for _, def := range req.Tools { + var t openAITool + t.Type = "function" + t.Function.Name = def.Name + t.Function.Description = def.Description + if def.InputSchema != nil { + var schema map[string]any + if err := json.Unmarshal(def.InputSchema.Data, &schema); err == nil { + t.Function.Parameters = schema + } + } + if t.Function.Parameters == nil { + t.Function.Parameters = map[string]any{"type": "object", "properties": map[string]any{}} + } + tools = append(tools, t) + } + body := map[string]any{ + "model": req.Model, + "messages": messages, + "stream": req.Stream, + } + if len(tools) > 0 { + body["tools"] = tools + } + if req.MaxTokens > 0 { + body["max_tokens"] = req.MaxTokens } - type wrapper struct { - *ChatCompletionRequest - StreamOptions *streamOptions `json:"stream_options,omitempty"` - PromptCacheKey string `json:"prompt_cache_key,omitempty"` - PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` + if req.Temperature != nil { + body["temperature"] = *req.Temperature } - w := wrapper{ChatCompletionRequest: req} if req.Stream { - w.StreamOptions = &streamOptions{IncludeUsage: true} + body["stream_options"] = map[string]any{"include_usage": true} } if req.CacheRetention != CacheNone && req.SessionID != "" { - w.PromptCacheKey = req.SessionID + body["prompt_cache_key"] = req.SessionID if req.CacheRetention == CacheLong { - w.PromptCacheRetention = "24h" + body["prompt_cache_retention"] = "24h" + } + } + return json.Marshal(body) +} + +// --- OpenAI response parsing --- + +type openAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` +} + +func (u *openAIUsage) UnmarshalJSON(data []byte) error { + type plain openAIUsage + var raw struct { + plain + // OpenAI format + PromptTokensDetails *struct { + CachedTokens int `json:"cached_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` + } `json:"prompt_tokens_details,omitempty"` + // DeepSeek format + PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens,omitempty"` + PromptCacheMissTokens *int `json:"prompt_cache_miss_tokens,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *u = openAIUsage(raw.plain) + if raw.PromptTokensDetails != nil { + u.CacheReadTokens = raw.PromptTokensDetails.CachedTokens + u.CacheWriteTokens = raw.PromptTokensDetails.CacheWriteTokens + } else if raw.PromptCacheHitTokens != nil { + u.CacheReadTokens = *raw.PromptCacheHitTokens + if raw.PromptCacheMissTokens != nil { + u.CacheWriteTokens = *raw.PromptCacheMissTokens + } + } + return nil +} + +func (u *openAIUsage) toProto() *aop.TokenUsage { + if u == nil { + return nil + } + return TokenUsage(u.PromptTokens, u.CompletionTokens, u.TotalTokens, u.CacheReadTokens, u.CacheWriteTokens) +} + +type openAIResponseMessage struct { + Role string `json:"role"` + Content *string `json:"content"` + ReasoningContent *string `json:"reasoning_content,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` +} + +func openAIMessageToAOP(msg *openAIResponseMessage) *aop.Message { + if msg.Role == "" { + msg.Role = "assistant" + } + out := &aop.Message{Role: msg.Role} + if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { + out.Content = append(out.Content, aop.Reasoning(*msg.ReasoningContent)) + } + if msg.Content != nil && *msg.Content != "" { + out.Content = append(out.Content, aop.Text(*msg.Content)) + } + for _, tc := range msg.ToolCalls { + var arguments *aop.EncodedValue + if tc.Function.Arguments != "" { + arguments = &aop.EncodedValue{Data: []byte(tc.Function.Arguments), MediaType: aop.JSONMediaType} + } + kind := tc.Type + if kind == "" { + kind = "function" + } + out.Content = append(out.Content, &aop.Content{Value: &aop.Content_ToolCall{ToolCall: &aop.ToolCall{ + Id: tc.ID, Name: tc.Function.Name, Kind: kind, Arguments: arguments, + }}}) + } + return out +} + +func parseOpenAIResponse(data []byte) (*ChatCompletionResponse, error) { + var raw struct { + ID string `json:"id"` + Choices []struct { + Message openAIResponseMessage `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *openAIUsage `json:"usage,omitempty"` + Error *APIError `json:"error,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + if raw.Error != nil { + return nil, raw.Error + } + result := &ChatCompletionResponse{ID: raw.ID, Usage: raw.Usage.toProto()} + for _, choice := range raw.Choices { + msg := choice.Message + result.Choices = append(result.Choices, Choice{ + Message: openAIMessageToAOP(&msg), + FinishReason: choice.FinishReason, + }) + } + return result, nil +} + +// --- OpenAI streaming --- + +type openAIStreamDelta struct { + Role string `json:"role,omitempty"` + Content *string `json:"content"` + ReasoningContent *string `json:"reasoning_content,omitempty"` + ToolCalls []struct { + Index int `json:"index,omitempty"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + } `json:"function,omitempty"` + } `json:"tool_calls,omitempty"` +} + +type openAIStreamChunk struct { + Choices []struct { + Delta openAIStreamDelta `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *openAIUsage `json:"usage,omitempty"` + Error *APIError `json:"error,omitempty"` +} + +func parseOpenAIStreamChunk(data []byte) (ChatCompletionStreamEvent, error) { + var chunk openAIStreamChunk + if err := json.Unmarshal(data, &chunk); err != nil { + return ChatCompletionStreamEvent{}, fmt.Errorf("unmarshal stream chunk: %w", err) + } + if chunk.Error != nil { + return ChatCompletionStreamEvent{}, chunk.Error + } + event := ChatCompletionStreamEvent{Usage: chunk.Usage.toProto()} + if len(chunk.Choices) == 0 { + return event, nil + } + delta := chunk.Choices[0].Delta + event.Role = delta.Role + event.FinishReason = chunk.Choices[0].FinishReason + if delta.Content != nil && *delta.Content != "" { + event.MessageDelta = &aop.MessageDelta{ + Operation: aop.DeltaOperation_DELTA_OPERATION_APPEND, + Value: &aop.MessageDelta_Text{Text: *delta.Content}, + } + } else if delta.ReasoningContent != nil && *delta.ReasoningContent != "" { + event.MessageDelta = &aop.MessageDelta{ + Operation: aop.DeltaOperation_DELTA_OPERATION_APPEND, + Value: &aop.MessageDelta_Reasoning{Reasoning: *delta.ReasoningContent}, + } + } + for _, tc := range delta.ToolCalls { + callDelta := &aop.ToolCallDelta{ + Index: uint32(tc.Index), CallId: tc.ID, Name: tc.Function.Name, + } + if tc.Function.Arguments != "" { + callDelta.Arguments = []byte(tc.Function.Arguments) } + event.ToolDeltas = append(event.ToolDeltas, callDelta) } - return json.Marshal(w) + return event, nil } // --- WebSearch via OpenAI Responses API --- @@ -255,29 +560,3 @@ func parseOpenAIWebSearchResponse(data []byte, maxResults int) (*WebSearchRespon out.Summary = strings.TrimSpace(out.Summary) return out, nil } - -type openAIStreamChunk struct { - Choices []struct { - Delta ChatMessageDelta `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *Usage `json:"usage,omitempty"` - Error *APIError `json:"error,omitempty"` -} - -func parseOpenAIStreamChunk(data []byte) (ChatCompletionStreamEvent, error) { - var chunk openAIStreamChunk - if err := json.Unmarshal(data, &chunk); err != nil { - return ChatCompletionStreamEvent{}, fmt.Errorf("unmarshal stream chunk: %w", err) - } - if chunk.Error != nil { - return ChatCompletionStreamEvent{}, chunk.Error - } - event := ChatCompletionStreamEvent{Usage: chunk.Usage} - if len(chunk.Choices) == 0 { - return event, nil - } - event.Delta = chunk.Choices[0].Delta - event.FinishReason = chunk.Choices[0].FinishReason - return event, nil -} diff --git a/agent/provider/provider_test.go b/agent/provider/provider_test.go index c168fc8c..44110848 100644 --- a/agent/provider/provider_test.go +++ b/agent/provider/provider_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + aop "github.com/chainreactors/aiscan/aop" ) func TestResolveProviderPresets(t *testing.T) { @@ -192,17 +194,16 @@ func TestAnthropicProviderChatCompletion(t *testing.T) { resp, err := p.ChatCompletion(context.Background(), &ChatCompletionRequest{ Model: "claude-test", - Messages: []ChatMessage{ - NewTextMessage("system", "system prompt"), - NewTextMessage("user", "scan localhost"), + Messages: []*aop.Message{ + TextMessage("system", "system prompt"), + TextMessage("user", "scan localhost"), }, - Tools: []ToolDefinition{{ + Tools: []*aop.ToolDefinition{{ Type: "function", - Function: FunctionDefinition{ - Name: "bash", - Parameters: map[string]interface{}{ - "type": "object", - }, + Name: "bash", + InputSchema: &aop.EncodedValue{ + Data: []byte(`{"type":"object"}`), + MediaType: aop.JSONMediaType, }, }}, }) @@ -213,13 +214,14 @@ func TestAnthropicProviderChatCompletion(t *testing.T) { t.Fatalf("choices = %d, want 1", len(resp.Choices)) } msg := resp.Choices[0].Message - if msg.Role != "assistant" || msg.Content == nil || *msg.Content != "scan ready" { + if msg.Role != "assistant" || MessageText(msg) != "scan ready" { t.Fatalf("message = %#v, want assistant text", msg) } - if len(msg.ToolCalls) != 1 { - t.Fatalf("tool calls = %d, want 1", len(msg.ToolCalls)) + calls := MessageToolCalls(msg) + if len(calls) != 1 { + t.Fatalf("tool calls = %d, want 1", len(calls)) } - if got := msg.ToolCalls[0].Function.Arguments; got != `{"command":"id"}` { + if got := string(calls[0].Arguments.Data); got != `{"command":"id"}` { t.Fatalf("tool arguments = %q, want command JSON", got) } if resp.Usage == nil || resp.Usage.TotalTokens != 15 { @@ -246,17 +248,17 @@ func TestAnthropicProviderParsesThinkingBlock(t *testing.T) { resp, err := p.ChatCompletion(context.Background(), &ChatCompletionRequest{ Model: "claude-test", - Messages: []ChatMessage{NewTextMessage("user", "think hard")}, + Messages: []*aop.Message{TextMessage("user", "think hard")}, }) if err != nil { t.Fatalf("ChatCompletion() error = %v", err) } msg := resp.Choices[0].Message - if msg.Content == nil || *msg.Content != "visible answer" { - t.Fatalf("content = %v, want 'visible answer'", msg.Content) + if got := MessageText(msg); got != "visible answer" { + t.Fatalf("content = %q, want 'visible answer'", got) } - if msg.ReasoningContent == nil || *msg.ReasoningContent != "internal reasoning" { - t.Fatalf("reasoning = %v, want 'internal reasoning'", msg.ReasoningContent) + if got := MessageReasoning(msg); got != "internal reasoning" { + t.Fatalf("reasoning = %q, want 'internal reasoning'", got) } } @@ -294,11 +296,9 @@ func TestOpenAIProviderChatCompletionStream(t *testing.T) { if event.Err != nil { t.Fatalf("stream error = %v", event.Err) } - if event.Delta.Content != nil { - text += *event.Delta.Content - } - if event.Delta.ReasoningContent != nil { - reasoning += *event.Delta.ReasoningContent + if delta := event.MessageDelta; delta != nil { + text += delta.GetText() + reasoning += delta.GetReasoning() } if event.Done { done = true @@ -363,7 +363,7 @@ func TestAnthropicProviderChatCompletionStream(t *testing.T) { ch, err := p.ChatCompletionStream(context.Background(), &ChatCompletionRequest{ Model: "claude-test", - Messages: []ChatMessage{NewTextMessage("user", "scan localhost")}, + Messages: []*aop.Message{TextMessage("user", "scan localhost")}, }) if err != nil { t.Fatalf("ChatCompletionStream() error = %v", err) @@ -373,33 +373,36 @@ func TestAnthropicProviderChatCompletionStream(t *testing.T) { var text string var done bool var finishReason string - var usage *Usage - toolCalls := make(map[int]ToolCall) + var usage *aop.TokenUsage + type toolCallAcc struct { + id string + name string + arguments string + } + toolCalls := make(map[uint32]*toolCallAcc) for event := range ch { if event.Err != nil { t.Fatalf("stream error = %v", event.Err) } - if event.Delta.Role != "" { - role = event.Delta.Role + if event.Role != "" { + role = event.Role } - if event.Delta.Content != nil { - text += *event.Delta.Content + if delta := event.MessageDelta; delta != nil { + text += delta.GetText() } - for _, delta := range event.Delta.ToolCalls { + for _, delta := range event.ToolDeltas { tc := toolCalls[delta.Index] - if delta.ID != "" { - tc.ID = delta.ID + if tc == nil { + tc = &toolCallAcc{} + toolCalls[delta.Index] = tc } - if delta.Type != "" { - tc.Type = delta.Type + if delta.CallId != "" { + tc.id = delta.CallId } - if delta.Function.Name != "" { - tc.Function.Name = delta.Function.Name + if delta.Name != "" { + tc.name = delta.Name } - if delta.Function.Arguments != "" { - tc.Function.Arguments += delta.Function.Arguments - } - toolCalls[delta.Index] = tc + tc.arguments += string(delta.Arguments) } if event.FinishReason != "" { finishReason = event.FinishReason @@ -421,11 +424,11 @@ func TestAnthropicProviderChatCompletionStream(t *testing.T) { t.Fatalf("finish reason = %q, want tool_calls", finishReason) } tc := toolCalls[1] - if tc.ID != "toolu_1" || tc.Type != "function" || tc.Function.Name != "bash" { + if tc == nil || tc.id != "toolu_1" || tc.name != "bash" { t.Fatalf("tool call = %#v, want bash tool call", tc) } - if tc.Function.Arguments != `{"command":"id"}` { - t.Fatalf("tool call arguments = %q, want command JSON", tc.Function.Arguments) + if tc.arguments != `{"command":"id"}` { + t.Fatalf("tool call arguments = %q, want command JSON", tc.arguments) } if usage == nil || usage.TotalTokens != 12 { t.Fatalf("usage = %#v, want total 12", usage) @@ -473,15 +476,24 @@ func TestAnthropicProviderStreamRejectsPrematureEOF(t *testing.T) { func TestAnthropicErrorToolResultIsMarkedOnWire(t *testing.T) { p := &AnthropicProvider{config: &ProviderConfig{BaseURL: "https://api.anthropic.com/v1"}} - result := NewToolResultMessage("call-truncated", truncatedToolResultForTest) - result.ToolResultIsError = true + result := ToolResultMessage("call-truncated", &aop.ToolResult{ + Output: []*aop.Content{aop.Text(truncatedToolResultForTest)}, + IsError: true, + }) body, err := p.marshalRequest(&ChatCompletionRequest{ Model: "test", - Messages: []ChatMessage{ - {Role: "assistant", ToolCalls: []ToolCall{{ - ID: "call-truncated", Type: "function", - Function: FunctionCall{Name: "write", Arguments: "{}"}, - }}}, + Messages: []*aop.Message{ + {Role: "assistant", Content: []*aop.Content{ + {Value: &aop.Content_ToolCall{ToolCall: &aop.ToolCall{ + Id: "call-truncated", + Name: "write", + Kind: "function", + Arguments: &aop.EncodedValue{ + Data: []byte(`{}`), + MediaType: aop.JSONMediaType, + }, + }}}, + }}, result, }, }) diff --git a/agent/provider/types.go b/agent/provider/types.go index a7238102..649d72d9 100644 --- a/agent/provider/types.go +++ b/agent/provider/types.go @@ -1,12 +1,11 @@ package provider import ( - "encoding/json" "fmt" "net/http" "strings" - "github.com/chainreactors/aiscan/core/tool" + aop "github.com/chainreactors/aiscan/aop" ) // CacheRetention controls prompt caching behavior across providers. @@ -18,211 +17,44 @@ const ( CacheLong CacheRetention = "long" // Anthropic ephemeral+TTL / OpenAI 24h retention ) -type ContentPart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ImageURL *ImageURL `json:"image_url,omitempty"` -} - -type ImageURL struct { - URL string `json:"url"` - Detail string `json:"detail,omitempty"` -} - -func TextPart(text string) ContentPart { - return ContentPart{Type: "text", Text: text} -} - -func ImagePart(mimeType, base64Data, detail string) ContentPart { - return ContentPart{ - Type: "image_url", - ImageURL: &ImageURL{URL: "data:" + mimeType + ";base64," + base64Data, Detail: detail}, - } -} - -type ChatMessage struct { - AOPMessageID string `json:"-"` - Name string `json:"name,omitempty"` - Role string `json:"role"` - Content *string `json:"content,omitempty"` - ContentParts []ContentPart `json:"-"` - ReasoningContent *string `json:"reasoning_content,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - // FinishReason is response metadata used by the agent loop. It must not be - // sent back as part of an OpenAI-compatible message. - FinishReason string `json:"-"` - ToolResultIsError bool `json:"-"` -} - -func (m ChatMessage) MarshalJSON() ([]byte, error) { - if len(m.ContentParts) == 0 { - type plain ChatMessage - return json.Marshal(plain(m)) - } - obj := map[string]interface{}{"role": m.Role, "content": m.ContentParts} - if m.ReasoningContent != nil { - obj["reasoning_content"] = *m.ReasoningContent - } - if len(m.ToolCalls) > 0 { - obj["tool_calls"] = m.ToolCalls - } - if m.ToolCallID != "" { - obj["tool_call_id"] = m.ToolCallID - } - return json.Marshal(obj) -} - -func NewMultimodalMessage(role string, parts []ContentPart) ChatMessage { - return ChatMessage{Role: role, ContentParts: parts} -} - -func ParseDataURI(dataURI string) (mediaType, base64Data string) { - rest, ok := strings.CutPrefix(dataURI, "data:") - if !ok { - return "", dataURI - } - parts := strings.SplitN(rest, ";base64,", 2) - if len(parts) != 2 { - return "", dataURI - } - return parts[0], parts[1] -} - -func StripImageParts(msgs []ChatMessage) []ChatMessage { - out := make([]ChatMessage, len(msgs)) - for i, m := range msgs { - if len(m.ContentParts) == 0 { - out[i] = m - continue - } - hasImage := false - for _, p := range m.ContentParts { - if p.Type == "image_url" { - hasImage = true - break - } - } - if !hasImage { - out[i] = m - continue - } - filtered := make([]ContentPart, 0, len(m.ContentParts)) - for _, p := range m.ContentParts { - if p.Type != "image_url" { - filtered = append(filtered, p) - } - } - filtered = append(filtered, TextPart("[image omitted: model does not support images]")) - cp := m - cp.ContentParts = filtered - out[i] = cp - } - return out -} - -type ChatMessageDelta struct { - Role string `json:"role,omitempty"` - Content *string `json:"content,omitempty"` - ReasoningContent *string `json:"reasoning_content,omitempty"` - ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"` -} - -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function FunctionCall `json:"function"` - RejectedReason string `json:"-"` -} - -type ToolCallDelta struct { - Index int `json:"index,omitempty"` - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Function FunctionCallDelta `json:"function,omitempty"` -} - -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -type FunctionCallDelta struct { - Name string `json:"name,omitempty"` - Arguments string `json:"arguments,omitempty"` -} - -type ToolDefinition = tool.Definition - -type FunctionDefinition = tool.FuncDef +// The provider boundary speaks AOP protos. Adapters (openai.go, anthropic.go) +// serialize []*aop.Message into the vendor wire format and parse responses +// back into aop types; nothing upstream of this package sees vendor JSON. type ChatCompletionRequest struct { - Model string `json:"model"` - Messages []ChatMessage `json:"messages"` - Tools []ToolDefinition `json:"tools,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - Stream bool `json:"stream,omitempty"` - CacheRetention CacheRetention `json:"-"` - SessionID string `json:"-"` + Model string + Messages []*aop.Message + Tools []*aop.ToolDefinition + MaxTokens int + Temperature *float64 + Stream bool + CacheRetention CacheRetention + SessionID string } type ChatCompletionResponse struct { - ID string `json:"id"` - Choices []Choice `json:"choices"` - Usage *Usage `json:"usage,omitempty"` - Error *APIError `json:"error,omitempty"` + ID string + Choices []Choice + Usage *aop.TokenUsage + Error *APIError } type Choice struct { - Message ChatMessage `json:"message"` - FinishReason string `json:"finish_reason"` -} - -type Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - CacheWriteTokens int `json:"cache_write_tokens,omitempty"` -} - -// CacheHitRatio returns the proportion of prompt tokens served from cache, -// based on the API response. Returns 0 when no cache data is available. -func (u *Usage) CacheHitRatio() float64 { - if u == nil || u.PromptTokens == 0 { - return 0 - } - return float64(u.CacheReadTokens) / float64(u.PromptTokens) + Message *aop.Message + FinishReason string } -func (u *Usage) UnmarshalJSON(data []byte) error { - type plain Usage - var raw struct { - plain - // OpenAI format - PromptTokensDetails *struct { - CachedTokens int `json:"cached_tokens"` - CacheWriteTokens int `json:"cache_write_tokens"` - } `json:"prompt_tokens_details,omitempty"` - // DeepSeek format - PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens,omitempty"` - PromptCacheMissTokens *int `json:"prompt_cache_miss_tokens,omitempty"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - *u = Usage(raw.plain) - if raw.PromptTokensDetails != nil { - u.CacheReadTokens = raw.PromptTokensDetails.CachedTokens - u.CacheWriteTokens = raw.PromptTokensDetails.CacheWriteTokens - } else if raw.PromptCacheHitTokens != nil { - u.CacheReadTokens = *raw.PromptCacheHitTokens - if raw.PromptCacheMissTokens != nil { - u.CacheWriteTokens = *raw.PromptCacheMissTokens - } - } - return nil +// ChatCompletionStreamEvent is one parsed SSE chunk. A chunk may carry a text +// or reasoning delta and/or tool-call deltas; Role is set on the first chunk +// of a message. +type ChatCompletionStreamEvent struct { + Role string + MessageDelta *aop.MessageDelta + ToolDeltas []*aop.ToolCallDelta + FinishReason string + Usage *aop.TokenUsage + Done bool + Err error } type APIError struct { @@ -262,18 +94,132 @@ func IsImageUnsupportedError(err error) bool { (strings.Contains(msg, "image") && strings.Contains(msg, "not support")) } -type ChatCompletionStreamEvent struct { - Delta ChatMessageDelta - FinishReason string - Usage *Usage - Done bool - Err error +// --- aop.Message constructors used across the agent --- + +func TextMessage(role, content string) *aop.Message { + return &aop.Message{Role: role, Content: []*aop.Content{aop.Text(content)}} +} + +func ToolResultMessage(callID string, result *aop.ToolResult) *aop.Message { + result.CallId = callID + return &aop.Message{Role: "tool", Content: []*aop.Content{{Value: &aop.Content_ToolResult{ToolResult: result}}}} +} + +// MessageText joins the text parts of an aop message. +func MessageText(msg *aop.Message) string { + if msg == nil { + return "" + } + var sb strings.Builder + for _, part := range msg.Content { + if text := part.GetText(); text != nil { + sb.WriteString(text.Text) + } + } + return sb.String() } -func NewTextMessage(role, content string) ChatMessage { - return ChatMessage{Role: role, Content: &content} +// MessageReasoning joins the reasoning parts of an aop message. +func MessageReasoning(msg *aop.Message) string { + if msg == nil { + return "" + } + var sb strings.Builder + for _, part := range msg.Content { + if reasoning := part.GetReasoning(); reasoning != nil { + sb.WriteString(reasoning.Text) + } + } + return sb.String() } -func NewToolResultMessage(toolCallID, content string) ChatMessage { - return ChatMessage{Role: "tool", Content: &content, ToolCallID: toolCallID} +// MessageToolCalls extracts the tool calls carried by an assistant message. +func MessageToolCalls(msg *aop.Message) []*aop.ToolCall { + if msg == nil { + return nil + } + var calls []*aop.ToolCall + for _, part := range msg.Content { + if call := part.GetToolCall(); call != nil { + calls = append(calls, call) + } + } + return calls +} + +// MessageToolResult returns the tool result carried by a tool-role message. +func MessageToolResult(msg *aop.Message) *aop.ToolResult { + if msg == nil { + return nil + } + for _, part := range msg.Content { + if result := part.GetToolResult(); result != nil { + return result + } + } + return nil +} + +// StripImageParts rewrites media parts into a placeholder note for models +// without image support. +func StripImageParts(msgs []*aop.Message) []*aop.Message { + out := make([]*aop.Message, len(msgs)) + for i, m := range msgs { + out[i] = m + hasImage := false + for _, part := range m.Content { + if part.GetMedia() != nil { + hasImage = true + break + } + } + if !hasImage { + continue + } + filtered := make([]*aop.Content, 0, len(m.Content)+1) + for _, part := range m.Content { + if part.GetMedia() == nil { + filtered = append(filtered, part) + } + } + filtered = append(filtered, aop.Text("[image omitted: model does not support images]")) + out[i] = &aop.Message{Id: m.Id, Role: m.Role, Name: m.Name, Content: filtered} + } + return out +} + +// TokenUsage builds the canonical usage proto from vendor-reported counters. +func TokenUsage(promptTokens, completionTokens, totalTokens, cacheRead, cacheWrite int) *aop.TokenUsage { + if totalTokens <= 0 { + totalTokens = promptTokens + completionTokens + } + return &aop.TokenUsage{ + InputTokens: uint64(max(promptTokens, 0)), + OutputTokens: uint64(max(completionTokens, 0)), + TotalTokens: uint64(max(totalTokens, 0)), + Detail: map[string]uint64{ + "cache_read": uint64(max(cacheRead, 0)), + "cache_write": uint64(max(cacheWrite, 0)), + }, + } +} + +// CacheHitRatio returns the proportion of prompt tokens served from cache. +func CacheHitRatio(usage *aop.TokenUsage) float64 { + if usage == nil || usage.InputTokens == 0 { + return 0 + } + return float64(usage.Detail["cache_read"]) / float64(usage.InputTokens) +} + +// UsageTotalTokens prefers the vendor-reported total and falls back to the +// sum of input and output tokens. +func UsageTotalTokens(usage *aop.TokenUsage) int { + if usage == nil { + return 0 + } + if usage.TotalTokens > 0 { + return int(usage.TotalTokens) + } + return int(usage.InputTokens + usage.OutputTokens) } diff --git a/agent/retry.go b/agent/retry.go index 85c8321e..b341b58c 100644 --- a/agent/retry.go +++ b/agent/retry.go @@ -13,8 +13,9 @@ import ( "time" "github.com/chainreactors/aiscan/agent/provider" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/telemetry" + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" ) type imageDisabler interface { @@ -162,7 +163,7 @@ func computeRetryDelay(attempt int, jitterFrac float64) time.Duration { return delay } -func requestWithRetry(ctx context.Context, cfg Config, em *aopEmitter, messages []ChatMessage, tools []ToolDefinition, turn int) (ChatMessage, *Usage, error) { +func requestWithRetry(ctx context.Context, cfg Config, em *aopEmitter, messages []*aop.Message, tools []*aop.ToolDefinition, turn int) (*assistantTurn, *aop.TokenUsage, error) { var lastErr error maxAttempts := cfg.MaxRetries + 1 if cfg.MaxRetries < 0 { @@ -179,18 +180,18 @@ func requestWithRetry(ctx context.Context, cfg Config, em *aopEmitter, messages select { case <-time.After(delay): case <-ctx.Done(): - return ChatMessage{}, nil, ctx.Err() + return nil, nil, ctx.Err() } } - msg, usage, err := requestAssistantMessageWithUsage(ctx, cfg, em, messages, tools, turn, messageID) + assistant, usage, err := requestAssistantMessageWithUsage(ctx, cfg, em, messages, tools, turn, messageID) if err == nil { - return msg, usage, nil + return assistant, usage, nil } lastErr = err if ctxErr := ctx.Err(); ctxErr != nil { - return ChatMessage{}, nil, ctxErr + return nil, nil, ctxErr } if provider.IsImageUnsupportedError(err) { @@ -198,21 +199,21 @@ func requestWithRetry(ctx context.Context, cfg Config, em *aopEmitter, messages if d, ok := cfg.Provider.(imageDisabler); ok { d.DisableImages() } - msg, usage, retryErr := requestAssistantMessageWithUsage(ctx, cfg, em, messages, tools, turn, messageID) + assistant, usage, retryErr := requestAssistantMessageWithUsage(ctx, cfg, em, messages, tools, turn, messageID) if retryErr == nil { - return msg, usage, nil + return assistant, usage, nil } - return ChatMessage{}, nil, retryErr + return nil, nil, retryErr } if !isRetryableError(err) { - return ChatMessage{}, nil, err + return nil, nil, err } } - return ChatMessage{}, nil, lastErr + return nil, nil, lastErr } -func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEmitter, messages []ChatMessage, tools []ToolDefinition, turn int, messageID string) (ChatMessage, *Usage, error) { +func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEmitter, messages []*aop.Message, tools []*aop.ToolDefinition, turn int, messageID string) (*assistantTurn, *aop.TokenUsage, error) { req := &ChatCompletionRequest{ Model: cfg.Model, Messages: messages, @@ -225,10 +226,10 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm estimatedInputTokens := estimateRequestTokens(messages, tools) maxTokens, err := clampMaxTokens(cfg.MaxTokens, cfg.ContextWindow, estimatedInputTokens) if err != nil { - return ChatMessage{}, nil, fmt.Errorf("cannot create LLM request at turn %d: %w", turn, err) + return nil, nil, fmt.Errorf("cannot create LLM request at turn %d: %w", turn, err) } req.MaxTokens = maxTokens - em.status(statusLLMRequest, aopStatusNamespace, &transport.LLMRequestDetail{ + em.status(statusLLMRequest, &agentpb.LLMRequestDetail{ Model: req.Model, Messages: uint32(len(req.Messages)), MaxTokens: uint32(max(req.MaxTokens, 0)), Stream: cfg.Stream, }) if cfg.Stream { @@ -239,18 +240,22 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm resp, err := cfg.Provider.ChatCompletion(ctx, req) if err != nil { - return ChatMessage{}, nil, fmt.Errorf("LLM call failed at turn %d: %w", turn, err) + return nil, nil, fmt.Errorf("LLM call failed at turn %d: %w", turn, err) } if len(resp.Choices) == 0 { - return ChatMessage{}, nil, fmt.Errorf("%w at turn %d", errEmptyResponse, turn) + return nil, nil, fmt.Errorf("%w at turn %d", errEmptyResponse, turn) } - msg := resp.Choices[0].Message - msg.FinishReason = resp.Choices[0].FinishReason - if parts := messagePartsFromChat(msg); len(parts) > 0 { - em.messageWithID(messageID, "assistant", parts) + choice := resp.Choices[0] + msg := choice.Message + if msg == nil { + msg = &aop.Message{Role: "assistant"} + } + msg.Id = messageID + if len(msg.Content) > 0 { + em.messageProto(msg) } logUsage(cfg.Logger, resp.Usage) - return msg, resp.Usage, nil + return &assistantTurn{message: msg, finishReason: choice.FinishReason}, resp.Usage, nil } func clampMaxTokens(configured, contextWindow, contextTokens int) (int, error) { @@ -273,7 +278,7 @@ func clampMaxTokens(configured, contextWindow, contextTokens int) (int, error) { return configured, nil } -func estimateRequestTokens(messages []ChatMessage, tools []ToolDefinition) int { +func estimateRequestTokens(messages []*aop.Message, tools []*aop.ToolDefinition) int { total := estimateAllTokens(messages) if len(tools) == 0 { return total @@ -284,26 +289,26 @@ func estimateRequestTokens(messages []ChatMessage, tools []ToolDefinition) int { return total } -func streamAssistantMessageWithUsage(ctx context.Context, p StreamingProvider, req *ChatCompletionRequest, em *aopEmitter, logger telemetry.Logger, turn int, messageID string) (ChatMessage, *Usage, error) { +func streamAssistantMessageWithUsage(ctx context.Context, p StreamingProvider, req *ChatCompletionRequest, em *aopEmitter, logger telemetry.Logger, turn int, messageID string) (*assistantTurn, *aop.TokenUsage, error) { events, err := p.ChatCompletionStream(ctx, req) if err != nil { - return ChatMessage{}, nil, fmt.Errorf("LLM stream failed at turn %d: %w", turn, err) + return nil, nil, fmt.Errorf("LLM stream failed at turn %d: %w", turn, err) } builder := newMessageBuilder() seenReasoning := false finishReason := "" - var usage *Usage + var usage *aop.TokenUsage for { select { case <-ctx.Done(): - return ChatMessage{}, nil, ctx.Err() + return nil, nil, ctx.Err() case event, ok := <-events: if !ok { goto streamDone } if event.Err != nil { - return ChatMessage{}, nil, fmt.Errorf("LLM stream failed at turn %d: %w", turn, event.Err) + return nil, nil, fmt.Errorf("LLM stream failed at turn %d: %w", turn, event.Err) } if event.Usage != nil { usage = event.Usage @@ -314,27 +319,29 @@ func streamAssistantMessageWithUsage(ctx context.Context, p StreamingProvider, r if event.Done { goto streamDone } - builder.Apply(event.Delta) - if event.Delta.ReasoningContent != nil && *event.Delta.ReasoningContent != "" { - seenReasoning = true - em.messageDelta(messageID, 0, partReasoning, *event.Delta.ReasoningContent) - } - if event.Delta.Content != nil && *event.Delta.Content != "" { - textIndex := 0 - if seenReasoning { - textIndex = 1 + builder.Apply(event) + if delta := event.MessageDelta; delta != nil { + if reasoning := delta.GetReasoning(); reasoning != "" { + seenReasoning = true + em.messageDelta(messageID, 0, partReasoning, reasoning) + } + if text := delta.GetText(); text != "" { + textIndex := 0 + if seenReasoning { + textIndex = 1 + } + em.messageDelta(messageID, textIndex, partText, text) } - em.messageDelta(messageID, textIndex, partText, *event.Delta.Content) } } } streamDone: msg := builder.Message() - msg.FinishReason = finishReason - if parts := messagePartsFromChat(msg); len(parts) > 0 { - em.messageWithID(messageID, "assistant", parts) + msg.Id = messageID + if len(msg.Content) > 0 { + em.messageProto(msg) } logUsage(logger, usage) - return msg, usage, nil + return &assistantTurn{message: msg, finishReason: finishReason}, usage, nil } diff --git a/agent/retry_test.go b/agent/retry_test.go index c027fac9..a4135ee3 100644 --- a/agent/retry_test.go +++ b/agent/retry_test.go @@ -9,8 +9,7 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/agent/provider" - aop "github.com/chainreactors/aiscan/aop" + "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" @@ -239,22 +238,21 @@ func TestImageErrorAutoRecovery(t *testing.T) { Logger: telemetry.NopLogger(), }) - a.LoadMessages([]ChatMessage{ - NewTextMessage("user", "take screenshot"), + a.LoadMessages([]*aop.Message{ + textMessage("user", "take screenshot"), { - Role: "assistant", - ToolCalls: []ToolCall{{ - ID: "tc1", Type: "function", - Function: FunctionCall{Name: "screenshot", Arguments: "{}"}, - }}, + Role: "assistant", + Content: []*aop.Content{toolCallContent("tc1", "screenshot", "{}")}, }, { - Role: "tool", - ToolCallID: "tc1", - ContentParts: []ContentPart{ - provider.TextPart("Screenshot captured"), - provider.ImagePart("image/png", "iVBORw0KGgo=", "high"), - }, + Role: "tool", + Content: []*aop.Content{{Value: &aop.Content_ToolResult{ToolResult: &aop.ToolResult{ + CallId: "tc1", + Output: []*aop.Content{ + aop.Text("Screenshot captured"), + aop.Image("image/png", []byte("iVBORw0KGgo=")), + }, + }}}}, }, }) @@ -280,22 +278,21 @@ func TestImageErrorRecoveryWithRealRetryPath(t *testing.T) { Logger: telemetry.NopLogger(), }) - a.LoadMessages([]ChatMessage{ - NewTextMessage("user", "take screenshot"), + a.LoadMessages([]*aop.Message{ + textMessage("user", "take screenshot"), { - Role: "assistant", - ToolCalls: []ToolCall{{ - ID: "tc1", Type: "function", - Function: FunctionCall{Name: "screenshot", Arguments: "{}"}, - }}, + Role: "assistant", + Content: []*aop.Content{toolCallContent("tc1", "screenshot", "{}")}, }, { - Role: "tool", - ToolCallID: "tc1", - ContentParts: []ContentPart{ - provider.TextPart("Screenshot taken"), - provider.ImagePart("image/png", "iVBORw0KGgo=", "high"), - }, + Role: "tool", + Content: []*aop.Content{{Value: &aop.Content_ToolResult{ToolResult: &aop.ToolResult{ + CallId: "tc1", + Output: []*aop.Content{ + aop.Text("Screenshot taken"), + aop.Image("image/png", []byte("iVBORw0KGgo=")), + }, + }}}}, }, }) @@ -324,15 +321,17 @@ func TestMultiTurnAfterImageError(t *testing.T) { Logger: telemetry.NopLogger(), }) - a.LoadMessages([]ChatMessage{ - NewTextMessage("user", "screenshot"), + a.LoadMessages([]*aop.Message{ + textMessage("user", "screenshot"), { - Role: "tool", - ToolCallID: "tc1", - ContentParts: []ContentPart{ - provider.TextPart("img"), - provider.ImagePart("image/png", "iVBORw0KGgo=", "high"), - }, + Role: "tool", + Content: []*aop.Content{{Value: &aop.Content_ToolResult{ToolResult: &aop.ToolResult{ + CallId: "tc1", + Output: []*aop.Content{ + aop.Text("img"), + aop.Image("image/png", []byte("iVBORw0KGgo=")), + }, + }}}}, }, }) diff --git a/agent/session.go b/agent/session.go index 4fdb3a28..ecce5558 100644 --- a/agent/session.go +++ b/agent/session.go @@ -6,17 +6,19 @@ import ( "os" "path/filepath" "sort" - "strings" "time" + + aop "github.com/chainreactors/aiscan/aop" + "google.golang.org/protobuf/encoding/protojson" ) type SessionData struct { - Version int `json:"version"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Model string `json:"model,omitempty"` - Provider string `json:"provider,omitempty"` - Messages []ChatMessage `json:"messages"` + Version int `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + Messages []*aop.Message `json:"messages"` // MessageCounter resumes AOP message_id allocation ("m-") after restore. MessageCounter int64 `json:"message_counter,omitempty"` } @@ -45,7 +47,15 @@ func SaveSession(dir string, data *SessionData) error { data.Version = sessionVersion data.Messages = sanitizeMessagesForSave(data.Messages) - raw, err := json.MarshalIndent(data, "", " ") + raw, err := json.MarshalIndent(sessionJSON{ + Version: data.Version, + CreatedAt: data.CreatedAt, + UpdatedAt: data.UpdatedAt, + Model: data.Model, + Provider: data.Provider, + Messages: marshalMessages(data.Messages), + MessageCounter: data.MessageCounter, + }, "", " ") if err != nil { return fmt.Errorf("marshal session: %w", err) } @@ -64,11 +74,59 @@ func LoadSession(path string) (*SessionData, error) { if err != nil { return nil, fmt.Errorf("read session file: %w", err) } - var data SessionData + var data sessionJSON if err := json.Unmarshal(raw, &data); err != nil { return nil, fmt.Errorf("parse session file: %w", err) } - return &data, nil + messages, err := unmarshalMessages(data.Messages) + if err != nil { + return nil, fmt.Errorf("parse session messages: %w", err) + } + return &SessionData{ + Version: data.Version, + CreatedAt: data.CreatedAt, + UpdatedAt: data.UpdatedAt, + Model: data.Model, + Provider: data.Provider, + Messages: messages, + MessageCounter: data.MessageCounter, + }, nil +} + +// sessionJSON is the on-disk envelope. Messages are stored as proto-JSON so +// the file format mirrors the AOP truth instead of a vendor shape. +type sessionJSON struct { + Version int `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + Messages []json.RawMessage `json:"messages"` + MessageCounter int64 `json:"message_counter,omitempty"` +} + +func marshalMessages(messages []*aop.Message) []json.RawMessage { + out := make([]json.RawMessage, 0, len(messages)) + for _, m := range messages { + raw, err := protojson.Marshal(m) + if err != nil { + continue + } + out = append(out, raw) + } + return out +} + +func unmarshalMessages(raw []json.RawMessage) ([]*aop.Message, error) { + out := make([]*aop.Message, 0, len(raw)) + for _, data := range raw { + msg := new(aop.Message) + if err := protojson.Unmarshal(data, msg); err != nil { + return nil, err + } + out = append(out, msg) + } + return out, nil } type sessionMeta struct { @@ -141,29 +199,32 @@ func (s SessionInfo) SortTime() time.Time { } } -func sanitizeMessagesForSave(messages []ChatMessage) []ChatMessage { - out := make([]ChatMessage, len(messages)) +// sanitizeMessagesForSave strips binary media parts before persisting: an +// image is re-fetchable context, not history worth 20 MiB of JSON. Text and +// tool call/result structure is preserved. +func sanitizeMessagesForSave(messages []*aop.Message) []*aop.Message { + out := make([]*aop.Message, len(messages)) for i, m := range messages { - if len(m.ContentParts) > 0 { - var text strings.Builder - for _, p := range m.ContentParts { - if p.Type == "text" { - if text.Len() > 0 { - text.WriteString("\n") - } - text.WriteString(p.Text) - } + hasMedia := false + for _, part := range m.Content { + if part.GetMedia() != nil { + hasMedia = true + break } - content := text.String() - out[i] = ChatMessage{ - Role: m.Role, - Content: &content, - ToolCalls: m.ToolCalls, - ToolCallID: m.ToolCallID, - } - } else { + } + if !hasMedia { out[i] = m + continue + } + filtered := make([]*aop.Content, 0, len(m.Content)) + for _, part := range m.Content { + if part.GetMedia() == nil { + filtered = append(filtered, part) + } } + cp := *m + cp.Content = filtered + out[i] = &cp } return out } diff --git a/agent/session_test.go b/agent/session_test.go index 3db8ebc9..0fc07dde 100644 --- a/agent/session_test.go +++ b/agent/session_test.go @@ -6,6 +6,9 @@ import ( "path/filepath" "testing" "time" + + "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" ) func TestSaveAndLoadSession(t *testing.T) { @@ -13,16 +16,16 @@ func TestSaveAndLoadSession(t *testing.T) { content := "hello world" toolArgs := `{"cmd":"ls"}` - messages := []ChatMessage{ - {Role: "user", Content: &content}, + messages := []*aop.Message{ + textMessage("user", content), { - Role: "assistant", - Content: &content, - ToolCalls: []ToolCall{ - {ID: "tc1", Type: "function", Function: FunctionCall{Name: "bash", Arguments: toolArgs}}, + Role: "assistant", + Content: []*aop.Content{ + aop.Text(content), + toolCallContent("tc1", "bash", toolArgs), }, }, - {Role: "tool", Content: &content, ToolCallID: "tc1"}, + toolResultMessage("tc1", content), } data := &SessionData{ @@ -59,14 +62,14 @@ func TestSaveAndLoadSession(t *testing.T) { if len(loaded.Messages) != 3 { t.Fatalf("messages len = %d, want 3", len(loaded.Messages)) } - if loaded.Messages[0].Role != "user" || *loaded.Messages[0].Content != "hello world" { + if loaded.Messages[0].Role != "user" || provider.MessageText(loaded.Messages[0]) != "hello world" { t.Errorf("message[0] = %+v", loaded.Messages[0]) } - if len(loaded.Messages[1].ToolCalls) != 1 || loaded.Messages[1].ToolCalls[0].Function.Name != "bash" { - t.Errorf("message[1] tool_calls = %+v", loaded.Messages[1].ToolCalls) + if calls := provider.MessageToolCalls(loaded.Messages[1]); len(calls) != 1 || calls[0].Name != "bash" { + t.Errorf("message[1] tool_calls = %+v", calls) } - if loaded.Messages[2].ToolCallID != "tc1" { - t.Errorf("message[2] tool_call_id = %q, want %q", loaded.Messages[2].ToolCallID, "tc1") + if r := provider.MessageToolResult(loaded.Messages[2]); r == nil || r.CallId != "tc1" { + t.Errorf("message[2] tool result = %+v, want call id tc1", r) } entries, _ := os.ReadDir(dir) @@ -89,19 +92,19 @@ func TestListSessionsSortsNewestFirst(t *testing.T) { Version: sessionVersion, UpdatedAt: oldTime, Model: "old", - Messages: []ChatMessage{NewTextMessage("user", "old")}, + Messages: []*aop.Message{textMessage("user", "old")}, }) writeSessionFile(t, filepath.Join(dir, "session-new.json"), SessionData{ Version: sessionVersion, UpdatedAt: newTime, Model: "new", - Messages: []ChatMessage{NewTextMessage("user", "new")}, + Messages: []*aop.Message{textMessage("user", "new")}, }) writeSessionFile(t, filepath.Join(dir, "latest.json"), SessionData{ Version: sessionVersion, UpdatedAt: newTime.Add(time.Hour), Model: "ignored", - Messages: []ChatMessage{NewTextMessage("user", "ignored")}, + Messages: []*aop.Message{textMessage("user", "ignored")}, }) sessions, err := ListSessions(dir) @@ -131,17 +134,14 @@ func writeSessionFile(t *testing.T, path string, data SessionData) { } func TestSanitizeMessagesForSave(t *testing.T) { - text := "some text" - reasoning := "thinking..." - msgs := []ChatMessage{ + msgs := []*aop.Message{ { - Role: "assistant", - Content: &text, - ReasoningContent: &reasoning, - ContentParts: []ContentPart{ - {Type: "text", Text: "part1"}, - {Type: "image_url"}, - {Type: "text", Text: "part2"}, + Role: "assistant", + Content: []*aop.Content{ + aop.Reasoning("thinking..."), + aop.Text("part1"), + aop.Image("image/png", []byte("binary-image-data")), + aop.Text("part2"), }, }, } @@ -149,11 +149,16 @@ func TestSanitizeMessagesForSave(t *testing.T) { if len(out) != 1 { t.Fatalf("len = %d", len(out)) } - if out[0].Content == nil || *out[0].Content != "part1\npart2" { - t.Errorf("content = %v, want %q", out[0].Content, "part1\npart2") + if got := provider.MessageText(out[0]); got != "part1part2" { + t.Errorf("content = %q, want %q", got, "part1part2") + } + for _, part := range out[0].Content { + if part.GetMedia() != nil { + t.Error("media parts should be stripped after sanitize") + } } - if len(out[0].ContentParts) != 0 { - t.Error("ContentParts should be empty after sanitize") + if got := provider.MessageReasoning(out[0]); got != "thinking..." { + t.Errorf("reasoning = %q, want preserved", got) } } diff --git a/agent/subagent.go b/agent/subagent.go index 96c93af4..7ef7850a 100644 --- a/agent/subagent.go +++ b/agent/subagent.go @@ -11,10 +11,12 @@ import ( "time" "github.com/chainreactors/aiscan/agent/inbox" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) type AgentType struct { @@ -63,14 +65,14 @@ type SubAgentArgs struct { Timeout string `json:"timeout,omitempty" jsonschema:"description=Optional timeout for sync mode (e.g. 30s or 2m). Returns error on timeout."` } -func (t *SubAgentTool) Definition() ToolDefinition { +func (t *SubAgentTool) Definition() *aop.ToolDefinition { return tool.Def(t.Name(), t.Description(), SubAgentArgs{}) } -func (t *SubAgentTool) Execute(ctx context.Context, arguments string) (tool.Result, error) { +func (t *SubAgentTool) Execute(ctx context.Context, arguments string) (*tool.Result, error) { args, err := tool.ParseArgs[SubAgentArgs](arguments) if err != nil { - return tool.Result{}, err + return nil, err } switch args.Action { @@ -79,23 +81,23 @@ func (t *SubAgentTool) Execute(ctx context.Context, arguments string) (tool.Resu case "kill": output, err := t.kill(args.Name) if err != nil { - return tool.Result{}, err + return nil, err } return tool.TextResult(output), nil case "message": output, err := t.sendMessage(args.Name, args.Message) if err != nil { - return tool.Result{}, err + return nil, err } return tool.TextResult(output), nil case "", "create": output, err := t.create(ctx, args.Prompt, args.Type, args.Name, args.Mode, args.Timeout) if err != nil { - return tool.Result{}, err + return nil, err } return tool.TextResult(output), nil default: - return tool.Result{}, fmt.Errorf("unknown action: %s", args.Action) + return nil, fmt.Errorf("unknown action: %s", args.Action) } } @@ -269,7 +271,7 @@ func runDerivedSession(ctx context.Context, sub *Agent, prompt string) (*Result, emitter.turnStart() result, err := sub.Run(ctx, TextInput(prompt), WithTurnID(turnID)) stop := StopReasonError - usage := Usage{} + var usage *aop.TokenUsage contextTokens := 0 if result != nil { stop = result.Stop @@ -406,14 +408,14 @@ func (t *SubAgentTool) uniqueName(base string) string { return base + "-" + hex.EncodeToString(b) } -func truncateToLastCompleteBoundary(messages []ChatMessage) []ChatMessage { - out := append([]ChatMessage(nil), messages...) +func truncateToLastCompleteBoundary(messages []*aop.Message) []*aop.Message { + out := append([]*aop.Message(nil), messages...) for i := len(out) - 1; i >= 0; i-- { msg := out[i] if msg.Role == "tool" || msg.Role == "user" { return out[:i+1] } - if msg.Role == "assistant" && len(msg.ToolCalls) == 0 { + if msg.Role == "assistant" && len(provider.MessageToolCalls(msg)) == 0 { return out[:i+1] } } diff --git a/agent/subagent_test.go b/agent/subagent_test.go index 3374f895..f04b5ef0 100644 --- a/agent/subagent_test.go +++ b/agent/subagent_test.go @@ -8,10 +8,11 @@ import ( "github.com/chainreactors/aiscan/agent/inbox" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" + coretool "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) func TestSubAgentSyncReturnsResult(t *testing.T) { @@ -28,7 +29,7 @@ func TestSubAgentSyncReturnsResult(t *testing.T) { if err != nil { t.Fatalf("Execute() error = %v", err) } - if got := result.Text(); got != ` + if got := coretool.ResultText(result); got != ` child result ` { t.Fatalf("result = %q", got) @@ -129,13 +130,15 @@ func TestSubAgentToolCallCarriesDelegationExtension(t *testing.T) { bus.Subscribe(func(event *aop.Event) { events <- event }) em := newAOPEmitter(bus, "aiscan", "parent-session", "", "", nil, 0) - em.toolCall("spawn-1", "subagent", map[string]any{ - "action": "create", - "prompt": "inspect the repository", - "name": "explorer", - "type": "reviewer", - "mode": "fork", - }, "") + em.toolCall(&aop.ToolCall{ + Id: "spawn-1", + Name: "subagent", + Kind: "function", + Arguments: &aop.EncodedValue{ + Data: []byte(`{"action":"create","prompt":"inspect the repository","name":"explorer","type":"reviewer","mode":"fork"}`), + MediaType: aop.JSONMediaType, + }, + }) event := <-events detail, ok, err := ext.GetDelegation(event) diff --git a/agent/types.go b/agent/types.go index c0a9477d..3d032d70 100644 --- a/agent/types.go +++ b/agent/types.go @@ -9,35 +9,26 @@ import ( "github.com/chainreactors/aiscan/agent/inbox" "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) -// Re-export provider types so external consumers only import agent. +// The agent loop operates on AOP protos directly. Vendored JSON shapes live +// only inside the provider adapters. -type ChatMessage = provider.ChatMessage -type ChatMessageDelta = provider.ChatMessageDelta -type ToolCall = provider.ToolCall -type ToolCallDelta = provider.ToolCallDelta -type FunctionCall = provider.FunctionCall -type FunctionCallDelta = provider.FunctionCallDelta -type ToolDefinition = provider.ToolDefinition -type FunctionDefinition = provider.FunctionDefinition -type ContentPart = provider.ContentPart -type ImageURL = provider.ImageURL +type ToolDefinition = aop.ToolDefinition +type Provider = provider.Provider +type StreamingProvider = provider.StreamingProvider +type ProviderConfig = provider.ProviderConfig type ChatCompletionRequest = provider.ChatCompletionRequest type ChatCompletionResponse = provider.ChatCompletionResponse type ChatCompletionStreamEvent = provider.ChatCompletionStreamEvent type ProviderRawFrame = provider.RawFrame type Choice = provider.Choice -type Usage = provider.Usage type APIError = provider.APIError type CacheRetention = provider.CacheRetention -type Provider = provider.Provider -type StreamingProvider = provider.StreamingProvider -type ProviderConfig = provider.ProviderConfig const ( CacheNone = provider.CacheNone @@ -46,12 +37,7 @@ const ( ) var ( - NewTextMessage = provider.NewTextMessage - NewToolResultMessage = provider.NewToolResultMessage - NewMultimodalMessage = provider.NewMultimodalMessage - TextPart = provider.TextPart - ImagePart = provider.ImagePart - ParseDataURI = provider.ParseDataURI + TextMessage = provider.TextMessage NewProvider = provider.NewProvider NewProviderFromResolved = provider.NewProviderFromResolved @@ -80,13 +66,13 @@ const ( StopReasonCanceled = hooks.StopReasonCanceled ) -type TransformContextFunc func([]ChatMessage) []ChatMessage +type TransformContextFunc func([]*aop.Message) []*aop.Message type BeforeToolCallContext struct { - AssistantMessage ChatMessage - ToolCall ToolCall + AssistantMessage *aop.Message + ToolCall *aop.ToolCall SystemPrompt string - Messages []ChatMessage + Messages []*aop.Message } type BeforeToolCallResult struct { @@ -95,12 +81,12 @@ type BeforeToolCallResult struct { } type AfterToolCallContext struct { - AssistantMessage ChatMessage - ToolCall ToolCall + AssistantMessage *aop.Message + ToolCall *aop.ToolCall Result string IsError bool SystemPrompt string - Messages []ChatMessage + Messages []*aop.Message } type ToolFlowDecision int @@ -136,7 +122,7 @@ type Config struct { Model string SystemPrompt string SystemPromptFn SystemPromptFunc - Messages []ChatMessage + Messages []*aop.Message MaxTokens int ContextWindow int Compaction CompactionSettings @@ -185,7 +171,7 @@ func (c Config) WithProvider(p Provider) Config { c.Provider = p; re func (c Config) WithTools(t tool.Executor) Config { c.Tools = t; return c } func (c Config) WithModel(m string) Config { c.Model = m; return c } func (c Config) WithSystemPrompt(s string) Config { c.SystemPrompt = s; return c } -func (c Config) WithMessages(msgs []ChatMessage) Config { c.Messages = msgs; return c } +func (c Config) WithMessages(msgs []*aop.Message) Config { c.Messages = msgs; return c } func (c Config) WithStream(s bool) Config { c.Stream = s; return c } func (c Config) WithInbox(ib inbox.Inbox) Config { c.Inbox = ib; return c } func (c Config) WithLogger(l telemetry.Logger) Config { c.Logger = l; return c } @@ -275,22 +261,14 @@ func NewAgent(cfg Config) *Agent { } } -type TurnUsage struct { - Turn int `json:"turn"` - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - CacheWriteTokens int `json:"cache_write_tokens,omitempty"` -} - type Result struct { - Output string - NewMessages []ChatMessage - Messages []ChatMessage - Turns int - TotalUsage Usage - TurnUsages []TurnUsage + Output string + NewMessages []*aop.Message + Messages []*aop.Message + Turns int + TotalUsage *aop.TokenUsage + // TurnUsages holds per-turn usage; the turn number is the slice index + 1. + TurnUsages []*aop.TokenUsage ContextTokens int Stop StopReason Err error @@ -299,7 +277,7 @@ type Result struct { type State struct { SystemPrompt string - Messages []ChatMessage + Messages []*aop.Message Tools tool.Executor ErrorMessage string LastError error diff --git a/core/tool/definition.go b/core/tool/definition.go index 947cb906..bf181341 100644 --- a/core/tool/definition.go +++ b/core/tool/definition.go @@ -1,14 +1,6 @@ package tool -// Definition describes a tool the LLM can invoke. -type Definition struct { - Type string `json:"type"` - Function FuncDef `json:"function"` -} +import aop "github.com/chainreactors/aiscan/aop" -// FuncDef is the schema half of a Definition. -type FuncDef struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} +// Definition describes a tool the LLM can invoke — the AOP transport proto. +type Definition = aop.ToolDefinition diff --git a/core/tool/interface.go b/core/tool/interface.go index d9628e94..e1a55976 100644 --- a/core/tool/interface.go +++ b/core/tool/interface.go @@ -3,21 +3,23 @@ package tool import ( "context" "fmt" + + aop "github.com/chainreactors/aiscan/aop" ) // Tool is a single tool that an LLM agent can invoke. type Tool interface { Name() string Description() string - Definition() Definition - Execute(ctx context.Context, arguments string) (Result, error) + Definition() *aop.ToolDefinition + Execute(ctx context.Context, arguments string) (*Result, error) } // Executor is the minimal interface the agent loop needs to // discover and invoke tools. CommandRegistry satisfies it directly. type Executor interface { - ToolDefinitions() []Definition - ExecuteTool(ctx context.Context, name, arguments string) (Result, error) + ToolDefinitions() []*aop.ToolDefinition + ExecuteTool(ctx context.Context, name, arguments string) (*Result, error) } // EmptyExecutor returns an Executor with no tools. @@ -25,7 +27,7 @@ func EmptyExecutor() Executor { return emptyExec{} } type emptyExec struct{} -func (emptyExec) ToolDefinitions() []Definition { return nil } -func (emptyExec) ExecuteTool(_ context.Context, name, _ string) (Result, error) { - return Result{}, fmt.Errorf("unknown tool: %s", name) +func (emptyExec) ToolDefinitions() []*aop.ToolDefinition { return nil } +func (emptyExec) ExecuteTool(_ context.Context, name, _ string) (*Result, error) { + return nil, fmt.Errorf("unknown tool: %s", name) } diff --git a/core/tool/result.go b/core/tool/result.go index 27668f14..150afa14 100644 --- a/core/tool/result.go +++ b/core/tool/result.go @@ -1,58 +1,47 @@ package tool -import "strings" - -// ContentBlock represents one piece of a tool result (text or image). -type ContentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - MimeType string `json:"mime_type,omitempty"` - Base64Data string `json:"base64_data,omitempty"` -} +import ( + "strings" -func TextBlock(text string) ContentBlock { - return ContentBlock{Type: "text", Text: text} -} + aop "github.com/chainreactors/aiscan/aop" +) -func ImageBlock(mimeType, base64Data string) ContentBlock { - return ContentBlock{Type: "image", MimeType: mimeType, Base64Data: base64Data} -} +// Result is the value returned by Tool.Execute — the AOP tool result proto. +type Result = aop.ToolResult -// Result is the value returned by Tool.Execute. -type Result struct { - Content []ContentBlock - Details any - IsError bool - Terminate bool -} - -func (r Result) Text() string { +func ResultText(r *Result) string { + if r == nil { + return "" + } var sb strings.Builder - for _, block := range r.Content { - if block.Type == "text" { - sb.WriteString(block.Text) + for _, block := range r.Output { + if text := block.GetText(); text != nil { + sb.WriteString(text.Text) } } return sb.String() } -func (r Result) HasImages() bool { - for _, block := range r.Content { - if block.Type == "image" { +func ResultHasImages(r *Result) bool { + if r == nil { + return false + } + for _, block := range r.Output { + if media := block.GetMedia(); media != nil && media.Kind == "image" { return true } } return false } -func TextResult(s string) Result { - return Result{Content: []ContentBlock{TextBlock(s)}} +func TextResult(s string) *Result { + return &Result{Output: []*aop.Content{aop.Text(s)}} } -func ErrorResult(msg string) Result { - return Result{Content: []ContentBlock{TextBlock(msg)}, IsError: true} +func ErrorResult(msg string) *Result { + return &Result{Output: []*aop.Content{aop.Text(msg)}, IsError: true} } -func TerminateResult(s string) Result { - return Result{Content: []ContentBlock{TextBlock(s)}, Terminate: true} +func TerminateResult(s string) *Result { + return &Result{Output: []*aop.Content{aop.Text(s)}, Terminate: true} } diff --git a/core/tool/schema.go b/core/tool/schema.go index a8bc793c..815750ea 100644 --- a/core/tool/schema.go +++ b/core/tool/schema.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + aop "github.com/chainreactors/aiscan/aop" "github.com/invopop/jsonschema" ) @@ -30,16 +31,15 @@ func SchemaOf(proto any) map[string]any { return m } -// ToolDef builds a complete Definition from a name, +// Def builds a complete Definition from a name, // description, and an args struct prototype. -func Def(name, description string, argsProto any) Definition { - return Definition{ - Type: "function", - Function: FuncDef{ - Name: name, - Description: description, - Parameters: SchemaOf(argsProto), - }, +func Def(name, description string, argsProto any) *aop.ToolDefinition { + schema, _ := aop.JSONValue(SchemaOf(argsProto)) + return &aop.ToolDefinition{ + Type: "function", + Name: name, + Description: description, + InputSchema: schema, } } diff --git a/pkg/commands/bash.go b/pkg/commands/bash.go index 720832eb..a99e6153 100644 --- a/pkg/commands/bash.go +++ b/pkg/commands/bash.go @@ -82,19 +82,19 @@ type BashArgs struct { Timeout int `json:"timeout,omitempty" jsonschema:"description=Optional timeout in seconds. The command is killed when it exceeds this. Omit to use the default (300s). Commands still running after 15s are moved to background and keep running until this timeout."` } -func (t *BashTool) Definition() coretool.Definition { +func (t *BashTool) Definition() *coretool.Definition { return coretool.Def("bash", t.Description(), BashArgs{}) } -func (t *BashTool) Execute(ctx context.Context, arguments string) (coretool.Result, error) { +func (t *BashTool) Execute(ctx context.Context, arguments string) (*coretool.Result, error) { args, err := coretool.ParseArgs[BashArgs](arguments) if err != nil { - return coretool.Result{}, err + return nil, err } command := strings.TrimSpace(args.Command) if command == "" { - return coretool.Result{}, fmt.Errorf("empty command") + return nil, fmt.Errorf("empty command") } if isOnlyCommentsOrBlank(command) { return coretool.TextResult("ok"), nil @@ -107,7 +107,7 @@ func (t *BashTool) Execute(ctx context.Context, arguments string) (coretool.Resu } execution, err := t.Start(ctx, command, options) if err != nil { - return coretool.Result{}, err + return nil, err } return t.waitOrBackground(execution, ctx, inbox.FromContext(ctx)), nil @@ -179,13 +179,13 @@ func (t *BashTool) RunForeground(ctx context.Context, command string, options Ba } // RunForegroundTool executes a command in the foreground and returns the -// collected ToolResult (bounded text plus structured Details), streaming raw +// collected ToolResult (bounded text and media), streaming raw // output through options.OnOutput. Transports that must not auto-background // (AOP tool.call) use this instead of Execute. -func (t *BashTool) RunForegroundTool(ctx context.Context, command string, options BashExecOptions) (coretool.Result, error) { +func (t *BashTool) RunForegroundTool(ctx context.Context, command string, options BashExecOptions) (*coretool.Result, error) { execution, err := t.RunForeground(ctx, command, options) if err != nil { - return coretool.Result{}, err + return nil, err } return t.collectResult(execution), nil } @@ -406,7 +406,7 @@ func configureProcess(cmd *exec.Cmd, workDir string, env []string) { } } -func (t *BashTool) waitOrBackground(execution *Execution, ctx context.Context, targetInbox inbox.Inbox) coretool.Result { +func (t *BashTool) waitOrBackground(execution *Execution, ctx context.Context, targetInbox inbox.Inbox) *coretool.Result { done := t.tasks.Done(execution.ID) select { case <-done: @@ -426,7 +426,7 @@ func (t *BashTool) waitOrBackground(execution *Execution, ctx context.Context, t } } -func (t *BashTool) collectResult(execution *Execution) coretool.Result { +func (t *BashTool) collectResult(execution *Execution) *coretool.Result { raw := t.tasks.PeekOrEmpty(execution.ID, truncate.DefaultMaxLines) r := truncate.Tail(raw, truncate.Options{}) text := r.Content @@ -444,7 +444,6 @@ func (t *BashTool) collectResult(execution *Execution) coretool.Result { text += fmt.Sprintf("\n[exit code: %d]", info.ExitCode) } result := coretool.TextResult(text) - result.Details = execution.Details return result } diff --git a/pkg/commands/bash_test.go b/pkg/commands/bash_test.go index b6a2c589..069270bb 100644 --- a/pkg/commands/bash_test.go +++ b/pkg/commands/bash_test.go @@ -78,20 +78,20 @@ func (c *outputCommand) Run(_ context.Context, execution *commands.Execution) (a // panicTool is a test tool that always panics. type panicTool struct{ msg string } -func (t *panicTool) Name() string { return "panic_tool" } -func (t *panicTool) Description() string { return "always panics" } -func (t *panicTool) Definition() tool.Definition { return tool.Definition{} } -func (t *panicTool) Execute(_ context.Context, _ string) (tool.Result, error) { +func (t *panicTool) Name() string { return "panic_tool" } +func (t *panicTool) Description() string { return "always panics" } +func (t *panicTool) Definition() *tool.Definition { return &tool.Definition{} } +func (t *panicTool) Execute(_ context.Context, _ string) (*tool.Result, error) { panic(t.msg) } // normalTool returns a result without panicking. type normalTool struct{} -func (t *normalTool) Name() string { return "normal_tool" } -func (t *normalTool) Description() string { return "works fine" } -func (t *normalTool) Definition() tool.Definition { return tool.Definition{} } -func (t *normalTool) Execute(_ context.Context, _ string) (tool.Result, error) { +func (t *normalTool) Name() string { return "normal_tool" } +func (t *normalTool) Description() string { return "works fine" } +func (t *normalTool) Definition() *tool.Definition { return &tool.Definition{} } +func (t *normalTool) Execute(_ context.Context, _ string) (*tool.Result, error) { return tool.TextResult("hello"), nil } @@ -108,10 +108,10 @@ type loggerAwareTool struct { logger telemetry.Logger } -func (t *loggerAwareTool) Name() string { return t.name } -func (t *loggerAwareTool) Description() string { return t.name } -func (t *loggerAwareTool) Definition() tool.Definition { return tool.Definition{} } -func (t *loggerAwareTool) Execute(_ context.Context, _ string) (tool.Result, error) { +func (t *loggerAwareTool) Name() string { return t.name } +func (t *loggerAwareTool) Description() string { return t.name } +func (t *loggerAwareTool) Definition() *tool.Definition { return &tool.Definition{} } +func (t *loggerAwareTool) Execute(_ context.Context, _ string) (*tool.Result, error) { return tool.TextResult("ok"), nil } func (t *loggerAwareTool) InitLogger(logger telemetry.Logger) { @@ -178,7 +178,7 @@ func TestScannerRejectsShellPipeAndFileRedir(t *testing.T) { t.Run(tt.name, func(t *testing.T) { res, err := bash.Execute(context.Background(), bashArgs(tt.cmd)) if err == nil { - t.Fatalf("expected error, got output %q", res.Text()) + t.Fatalf("expected error, got output %q", tool.ResultText(res)) } if !strings.Contains(err.Error(), tt.wantHint) { t.Fatalf("error = %v, want hint containing %q", err, tt.wantHint) @@ -200,7 +200,7 @@ func TestBashProxyEnvInjection(t *testing.T) { if err != nil { t.Fatalf("bash env: %v", err) } - out := res.Text() + out := tool.ResultText(res) for _, envVar := range []string{"ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"} { if !strings.Contains(out, envVar+"="+proxy) { t.Errorf("env output missing %s", envVar) @@ -218,7 +218,7 @@ func TestBashNoProxyEnvWhenEmpty(t *testing.T) { if err != nil { t.Fatalf("bash env: %v", err) } - if strings.Contains(res.Text(), "ALL_PROXY=socks5://") { + if strings.Contains(tool.ResultText(res), "ALL_PROXY=socks5://") { t.Errorf("should not inject proxy when empty") } } @@ -304,7 +304,7 @@ func TestPseudoPipeGrep(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := strings.TrimSpace(res.Text()) + out := strings.TrimSpace(tool.ResultText(res)) t.Logf("output:\n%s", out) lines := strings.Split(out, "\n") @@ -328,7 +328,7 @@ func TestPseudoPipeHead(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := strings.TrimSpace(res.Text()) + out := strings.TrimSpace(tool.ResultText(res)) t.Logf("output:\n%s", out) lines := strings.Split(out, "\n") @@ -347,7 +347,7 @@ func TestPseudoPipeWc(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := strings.TrimSpace(res.Text()) + out := strings.TrimSpace(tool.ResultText(res)) t.Logf("output: %q", out) if out != "5" { @@ -365,7 +365,7 @@ func TestPseudoPipeChain(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := strings.TrimSpace(res.Text()) + out := strings.TrimSpace(tool.ResultText(res)) t.Logf("output: %q", out) if out != "3" { @@ -383,7 +383,7 @@ func TestPseudoPipeAwk(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := strings.TrimSpace(res.Text()) + out := strings.TrimSpace(tool.ResultText(res)) t.Logf("output:\n%s", out) if !strings.Contains(out, "[critical]") { @@ -406,7 +406,7 @@ func TestPseudoPipeGrepRegexWithPipe(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := strings.TrimSpace(res.Text()) + out := strings.TrimSpace(tool.ResultText(res)) t.Logf("output:\n%s", out) lines := strings.Split(out, "\n") @@ -452,8 +452,8 @@ func TestNoPipeStillWorks(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(res.Text(), "all findings here") { - t.Errorf("output %q should contain expected text", res.Text()) + if !strings.Contains(tool.ResultText(res), "all findings here") { + t.Errorf("output %q should contain expected text", tool.ResultText(res)) } } @@ -633,8 +633,8 @@ func TestBashExecuteHonorsTimeoutArg(t *testing.T) { if elapsed := time.Since(started); elapsed > 5*time.Second { t.Fatalf("timeout arg not enforced promptly, took %s", elapsed) } - if !strings.Contains(res.Text(), "timeout after 1s") { - t.Fatalf("result = %q", res.Text()) + if !strings.Contains(tool.ResultText(res), "timeout after 1s") { + t.Fatalf("result = %q", tool.ResultText(res)) } } @@ -687,7 +687,7 @@ func TestShellPipeStillWorks(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := strings.TrimSpace(res.Text()) + out := strings.TrimSpace(tool.ResultText(res)) if out != "3" { t.Errorf("expected 3, got %q", out) } @@ -706,8 +706,8 @@ func TestPseudoFlagWithPipeChar(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(res.Text(), "match") { - t.Errorf("output %q should contain 'match'", res.Text()) + if !strings.Contains(tool.ResultText(res), "match") { + t.Errorf("output %q should contain 'match'", tool.ResultText(res)) } } @@ -729,8 +729,8 @@ func TestExecuteTool_RecoversPanic(t *testing.T) { if !strings.Contains(err.Error(), "tool panic_tool panic") { t.Fatalf("error should identify the tool, got: %s", err.Error()) } - if result.Text() != "" { - t.Fatalf("result should be empty on panic, got: %s", result.Text()) + if tool.ResultText(result) != "" { + t.Fatalf("result should be empty on panic, got: %s", tool.ResultText(result)) } } @@ -742,8 +742,8 @@ func TestExecuteTool_NormalToolUnaffected(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if result.Text() != "hello" { - t.Fatalf("expected 'hello', got: %s", result.Text()) + if tool.ResultText(result) != "hello" { + t.Fatalf("expected 'hello', got: %s", tool.ResultText(result)) } } @@ -764,10 +764,10 @@ func TestExecuteTool_PanicDoesNotAffectSubsequentCalls(t *testing.T) { if err != nil { t.Fatalf("normal tool failed after panic recovery: %v", err) } - if result.Text() != "hello" { - t.Fatalf("expected 'hello', got: %s", result.Text()) + if tool.ResultText(result) != "hello" { + t.Fatalf("expected 'hello', got: %s", tool.ResultText(result)) } - t.Logf("call 2 (normal_tool): succeeded after panic → result=%q", result.Text()) + t.Logf("call 2 (normal_tool): succeeded after panic → result=%q", tool.ResultText(result)) // Call 3: panic again — still recoverable. _, err = reg.ExecuteTool(context.Background(), "panic_tool", "{}") @@ -781,5 +781,5 @@ func TestExecuteTool_PanicDoesNotAffectSubsequentCalls(t *testing.T) { if err != nil { t.Fatalf("normal tool failed after second panic: %v", err) } - t.Logf("call 4 (normal_tool): still works → result=%q", result.Text()) + t.Logf("call 4 (normal_tool): still works → result=%q", tool.ResultText(result)) } diff --git a/pkg/commands/command.go b/pkg/commands/command.go index 2e1775a0..5d2f39f1 100644 --- a/pkg/commands/command.go +++ b/pkg/commands/command.go @@ -86,26 +86,26 @@ func (r *CommandRegistry) GetTool(name string) (tool.Tool, bool) { return t, ok } -func (r *CommandRegistry) ToolDefinitions() []tool.Definition { +func (r *CommandRegistry) ToolDefinitions() []*tool.Definition { tools := r.Tools() - defs := make([]tool.Definition, 0, len(tools)) + defs := make([]*tool.Definition, 0, len(tools)) for _, t := range tools { defs = append(defs, t.Definition()) } return defs } -func (r *CommandRegistry) ExecuteTool(ctx context.Context, name, arguments string) (result tool.Result, err error) { +func (r *CommandRegistry) ExecuteTool(ctx context.Context, name, arguments string) (result *tool.Result, err error) { defer func() { if recovered := recover(); recovered != nil { - result = tool.Result{} + result = nil err = fmt.Errorf("tool %s panic: %v\n%s", name, recovered, debug.Stack()) } }() t, ok := r.GetTool(name) if !ok { - return tool.Result{}, fmt.Errorf("unknown tool: %s", name) + return nil, fmt.Errorf("unknown tool: %s", name) } return t.Execute(ctx, arguments) } diff --git a/pkg/commands/glob.go b/pkg/commands/glob.go index c86a75f7..8e7a17a2 100644 --- a/pkg/commands/glob.go +++ b/pkg/commands/glob.go @@ -38,21 +38,21 @@ type GlobArgs struct { Path string `json:"path,omitempty" jsonschema:"description=Base directory for the search (default: working directory)"` } -func (t *GlobTool) Definition() coretool.Definition { +func (t *GlobTool) Definition() *coretool.Definition { return coretool.Def("glob", t.Description(), GlobArgs{}) } -func (t *GlobTool) Execute(ctx context.Context, arguments string) (coretool.Result, error) { +func (t *GlobTool) Execute(ctx context.Context, arguments string) (*coretool.Result, error) { effective := *t effective.workDir = coretool.WorkDirFromContext(ctx, t.workDir) t = &effective args, err := coretool.ParseArgs[GlobArgs](arguments) if err != nil { - return coretool.Result{}, err + return nil, err } if args.Pattern == "" { - return coretool.Result{}, fmt.Errorf("pattern is required") + return nil, fmt.Errorf("pattern is required") } baseDir := t.workDir @@ -73,7 +73,7 @@ func (t *GlobTool) Execute(ctx context.Context, arguments string) (coretool.Resu matches, err = filepath.Glob(pattern) } if err != nil { - return coretool.Result{}, fmt.Errorf("glob error: %w", err) + return nil, fmt.Errorf("glob error: %w", err) } // Also search virtual/embedded files diff --git a/pkg/commands/glob_test.go b/pkg/commands/glob_test.go index 6bc7841a..379acc3a 100644 --- a/pkg/commands/glob_test.go +++ b/pkg/commands/glob_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + coretool "github.com/chainreactors/aiscan/core/tool" ) func TestGlobBasic(t *testing.T) { @@ -19,7 +21,7 @@ func TestGlobBasic(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := res.Text() + out := coretool.ResultText(res) if !strings.Contains(out, "a.go") || !strings.Contains(out, "b.go") { t.Fatalf("expected go files, got: %s", out) } @@ -41,7 +43,7 @@ func TestGlobRecursive(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := res.Text() + out := coretool.ResultText(res) if !strings.Contains(out, "main.go") { t.Fatalf("expected src/main.go in recursive match, got: %s", out) } diff --git a/pkg/commands/image_optimize.go b/pkg/commands/image_optimize.go index f7d21e96..66e99399 100644 --- a/pkg/commands/image_optimize.go +++ b/pkg/commands/image_optimize.go @@ -2,7 +2,6 @@ package commands import ( "bytes" - "encoding/base64" "fmt" "image" "image/jpeg" @@ -15,18 +14,18 @@ import ( const ( maxDimension = 2000 - maxPayloadBytes = 4_500_000 // 4.5MB base64, below Anthropic's 5MB limit + maxPayloadBytes = 3_400_000 // raw bytes; ~4.5MB base64, below Anthropic's 5MB limit ) var jpegQualities = []int{85, 70, 55, 40} type optimizedImage struct { - MimeType string - Base64Data string - OrigW int - OrigH int - FinalW int - FinalH int + MimeType string + Data []byte + OrigW int + OrigH int + FinalW int + FinalH int } func optimizeImage(r io.Reader, srcMime string) (*optimizedImage, error) { @@ -52,29 +51,28 @@ func optimizeImage(r io.Reader, srcMime string) (*optimizedImage, error) { finalBounds := img.Bounds() finalW, finalH := finalBounds.Dx(), finalBounds.Dy() - b64, mime, err := pickSmallestEncoding(img) + data, mime, err := pickSmallestEncoding(img) if err != nil { return nil, err } return &optimizedImage{ - MimeType: mime, - Base64Data: b64, - OrigW: origW, - OrigH: origH, - FinalW: finalW, - FinalH: finalH, + MimeType: mime, + Data: data, + OrigW: origW, + OrigH: origH, + FinalW: finalW, + FinalH: finalH, }, nil } func passthrough(raw []byte, mime string) (*optimizedImage, error) { - b64 := base64.StdEncoding.EncodeToString(raw) - if base64Len(len(raw)) > maxPayloadBytes { - return nil, fmt.Errorf("image too large after encoding (%d bytes, max %d)", base64Len(len(raw)), maxPayloadBytes) + if len(raw) > maxPayloadBytes { + return nil, fmt.Errorf("image too large after encoding (%d bytes, max %d)", len(raw), maxPayloadBytes) } return &optimizedImage{ - MimeType: mime, - Base64Data: b64, + MimeType: mime, + Data: raw, }, nil } @@ -105,7 +103,7 @@ func resizeIfNeeded(img image.Image, w, h int) image.Image { // pickSmallestEncoding tries PNG and multiple JPEG quality levels, // returning the smallest encoding that fits under maxPayloadBytes. -func pickSmallestEncoding(img image.Image) (b64 string, mime string, err error) { +func pickSmallestEncoding(img image.Image) (data []byte, mime string, err error) { pngData := encodePNG(img) jpegData := encodeJPEG(img, jpegQualities[0]) @@ -117,15 +115,15 @@ func pickSmallestEncoding(img image.Image) (b64 string, mime string, err error) bestMime = "image/jpeg" } - if base64Len(len(best)) <= maxPayloadBytes { - return base64.StdEncoding.EncodeToString(best), bestMime, nil + if len(best) <= maxPayloadBytes { + return best, bestMime, nil } // Too large — try lower JPEG qualities for _, q := range jpegQualities[1:] { jpegData = encodeJPEG(img, q) - if base64Len(len(jpegData)) <= maxPayloadBytes { - return base64.StdEncoding.EncodeToString(jpegData), "image/jpeg", nil + if len(jpegData) <= maxPayloadBytes { + return jpegData, "image/jpeg", nil } } @@ -144,12 +142,12 @@ func pickSmallestEncoding(img image.Image) (b64 string, mime string, err error) dst := image.NewRGBA(image.Rect(0, 0, w, h)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) jpegData = encodeJPEG(dst, jpegQualities[0]) - if base64Len(len(jpegData)) <= maxPayloadBytes { - return base64.StdEncoding.EncodeToString(jpegData), "image/jpeg", nil + if len(jpegData) <= maxPayloadBytes { + return jpegData, "image/jpeg", nil } } - return "", "", fmt.Errorf("cannot compress image to fit %d byte limit", maxPayloadBytes) + return nil, "", fmt.Errorf("cannot compress image to fit %d byte limit", maxPayloadBytes) } func encodePNG(img image.Image) []byte { @@ -164,7 +162,3 @@ func encodeJPEG(img image.Image, quality int) []byte { _ = jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}) return buf.Bytes() } - -func base64Len(n int) int { - return (n + 2) / 3 * 4 -} diff --git a/pkg/commands/image_optimize_test.go b/pkg/commands/image_optimize_test.go index 4a5337f7..cc614878 100644 --- a/pkg/commands/image_optimize_test.go +++ b/pkg/commands/image_optimize_test.go @@ -76,7 +76,7 @@ func TestOptimize_PayloadUnderLimit(t *testing.T) { if err != nil { t.Fatal(err) } - payloadSize := len(opt.Base64Data) + payloadSize := len(opt.Data) if payloadSize > maxPayloadBytes { t.Errorf("payload %d exceeds limit %d", payloadSize, maxPayloadBytes) } diff --git a/pkg/commands/list.go b/pkg/commands/list.go index 19007547..36b64ceb 100644 --- a/pkg/commands/list.go +++ b/pkg/commands/list.go @@ -42,14 +42,14 @@ type ListResult struct { Truncated bool `json:"truncated,omitempty"` } -func (t *ListTool) Definition() coretool.Definition { +func (t *ListTool) Definition() *coretool.Definition { return coretool.Def("ls", t.Description(), ListArgs{}) } -func (t *ListTool) Execute(ctx context.Context, arguments string) (coretool.Result, error) { +func (t *ListTool) Execute(ctx context.Context, arguments string) (*coretool.Result, error) { args, err := coretool.ParseArgs[ListArgs](arguments) if err != nil { - return coretool.Result{}, err + return nil, err } if args.Path == "" { args.Path = "." @@ -62,7 +62,7 @@ func (t *ListTool) Execute(ctx context.Context, arguments string) (coretool.Resu } entries, err := os.ReadDir(filepath.Clean(resolved)) if err != nil { - return coretool.Result{}, fmt.Errorf("list directory: %w", err) + return nil, fmt.Errorf("list directory: %w", err) } result := ListResult{Path: args.Path, Entries: make([]ListEntry, 0, min(len(entries), truncate.MaxGlobResults))} @@ -73,7 +73,7 @@ func (t *ListTool) Execute(ctx context.Context, arguments string) (coretool.Resu for _, entry := range entries { info, err := entry.Info() if err != nil { - return coretool.Result{}, fmt.Errorf("stat %s: %w", entry.Name(), err) + return nil, fmt.Errorf("stat %s: %w", entry.Name(), err) } result.Entries = append(result.Entries, ListEntry{ Name: entry.Name(), @@ -84,10 +84,7 @@ func (t *ListTool) Execute(ctx context.Context, arguments string) (coretool.Resu content, err := json.MarshalIndent(result, "", " ") if err != nil { - return coretool.Result{}, err + return nil, err } - return coretool.Result{ - Content: []coretool.ContentBlock{coretool.TextBlock(string(content))}, - Details: result, - }, nil + return coretool.TextResult(string(content)), nil } diff --git a/pkg/commands/list_test.go b/pkg/commands/list_test.go index 30de2eed..a839f612 100644 --- a/pkg/commands/list_test.go +++ b/pkg/commands/list_test.go @@ -24,8 +24,8 @@ func TestListToolReturnsStructuredDirectoryEntries(t *testing.T) { t.Fatal(err) } var listing ListResult - if err := json.Unmarshal([]byte(result.Text()), &listing); err != nil { - t.Fatalf("result is not structured JSON: %v\n%s", err, result.Text()) + if err := json.Unmarshal([]byte(coretool.ResultText(result)), &listing); err != nil { + t.Fatalf("result is not structured JSON: %v\n%s", err, coretool.ResultText(result)) } if listing.Path != "." || len(listing.Entries) != 2 { t.Fatalf("listing = %+v", listing) @@ -55,7 +55,7 @@ func TestListToolUsesInvocationWorkdir(t *testing.T) { t.Fatal(err) } var listing ListResult - if err := json.Unmarshal([]byte(result.Text()), &listing); err != nil { + if err := json.Unmarshal([]byte(coretool.ResultText(result)), &listing); err != nil { t.Fatal(err) } if len(listing.Entries) != 1 || listing.Entries[0].Name != "proof.txt" { diff --git a/pkg/commands/read.go b/pkg/commands/read.go index 7d1cc580..a77d5f2f 100644 --- a/pkg/commands/read.go +++ b/pkg/commands/read.go @@ -9,6 +9,7 @@ import ( "strings" "unicode/utf8" + aop "github.com/chainreactors/aiscan/aop" coretool "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/core/truncate" ) @@ -44,21 +45,21 @@ type ReadArgs struct { Limit int `json:"limit,omitempty" jsonschema:"description=Maximum number of lines to read (default: 2000)"` } -func (t *ReadTool) Definition() coretool.Definition { +func (t *ReadTool) Definition() *coretool.Definition { return coretool.Def("read", t.Description(), ReadArgs{}) } -func (t *ReadTool) Execute(ctx context.Context, arguments string) (coretool.Result, error) { +func (t *ReadTool) Execute(ctx context.Context, arguments string) (*coretool.Result, error) { effective := *t effective.workDir = coretool.WorkDirFromContext(ctx, t.workDir) t = &effective args, err := coretool.ParseArgs[ReadArgs](arguments) if err != nil { - return coretool.Result{}, err + return nil, err } if args.Path == "" { - return coretool.Result{}, fmt.Errorf("path is required") + return nil, fmt.Errorf("path is required") } // Virtual file reads (aiscan://..., embedded skills, etc.) @@ -75,11 +76,11 @@ func (t *ReadTool) Execute(ctx context.Context, arguments string) (coretool.Resu if result, ok := t.tryVirtualFallback(args.Path); ok { return result, nil } - return coretool.Result{}, fmt.Errorf("file not found: %s", args.Path) + return nil, fmt.Errorf("file not found: %s", args.Path) } if info.IsDir() { - return coretool.Result{}, fmt.Errorf("%s is a directory, not a file", args.Path) + return nil, fmt.Errorf("%s is a directory, not a file", args.Path) } if mime := detectImageMime(resolved); mime != "" { @@ -93,10 +94,10 @@ func (t *ReadTool) Execute(ctx context.Context, arguments string) (coretool.Resu return t.readFileLines(resolved, args.Path, args.Offset, args.Limit) } -func (t *ReadTool) readFileLines(resolved, displayPath string, offset, limit int) (coretool.Result, error) { +func (t *ReadTool) readFileLines(resolved, displayPath string, offset, limit int) (*coretool.Result, error) { f, err := os.Open(resolved) if err != nil { - return coretool.Result{}, fmt.Errorf("open file: %w", err) + return nil, fmt.Errorf("open file: %w", err) } defer f.Close() @@ -145,7 +146,7 @@ func (t *ReadTool) readFileLines(resolved, displayPath string, offset, limit int } if err := scanner.Err(); err != nil { - return coretool.Result{}, fmt.Errorf("read file: %w", err) + return nil, fmt.Errorf("read file: %w", err) } content := sb.String() @@ -161,7 +162,7 @@ func (t *ReadTool) readFileLines(resolved, displayPath string, offset, limit int return coretool.TextResult(content), nil } -func (t *ReadTool) readVirtual(args ReadArgs) (coretool.Result, error) { +func (t *ReadTool) readVirtual(args ReadArgs) (*coretool.Result, error) { for _, reader := range t.readers { if reader == nil { continue @@ -171,14 +172,14 @@ func (t *ReadTool) readVirtual(args ReadArgs) (coretool.Result, error) { continue } if err != nil { - return coretool.Result{}, err + return nil, err } return t.paginateString(content, args.Path, args.Offset, args.Limit), nil } - return coretool.Result{}, fmt.Errorf("virtual file not found: %s", args.Path) + return nil, fmt.Errorf("virtual file not found: %s", args.Path) } -func (t *ReadTool) tryVirtualFallback(path string) (coretool.Result, bool) { +func (t *ReadTool) tryVirtualFallback(path string) (*coretool.Result, bool) { for _, reader := range t.readers { if reader == nil { continue @@ -192,10 +193,10 @@ func (t *ReadTool) tryVirtualFallback(path string) (coretool.Result, bool) { } return t.paginateString(content, path, 0, 0), true } - return coretool.Result{}, false + return nil, false } -func (t *ReadTool) paginateString(content, displayPath string, offset, limit int) coretool.Result { +func (t *ReadTool) paginateString(content, displayPath string, offset, limit int) *coretool.Result { lines := strings.Split(content, "\n") totalLines := len(lines) @@ -279,31 +280,31 @@ func detectImageMime(path string) string { return "" } -func readImageFile(resolved, displayPath, mime string, size int64) (coretool.Result, error) { +func readImageFile(resolved, displayPath, mime string, size int64) (*coretool.Result, error) { if size > maxImageSize { return coretool.TextResult(fmt.Sprintf("[image too large: %s (%d bytes, max %d)]", displayPath, size, maxImageSize)), nil } f, err := os.Open(resolved) if err != nil { - return coretool.Result{}, fmt.Errorf("open image: %w", err) + return nil, fmt.Errorf("open image: %w", err) } defer f.Close() opt, err := optimizeImage(f, mime) if err != nil { - return coretool.Result{}, fmt.Errorf("optimize image: %w", err) + return nil, fmt.Errorf("optimize image: %w", err) } - desc := fmt.Sprintf("Read image file [%s] (%d bytes)", opt.MimeType, len(opt.Base64Data)*3/4) + desc := fmt.Sprintf("Read image file [%s] (%d bytes)", opt.MimeType, len(opt.Data)) if opt.OrigW > 0 && (opt.OrigW != opt.FinalW || opt.OrigH != opt.FinalH) { desc = fmt.Sprintf("Read image file [%s] (original %dx%d, resized to %dx%d)", opt.MimeType, opt.OrigW, opt.OrigH, opt.FinalW, opt.FinalH) } - return coretool.Result{ - Content: []coretool.ContentBlock{ - coretool.TextBlock(desc), - coretool.ImageBlock(opt.MimeType, opt.Base64Data), + return &coretool.Result{ + Output: []*aop.Content{ + aop.Text(desc), + aop.Image(opt.MimeType, opt.Data), }, }, nil } diff --git a/pkg/commands/read_test.go b/pkg/commands/read_test.go index fc9f8354..0e672742 100644 --- a/pkg/commands/read_test.go +++ b/pkg/commands/read_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + coretool "github.com/chainreactors/aiscan/core/tool" ) func TestReadSmallFile(t *testing.T) { @@ -19,7 +21,7 @@ func TestReadSmallFile(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := res.Text() + out := coretool.ResultText(res) if !strings.Contains(out, "line1") { t.Fatalf("expected line1 in output, got: %s", out) } @@ -42,7 +44,7 @@ func TestReadWithOffset(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := res.Text() + out := coretool.ResultText(res) if !strings.Contains(out, "line number 50") { t.Fatalf("expected line 50 at start, got: %s", out) } @@ -73,7 +75,7 @@ func TestReadLargeFileDoesNotOOM(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - out := res.Text() + out := coretool.ResultText(res) // Should stop at default limit (2000 lines) and provide continuation hint if !strings.Contains(out, "of 5000 total") { t.Fatalf("expected total line count in output, got: %s", out[len(out)-200:]) @@ -93,8 +95,8 @@ func TestReadBinaryFile(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(res.Text(), "[binary file") { - t.Fatalf("expected binary file detection, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "[binary file") { + t.Fatalf("expected binary file detection, got: %s", coretool.ResultText(res)) } } @@ -143,11 +145,11 @@ func TestReadImageFileByMagicBytes(t *testing.T) { if err != nil { t.Fatalf("%s: unexpected error: %v", tt.name, err) } - if !res.HasImages() { + if !coretool.ResultHasImages(res) { t.Fatalf("%s: expected image content", tt.name) } - if !strings.Contains(res.Text(), tt.mime) { - t.Fatalf("%s: expected mime %s in text, got: %s", tt.name, tt.mime, res.Text()) + if !strings.Contains(coretool.ResultText(res), tt.mime) { + t.Fatalf("%s: expected mime %s in text, got: %s", tt.name, tt.mime, coretool.ResultText(res)) } } } @@ -162,7 +164,7 @@ func TestReadNonImageBinaryNotDetectedAsImage(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if res.HasImages() { + if coretool.ResultHasImages(res) { t.Fatal("non-image binary should not be detected as image") } } diff --git a/pkg/commands/schema_test.go b/pkg/commands/schema_test.go index 3d8888c1..fc3a4d5e 100644 --- a/pkg/commands/schema_test.go +++ b/pkg/commands/schema_test.go @@ -3,6 +3,7 @@ package commands import ( "testing" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/tool" ) @@ -86,17 +87,21 @@ func TestToolDef(t *testing.T) { if def.Type != "function" { t.Fatalf("expected type=function, got %s", def.Type) } - if def.Function.Name != "read" { - t.Fatalf("expected name=read, got %s", def.Function.Name) + if def.Name != "read" { + t.Fatalf("expected name=read, got %s", def.Name) } - if def.Function.Description != "Read a file" { - t.Fatalf("expected description='Read a file', got %s", def.Function.Description) + if def.Description != "Read a file" { + t.Fatalf("expected description='Read a file', got %s", def.Description) } - if def.Function.Parameters == nil { - t.Fatal("expected non-nil parameters") + if def.InputSchema == nil { + t.Fatal("expected non-nil input schema") } - if def.Function.Parameters["type"] != "object" { - t.Fatalf("expected parameters type=object, got %v", def.Function.Parameters["type"]) + params, err := aop.DecodeJSON[map[string]any](def.InputSchema) + if err != nil { + t.Fatalf("decode input schema: %v", err) + } + if params["type"] != "object" { + t.Fatalf("expected parameters type=object, got %v", params["type"]) } } @@ -125,8 +130,8 @@ func TestParseArgsInvalid(t *testing.T) { func TestToolResult(t *testing.T) { r := tool.TextResult("hello world") - if r.Text() != "hello world" { - t.Fatalf("expected 'hello world', got %q", r.Text()) + if tool.ResultText(r) != "hello world" { + t.Fatalf("expected 'hello world', got %q", tool.ResultText(r)) } if r.IsError { t.Fatal("expected IsError=false") @@ -136,8 +141,8 @@ func TestToolResult(t *testing.T) { if !e.IsError { t.Fatal("expected IsError=true") } - if e.Text() != "something broke" { - t.Fatalf("expected 'something broke', got %q", e.Text()) + if tool.ResultText(e) != "something broke" { + t.Fatalf("expected 'something broke', got %q", tool.ResultText(e)) } tr := tool.TerminateResult("done") diff --git a/pkg/commands/write.go b/pkg/commands/write.go index 1554b3b4..03937c2f 100644 --- a/pkg/commands/write.go +++ b/pkg/commands/write.go @@ -43,21 +43,21 @@ type WriteArgs struct { Edits []EditPatch `json:"edits,omitempty" jsonschema:"description=One or more targeted replacements. Each edit is matched against the original file. Do not include overlapping edits."` } -func (t *WriteTool) Definition() coretool.Definition { +func (t *WriteTool) Definition() *coretool.Definition { return coretool.Def("write", t.Description(), WriteArgs{}) } -func (t *WriteTool) Execute(ctx context.Context, arguments string) (coretool.Result, error) { +func (t *WriteTool) Execute(ctx context.Context, arguments string) (*coretool.Result, error) { effective := *t effective.workDir = coretool.WorkDirFromContext(ctx, t.workDir) t = &effective args, err := coretool.ParseArgs[WriteArgs](arguments) if err != nil { - return coretool.Result{}, err + return nil, err } if args.Path == "" { - return coretool.Result{}, fmt.Errorf("path is required") + return nil, fmt.Errorf("path is required") } if len(args.Edits) > 0 { @@ -67,16 +67,16 @@ func (t *WriteTool) Execute(ctx context.Context, arguments string) (coretool.Res return t.writeFile(args) } -func (t *WriteTool) writeFile(args WriteArgs) (coretool.Result, error) { +func (t *WriteTool) writeFile(args WriteArgs) (*coretool.Result, error) { path := t.resolvePath(args.Path) dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { - return coretool.Result{}, fmt.Errorf("create directory: %w", err) + return nil, fmt.Errorf("create directory: %w", err) } if err := os.WriteFile(path, []byte(args.Content), 0644); err != nil { - return coretool.Result{}, fmt.Errorf("write file: %w", err) + return nil, fmt.Errorf("write file: %w", err) } lineCount := strings.Count(args.Content, "\n") + 1 @@ -90,12 +90,12 @@ type editMatch struct { newText string } -func (t *WriteTool) editFile(args WriteArgs) (coretool.Result, error) { +func (t *WriteTool) editFile(args WriteArgs) (*coretool.Result, error) { path := t.resolvePath(args.Path) data, err := os.ReadFile(path) if err != nil { - return coretool.Result{}, fmt.Errorf("read file for edit: %w", err) + return nil, fmt.Errorf("read file for edit: %w", err) } original := string(data) @@ -203,7 +203,7 @@ func (t *WriteTool) editFile(args WriteArgs) (coretool.Result, error) { } if err := os.WriteFile(path, []byte(result), 0644); err != nil { - return coretool.Result{}, fmt.Errorf("write edited file: %w", err) + return nil, fmt.Errorf("write edited file: %w", err) } // Build summary diff --git a/pkg/commands/write_test.go b/pkg/commands/write_test.go index a512ab35..b270bccc 100644 --- a/pkg/commands/write_test.go +++ b/pkg/commands/write_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + coretool "github.com/chainreactors/aiscan/core/tool" ) func TestWriteNewFile(t *testing.T) { @@ -16,8 +18,8 @@ func TestWriteNewFile(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(res.Text(), "wrote") { - t.Fatalf("expected write confirmation, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "wrote") { + t.Fatalf("expected write confirmation, got: %s", coretool.ResultText(res)) } data, _ := os.ReadFile(filepath.Join(dir, "new.txt")) @@ -52,8 +54,8 @@ func TestEditSingleReplace(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(res.Text(), "edited") { - t.Fatalf("expected edit confirmation, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "edited") { + t.Fatalf("expected edit confirmation, got: %s", coretool.ResultText(res)) } data, _ := os.ReadFile(path) @@ -76,8 +78,8 @@ func TestEditMultipleEdits(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(res.Text(), "2 edit(s)") { - t.Fatalf("expected 2 edits confirmation, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "2 edit(s)") { + t.Fatalf("expected 2 edits confirmation, got: %s", coretool.ResultText(res)) } data, _ := os.ReadFile(path) @@ -101,8 +103,8 @@ func TestEditNotFound(t *testing.T) { if !res.IsError { t.Fatal("expected IsError=true for old_text not found") } - if !strings.Contains(res.Text(), "not found") { - t.Fatalf("expected not found message, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "not found") { + t.Fatalf("expected not found message, got: %s", coretool.ResultText(res)) } } @@ -120,8 +122,8 @@ func TestEditAmbiguousWithoutReplaceAll(t *testing.T) { if !res.IsError { t.Fatal("expected IsError=true for ambiguous match") } - if !strings.Contains(res.Text(), "2 locations") { - t.Fatalf("expected ambiguity message, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "2 locations") { + t.Fatalf("expected ambiguity message, got: %s", coretool.ResultText(res)) } } @@ -145,8 +147,8 @@ func TestEditReplaceAll(t *testing.T) { if strings.Count(content, "x = 99") != 2 { t.Fatalf("expected 2 replacements, got: %s", content) } - if !strings.Contains(res.Text(), "2 occurrences") { - t.Fatalf("expected occurrence count in summary, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "2 occurrences") { + t.Fatalf("expected occurrence count in summary, got: %s", coretool.ResultText(res)) } } @@ -164,8 +166,8 @@ func TestEditOverlapDetection(t *testing.T) { if !res.IsError { t.Fatal("expected IsError=true for overlapping edits") } - if !strings.Contains(res.Text(), "overlap") { - t.Fatalf("expected overlap message, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "overlap") { + t.Fatalf("expected overlap message, got: %s", coretool.ResultText(res)) } } @@ -180,8 +182,8 @@ func TestEditReportsLineNumber(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(res.Text(), "line 3") { - t.Fatalf("expected edit at line 3, got: %s", res.Text()) + if !strings.Contains(coretool.ResultText(res), "line 3") { + t.Fatalf("expected edit at line 3, got: %s", coretool.ResultText(res)) } } diff --git a/pkg/runner/application_builder.go b/pkg/runner/application_builder.go index af6d8a9d..72d25759 100644 --- a/pkg/runner/application_builder.go +++ b/pkg/runner/application_builder.go @@ -5,6 +5,7 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) type RuntimeFeatures struct { @@ -16,6 +17,55 @@ type RuntimeFeatures struct { Warning string } +// AppConfigFromDistribute builds the runner configuration directly from the +// canonical config proto. Fields that have no proto representation (playwright +// session, uncover credentials, CLI skill paths) stay at their defaults; the +// startup path layers them from cfg.Option via MergeOptionExtras. +func AppConfigFromDistribute(dc *configpb.DistributeConfig, features RuntimeFeatures, logger telemetry.Logger) ApplicationConfig { + return ApplicationConfig{ + Provider: ApplicationProviderConfig{ + Enabled: features.ProviderEnabled, + Config: ProviderConfigFromProto(dc.GetLlm()), + Fallbacks: FallbackProviderConfigsFromProto(dc.GetLlm()), + Optional: features.ProviderOptional, + }, + Scanner: ScannerConfig{ + CyberhubURL: dc.GetCyberhub().GetUrl(), + CyberhubKey: dc.GetCyberhub().GetKey(), + CyberhubMode: dc.GetCyberhub().GetMode(), + AIEnabled: features.AIEnabled, + VerifyMode: cfg.ResolveString(dc.GetScan().GetVerify(), cfg.DefaultVerify), + Proxy: dc.GetCyberhub().GetProxy(), + FofaEmail: dc.GetRecon().GetFofaEmail(), + FofaKey: dc.GetRecon().GetFofaKey(), + HunterToken: dc.GetRecon().GetHunterToken(), + HunterAPIKey: dc.GetRecon().GetHunterApiKey(), + ReconProxy: dc.GetRecon().GetProxy(), + ReconLimit: int(dc.GetRecon().GetLimit()), + }, + Tools: ToolConfig{ + Enabled: features.ToolsEnabled, + BashTimeout: 300, + TavilyKeys: dc.GetSearch().GetTavilyKeys(), + OptionalTools: append([]string(nil), dc.GetAgent().GetTools()...), + }, + Logger: logger, + } +} + +// MergeOptionExtras layers the fields DistributeConfig does not model onto a +// proto-built ApplicationConfig: playwright session, uncover credentials, and +// CLI skill paths. +func MergeOptionExtras(rc ApplicationConfig, option *cfg.Option) ApplicationConfig { + if option == nil { + return rc + } + rc.Scanner.UncoverCredentials = cloneStringMap(option.UncoverCredentials) + rc.Tools.PlaywrightSession = option.PlaywrightSession + rc.CLISkillPaths = skillPathsFromOptions(option) + return rc +} + func AppConfig(option *cfg.Option, features RuntimeFeatures, logger telemetry.Logger) ApplicationConfig { return ApplicationConfig{ Provider: ApplicationProviderConfig{ diff --git a/pkg/runner/provider_config.go b/pkg/runner/provider_config.go index 0576e435..f898155e 100644 --- a/pkg/runner/provider_config.go +++ b/pkg/runner/provider_config.go @@ -5,6 +5,7 @@ import ( "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) func defaultProviderConfig() agent.ProviderConfig { @@ -123,3 +124,50 @@ func ApplyResolvedProviderOptions(option *cfg.Option, providerConfig agent.Provi option.MaxTokens = providerConfig.MaxTokens option.ContextWindow = providerConfig.ContextWindow } + +// ProviderConfigFromProto resolves the active LLM profile directly from the +// canonical config proto. This is the only provider-config path used when a +// DistributeConfig is already in hand (remote agents, hub reload). +func ProviderConfigFromProto(llm *configpb.LLMConfig) agent.ProviderConfig { + active := cfg.ActiveLLMProvider(llm) + if active == nil { + return defaultProviderConfig() + } + return providerConfigFromProto(active) +} + +// FallbackProviderConfigsFromProto returns every non-active profile in order. +func FallbackProviderConfigsFromProto(llm *configpb.LLMConfig) []agent.ProviderConfig { + if llm == nil { + return nil + } + active := cfg.ActiveLLMProvider(llm) + var configs []agent.ProviderConfig + for _, profile := range llm.Providers { + if active != nil && profile.Id == active.Id { + continue + } + configs = append(configs, providerConfigFromProto(profile)) + } + return configs +} + +func providerConfigFromProto(profile *configpb.LLMProviderConfig) agent.ProviderConfig { + profile = cfg.NormalizeLLMProvider(profile) + providerName := strings.TrimSpace(profile.Provider) + if providerName == "" { + providerName = agent.InferProviderFromBaseURL(profile.BaseUrl) + } else { + providerName = agent.NormalizeProvider(providerName) + } + return agent.ProviderConfig{ + Provider: providerName, + BaseURL: profile.BaseUrl, + APIKey: profile.ApiKey, + Model: profile.Model, + Proxy: profile.Proxy, + Timeout: 120, + MaxTokens: int(profile.MaxTokens), + ContextWindow: int(profile.ContextWindow), + } +} diff --git a/pkg/runner/provider_config_from_proto_test.go b/pkg/runner/provider_config_from_proto_test.go new file mode 100644 index 00000000..14aae722 --- /dev/null +++ b/pkg/runner/provider_config_from_proto_test.go @@ -0,0 +1,86 @@ +package runner + +import ( + "testing" + + cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/telemetry" + configpb "github.com/chainreactors/aiscan/pkg/types/config" +) + +func TestProviderConfigFromProtoSelectsActiveProfileAndFallbacks(t *testing.T) { + llm := &configpb.LLMConfig{ + ActiveProfile: "openai", + Providers: []*configpb.LLMProviderConfig{ + {Id: "deepseek", Provider: "openai", ApiKey: "dk-111", Model: "deepseek-chat", MaxTokens: 8192}, + {Id: "openai", Provider: "openai", ApiKey: "sk-222", Model: "gpt-4o", MaxTokens: 32768}, + }, + } + primary := ProviderConfigFromProto(llm) + if primary.Provider != "openai" || primary.APIKey != "sk-222" || primary.MaxTokens != 32768 { + t.Fatalf("primary profile = %+v", primary) + } + fallbacks := FallbackProviderConfigsFromProto(llm) + if len(fallbacks) != 1 || fallbacks[0].Provider != "openai" || fallbacks[0].APIKey != "dk-111" { + t.Fatalf("fallback profiles = %+v", fallbacks) + } +} + +func TestProviderConfigFromProtoInfersProtocolFromBaseURL(t *testing.T) { + llm := &configpb.LLMConfig{Providers: []*configpb.LLMProviderConfig{ + {Id: "claude", BaseUrl: "https://api.anthropic.com", ApiKey: "ak", Model: "claude-opus-4-7"}, + }} + primary := ProviderConfigFromProto(llm) + if primary.Provider != "anthropic" { + t.Fatalf("inferred provider = %q, want anthropic", primary.Provider) + } +} + +func TestAppConfigFromDistributeMapsProtoSections(t *testing.T) { + dc := &configpb.DistributeConfig{ + Llm: &configpb.LLMConfig{ + ActiveProfile: "main", + Providers: []*configpb.LLMProviderConfig{{Id: "main", Provider: "openai", ApiKey: "sk", Model: "gpt-4o"}}, + }, + Cyberhub: &configpb.CyberhubConfig{Url: "https://hub", Key: "hub-key", Mode: "release", Proxy: "http://proxy"}, + Recon: &configpb.ReconConfig{ + FofaEmail: "a@b.c", FofaKey: "fofa", HunterToken: "ht", HunterApiKey: "hk", + Proxy: "http://recon-proxy", Limit: 42, + }, + Scan: &configpb.ScanConfig{Verify: "high"}, + Search: &configpb.SearchConfig{TavilyKeys: "tv-1,tv-2"}, + Agent: &configpb.AgentConfig{Tools: []string{"search", "browser"}}, + } + rc := AppConfigFromDistribute(dc, RuntimeFeatures{ProviderEnabled: true, ToolsEnabled: true, AIEnabled: true}, telemetry.NopLogger()) + + if rc.Provider.Config.Model != "gpt-4o" || !rc.Provider.Enabled { + t.Fatalf("provider config = %+v", rc.Provider) + } + if rc.Scanner.CyberhubURL != "https://hub" || rc.Scanner.CyberhubKey != "hub-key" || rc.Scanner.CyberhubMode != "release" || rc.Scanner.Proxy != "http://proxy" { + t.Fatalf("cyberhub = %+v", rc.Scanner) + } + if rc.Scanner.FofaEmail != "a@b.c" || rc.Scanner.FofaKey != "fofa" || rc.Scanner.HunterToken != "ht" || rc.Scanner.HunterAPIKey != "hk" { + t.Fatalf("recon = %+v", rc.Scanner) + } + if rc.Scanner.ReconProxy != "http://recon-proxy" || rc.Scanner.ReconLimit != 42 { + t.Fatalf("recon proxy/limit = %+v", rc.Scanner) + } + if rc.Scanner.VerifyMode != "high" || !rc.Scanner.AIEnabled { + t.Fatalf("scan section = %+v", rc.Scanner) + } + if rc.Tools.TavilyKeys != "tv-1,tv-2" || len(rc.Tools.OptionalTools) != 2 || !rc.Tools.Enabled { + t.Fatalf("tools = %+v", rc.Tools) + } +} + +func TestMergeOptionExtrasLayersNonProtoFields(t *testing.T) { + rc := AppConfigFromDistribute(&configpb.DistributeConfig{}, RuntimeFeatures{}, telemetry.NopLogger()) + option := &cfg.Option{ + PlaywrightSession: "browser-1", + UncoverCredentials: map[string]string{"SHODAN_API_KEY": "shodan-key"}, + } + rc = MergeOptionExtras(rc, option) + if rc.Tools.PlaywrightSession != "browser-1" || rc.Scanner.UncoverCredentials["SHODAN_API_KEY"] != "shodan-key" { + t.Fatalf("extras = %+v", rc) + } +} diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 0474a1ea..8aaee0bb 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -40,7 +40,7 @@ type AgentRuntime struct { sessionEvents *sessionEmitter output *tui.AgentOutput configFile string - resumeMessages []agent.ChatMessage + resumeMessages []*aop.Message ctx context.Context cancel context.CancelFunc mu sync.RWMutex @@ -50,6 +50,7 @@ type AgentRuntime struct { closeOnce sync.Once wg sync.WaitGroup operations sync.WaitGroup + namespaceMux *aop.NamespaceMux ptyManager *tmuxpkg.Manager replMode REPLMode maxPending int @@ -93,6 +94,12 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L sessions: make(map[string]*sessionState), runs: make(map[string]*Run), } + namespaceMux, err := newRuntimeNamespaceMux(rt) + if err != nil { + runtimeCancel() + return nil, fmt.Errorf("init runtime namespaces: %w", err) + } + rt.namespaceMux = namespaceMux if rc != nil { rt.replMode = rc.REPLMode rt.maxPending = rc.MaxPending diff --git a/pkg/runner/runtime_protocol.go b/pkg/runner/runtime_protocol.go index 6c1ac1c3..002d1bbb 100644 --- a/pkg/runner/runtime_protocol.go +++ b/pkg/runner/runtime_protocol.go @@ -3,17 +3,22 @@ package runner import ( "context" "encoding/json" + "errors" + "fmt" + "io" + "strconv" "strings" + "sync/atomic" + "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" protobuf "google.golang.org/protobuf/proto" ) -const AIScanRunOptionsNamespace = "io.chainreactors.aiscan.run" - -func RuntimeCommandSpecs() []*transport.CommandSpec { - return []*transport.CommandSpec{ +func RuntimeCommandSpecs() []*commandpb.Spec { + return []*commandpb.Spec{ {Name: "/status", Description: "Show Runtime session and provider status"}, {Name: "/clear", Description: "Clear the current Agent context"}, {Name: "/compact", Usage: "/compact [focus]", Description: "Compact the current Agent context"}, @@ -22,9 +27,6 @@ func RuntimeCommandSpecs() []*transport.CommandSpec { func (rt *AgentRuntime) OpenAOPSession(req *aop.OpenSessionRequest) *aop.OpenSessionResponse { response := &aop.OpenSessionResponse{} - if req != nil { - response.RequestId = req.RequestId - } if rt == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { response.Outcome = &aop.OpenSessionResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id is required")} return response @@ -34,23 +36,20 @@ func (rt *AgentRuntime) OpenAOPSession(req *aop.OpenSessionRequest) *aop.OpenSes response.Outcome = &aop.OpenSessionResponse_Rejected{Rejected: rejection("FAILED_PRECONDITION", err.Error())} return response } - response.Outcome = &aop.OpenSessionResponse_Accepted{Accepted: &aop.Session{Id: session.ID(), State: "open", Participant: req.Participant, Title: req.Title}} + response.Outcome = &aop.OpenSessionResponse_Accepted{Accepted: &aop.Session{Id: session.ID(), State: "open", NodeUri: req.NodeUri, Title: req.Title}} return response } func (rt *AgentRuntime) RunAOPTurn(ctx context.Context, req *aop.RunTurnRequest) *aop.RunTurnResponse { response := &aop.RunTurnResponse{} - if req != nil { - response.RequestId = req.RequestId - } if rt == nil || req == nil || (!req.ContinueSession && req.Input == nil) || strings.TrimSpace(req.SessionId) == "" || strings.TrimSpace(req.TurnId) == "" { response.Outcome = &aop.RunTurnResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id, turn_id, and input are required unless continue_session is true")} return response } - options := new(transport.RunOptions) + options := new(agentpb.RunOptions) for _, extension := range req.Extensions { - if extension.GetNamespace() == AIScanRunOptionsNamespace { - if err := aop.DecodeProtoJSON(extension.GetValue(), options); err != nil { + if extension != nil && extension.MessageIs(options) { + if err := extension.UnmarshalTo(options); err != nil { response.Outcome = &aop.RunTurnResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "invalid AIScan run options: "+err.Error())} return response } @@ -75,9 +74,6 @@ func (rt *AgentRuntime) RunAOPTurn(ctx context.Context, req *aop.RunTurnRequest) func (rt *AgentRuntime) CancelAOPTurn(req *aop.CancelTurnRequest) *aop.CancelTurnResponse { response := &aop.CancelTurnResponse{} - if req != nil { - response.RequestId = req.RequestId - } if rt == nil || req == nil || strings.TrimSpace(req.SessionId) == "" || strings.TrimSpace(req.TurnId) == "" { response.Outcome = &aop.CancelTurnResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id and turn_id are required")} return response @@ -92,9 +88,6 @@ func (rt *AgentRuntime) CancelAOPTurn(req *aop.CancelTurnRequest) *aop.CancelTur func (rt *AgentRuntime) CloseAOPSession(ctx context.Context, req *aop.CloseSessionRequest) *aop.CloseSessionResponse { response := &aop.CloseSessionResponse{} - if req != nil { - response.RequestId = req.RequestId - } if rt == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { response.Outcome = &aop.CloseSessionResponse_Rejected{Rejected: rejection("INVALID_ARGUMENT", "session_id is required")} return response @@ -107,53 +100,122 @@ func (rt *AgentRuntime) CloseAOPSession(ctx context.Context, req *aop.CloseSessi return response } -// HandleServerFrame is the generated-message control loop shared by stdio and -// other transports that host an AgentRuntime directly. -func (rt *AgentRuntime) HandleServerFrame(ctx context.Context, frame *transport.ServerFrame, send func(*transport.AgentFrame)) bool { - if rt == nil || frame == nil || send == nil { +var runtimeEnvelopeSequence atomic.Uint64 + +func runtimeEnvelopeID() string { + return "runtime:" + strconv.FormatInt(time.Now().UnixNano(), 36) + ":" + strconv.FormatUint(runtimeEnvelopeSequence.Add(1), 36) +} + +// HandleEnvelope is the protobuf control loop shared by stdio and other direct +// AgentRuntime hosts. The wire envelope is common; semantics remain in their +// AOP or AIScan namespace ProtocolMessage. +func (rt *AgentRuntime) HandleEnvelope(ctx context.Context, envelope *aop.Envelope, send func(*aop.Envelope)) bool { + if rt == nil || envelope == nil || send == nil { return false } - correlation := frame.CorrelationId - switch payload := frame.Payload.(type) { - case *transport.ServerFrame_OpenSession: - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_OpenSession{OpenSession: rt.OpenAOPSession(payload.OpenSession)}}) - case *transport.ServerFrame_RunTurn: - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_RunTurn{RunTurn: rt.RunAOPTurn(ctx, payload.RunTurn)}}) - case *transport.ServerFrame_CancelTurn: - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CancelTurn{CancelTurn: rt.CancelAOPTurn(payload.CancelTurn)}}) - case *transport.ServerFrame_CloseSession: - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CloseSession{CloseSession: rt.CloseAOPSession(ctx, payload.CloseSession)}}) - case *transport.ServerFrame_Command: - request := payload.Command - if request == nil || strings.TrimSpace(request.Line) == "" { - send(operationError(correlation, request.GetTaskId(), "command line is required")) - break + if rt.namespaceMux == nil { + send(runtimeReply(envelope.Id, runtimeProtocolError("NAMESPACE_INIT_FAILED", "runtime namespaces are not initialized"))) + return true + } + handled, err := rt.namespaceMux.Dispatch(ctx, envelope, func(value *aop.Envelope) error { send(value); return nil }) + if err != nil { + send(runtimeReply(envelope.Id, runtimeProtocolError("INVALID_PAYLOAD", err.Error()))) + return true + } + return handled +} + +func newRuntimeNamespaceMux(rt *AgentRuntime) (*aop.NamespaceMux, error) { + mux := aop.NewNamespaceMux() + if err := mux.Register(&aop.ProtocolMessage{}, rt.handleCoreNamespace); err != nil { + return nil, err + } + if err := mux.Register(&commandpb.ProtocolMessage{}, rt.handleCommandNamespace); err != nil { + return nil, err + } + return mux, nil +} + +func (rt *AgentRuntime) handleCoreNamespace(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, send aop.SendFunc) error { + value := message.(*aop.ProtocolMessage) + reply := func(message protobuf.Message) error { return send(runtimeReply(envelope.Id, message)) } + switch payload := value.Message.(type) { + case *aop.ProtocolMessage_OpenSessionRequest: + return reply(&aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionResponse{OpenSessionResponse: rt.OpenAOPSession(payload.OpenSessionRequest)}}) + case *aop.ProtocolMessage_RunTurnRequest: + return reply(&aop.ProtocolMessage{Message: &aop.ProtocolMessage_RunTurnResponse{RunTurnResponse: rt.RunAOPTurn(ctx, payload.RunTurnRequest)}}) + case *aop.ProtocolMessage_CancelTurnRequest: + return reply(&aop.ProtocolMessage{Message: &aop.ProtocolMessage_CancelTurnResponse{CancelTurnResponse: rt.CancelAOPTurn(payload.CancelTurnRequest)}}) + case *aop.ProtocolMessage_CloseSessionRequest: + return reply(&aop.ProtocolMessage{Message: &aop.ProtocolMessage_CloseSessionResponse{CloseSessionResponse: rt.CloseAOPSession(ctx, payload.CloseSessionRequest)}}) + default: + return fmt.Errorf("unsupported AOP core message") + } +} + +func (rt *AgentRuntime) handleCommandNamespace(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, send aop.SendFunc) error { + value := message.(*commandpb.ProtocolMessage) + reply := func(message protobuf.Message) error { return send(runtimeReply(envelope.Id, message)) } + request := value.GetRequest() + if request == nil || strings.TrimSpace(request.Line) == "" { + return reply(runtimeProtocolError("INVALID_ARGUMENT", "command line is required")) + } + rt.operations.Add(1) + go func() { + defer rt.operations.Done() + result, err := rt.CommandSession(ctx, request.SessionId, request.Line) + if err != nil { + _ = reply(runtimeProtocolError("COMMAND_FAILED", err.Error())) + return } - rt.operations.Add(1) - go func() { - defer rt.operations.Done() - result, err := rt.CommandSession(ctx, request.SessionId, request.Line) - if err != nil { - send(operationError(correlation, request.TaskId, err.Error())) - return - } - encoded, err := json.Marshal(result) - if err != nil { - send(operationError(correlation, request.TaskId, err.Error())) - return + encoded, err := json.Marshal(result) + if err != nil { + _ = reply(runtimeProtocolError("COMMAND_FAILED", err.Error())) + return + } + _ = reply(&commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Result{Result: &commandpb.Result{Data: encoded, MediaType: aop.JSONMediaType}}}) + }() + return nil +} + +// ServeEnvelopeStream is the framing-independent runtime loop. WebSocket and +// stdio decide only how an Envelope is read and written; protobuf dispatch and +// reply correlation stay here. +func (rt *AgentRuntime) ServeEnvelopeStream(ctx context.Context, stream aop.EnvelopeStream) error { + if rt == nil || stream == nil { + return fmt.Errorf("runtime envelope stream is required") + } + for { + envelope, err := stream.Recv() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + handled := rt.HandleEnvelope(ctx, envelope, func(response *aop.Envelope) { + _ = stream.Send(response) + }) + if !handled { + if err := stream.Send(runtimeReply(envelope.GetId(), runtimeProtocolError("UNSUPPORTED_MESSAGE", "unsupported protocol message"))); err != nil { + return err } - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CommandResult{CommandResult: &transport.CommandResult{TaskId: request.TaskId, Result: encoded, MediaType: "application/json"}}}) - }() - default: - return false + } } - return true } func rejection(code, message string) *aop.Rejection { return &aop.Rejection{Code: code, Message: message} } -func operationError(correlation, taskID, message string) *transport.AgentFrame { - return &transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_OperationError{OperationError: &transport.OperationError{TaskId: taskID, Code: "INVALID_ARGUMENT", Message: message}}} +func runtimeReply(replyTo string, message protobuf.Message) *aop.Envelope { + envelope, err := aop.Wrap(runtimeEnvelopeID(), replyTo, message) + if err != nil { + panic(fmt.Sprintf("wrap runtime protocol message: %v", err)) + } + return envelope +} + +func runtimeProtocolError(code, message string) *aop.ProtocolMessage { + return &aop.ProtocolMessage{Message: &aop.ProtocolMessage_ProtocolError{ProtocolError: &aop.ProtocolError{Code: code, Message: message}}} } diff --git a/pkg/runner/runtime_protocol_test.go b/pkg/runner/runtime_protocol_test.go index 5ae492be..1da5eb98 100644 --- a/pkg/runner/runtime_protocol_test.go +++ b/pkg/runner/runtime_protocol_test.go @@ -7,65 +7,55 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + protobuf "google.golang.org/protobuf/proto" ) -func TestServerFrameErrorsKeepDistinctCorrelationIDs(t *testing.T) { +func handleRuntimeMessage(t *testing.T, rt *AgentRuntime, id string, message protobuf.Message) *aop.Envelope { + t.Helper() + request := aop.MustWrap(id, "", message) + var response *aop.Envelope + if !rt.HandleEnvelope(context.Background(), request, func(envelope *aop.Envelope) { response = envelope }) { + t.Fatal("message was not handled") + } + return response +} + +func TestEnvelopeErrorsKeepDistinctReplyIDs(t *testing.T) { rt := newBareRuntime(t, nil, nil) - var runResponse *transport.AgentFrame - if !rt.HandleServerFrame(context.Background(), &transport.ServerFrame{ - CorrelationId: "turn-correlation", - Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{RequestId: "run-1"}}, - }, func(frame *transport.AgentFrame) { runResponse = frame }) { - t.Fatal("run frame was not handled") - } - if runResponse.CorrelationId != "turn-correlation" || runResponse.GetRunTurn().GetRejected() == nil { + runResponse := handleRuntimeMessage(t, rt, "turn-correlation", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_RunTurnRequest{RunTurnRequest: &aop.RunTurnRequest{}}}) + runMessage, _ := aop.Unwrap(runResponse) + if runResponse.ReplyTo != "turn-correlation" || runMessage.(*aop.ProtocolMessage).GetRunTurnResponse().GetRejected() == nil { t.Fatalf("run response = %+v", runResponse) } - var commandResponse *transport.AgentFrame - if !rt.HandleServerFrame(context.Background(), &transport.ServerFrame{ - CorrelationId: "command-correlation", - Payload: &transport.ServerFrame_Command{Command: &transport.CommandRequest{ - TaskId: "command-1", - }}, - }, func(frame *transport.AgentFrame) { commandResponse = frame }) { - t.Fatal("command frame was not handled") - } - if commandResponse.CorrelationId != "command-correlation" || commandResponse.GetOperationError().GetTaskId() != "command-1" { + commandResponse := handleRuntimeMessage(t, rt, "command-correlation", &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Request{Request: &commandpb.Request{}}}) + commandMessage, _ := aop.Unwrap(commandResponse) + if commandResponse.ReplyTo != "command-correlation" || commandMessage.(*aop.ProtocolMessage).GetProtocolError() == nil { t.Fatalf("command response = %+v", commandResponse) } } -func TestServerFrameRequiresTurnID(t *testing.T) { +func TestEnvelopeRequiresTurnID(t *testing.T) { rt := newBareRuntime(t, nil, nil) - var response *transport.AgentFrame - rt.HandleServerFrame(context.Background(), &transport.ServerFrame{ - Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{ - RequestId: "run-1", SessionId: "session-1", Input: &aop.Message{Role: "user"}, - }}, - }, func(frame *transport.AgentFrame) { response = frame }) - rejected := response.GetRunTurn().GetRejected() + response := handleRuntimeMessage(t, rt, "run-1", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_RunTurnRequest{RunTurnRequest: &aop.RunTurnRequest{ + SessionId: "session-1", Input: &aop.Message{Role: "user"}, + }}}) + message, _ := aop.Unwrap(response) + rejected := message.(*aop.ProtocolMessage).GetRunTurnResponse().GetRejected() if rejected == nil || !strings.Contains(rejected.Message, "turn_id") { t.Fatalf("response = %+v", response) } } -func TestServerFrameSessionOpenIsIdempotent(t *testing.T) { +func TestEnvelopeSessionOpenIsIdempotent(t *testing.T) { rt := newBareRuntime(t, nil, nil) - request := &transport.ServerFrame{ - CorrelationId: "open-1", - Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ - RequestId: "open-1", SessionId: "session-1", - }}, - } + message := &aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionRequest{OpenSessionRequest: &aop.OpenSessionRequest{SessionId: "session-1"}}} for i := 0; i < 2; i++ { - var response *transport.AgentFrame - if !rt.HandleServerFrame(context.Background(), request, func(frame *transport.AgentFrame) { response = frame }) { - t.Fatal("session open was not handled") - } - if response.GetOpenSession().GetAccepted().GetId() != "session-1" { + response := handleRuntimeMessage(t, rt, "open-1", message) + decoded, _ := aop.Unwrap(response) + if decoded.(*aop.ProtocolMessage).GetOpenSessionResponse().GetAccepted().GetId() != "session-1" { t.Fatalf("open %d response = %+v", i, response) } } diff --git a/pkg/runner/runtime_semantics_test.go b/pkg/runner/runtime_semantics_test.go index 7dad8864..71f7db45 100644 --- a/pkg/runner/runtime_semantics_test.go +++ b/pkg/runner/runtime_semantics_test.go @@ -8,13 +8,13 @@ import ( "testing" "time" - "github.com/chainreactors/aiscan/agent" "github.com/chainreactors/aiscan/agent/inbox" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/capability" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "google.golang.org/protobuf/proto" ) @@ -23,12 +23,12 @@ type runtimeSemanticProvider struct { calls int started chan struct{} release chan struct{} - usage *agent.Usage + usage *aop.TokenUsage } func (p *runtimeSemanticProvider) Name() string { return "runtime-semantic" } -func (p *runtimeSemanticProvider) ChatCompletion(ctx context.Context, _ *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) { +func (p *runtimeSemanticProvider) ChatCompletion(ctx context.Context, _ *provider.ChatCompletionRequest) (*provider.ChatCompletionResponse, error) { p.mu.Lock() p.calls++ call := p.calls @@ -41,8 +41,8 @@ func (p *runtimeSemanticProvider) ChatCompletion(ctx context.Context, _ *agent.C return nil, ctx.Err() } } - return &agent.ChatCompletionResponse{ - Choices: []agent.Choice{{Message: agent.NewTextMessage("assistant", "done")}}, + return &provider.ChatCompletionResponse{ + Choices: []provider.Choice{{Message: provider.TextMessage("assistant", "done")}}, Usage: p.usage, }, nil } @@ -114,7 +114,7 @@ func TestRunAOPTurnPreservesClientMessageIdentity(t *testing.T) { unsubscribe := rt.Subscribe(func(event *aop.Event) { events <- proto.Clone(event).(*aop.Event) }) defer unsubscribe() - opened := rt.OpenAOPSession(&aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1"}) + opened := rt.OpenAOPSession(&aop.OpenSessionRequest{SessionId: "session-1"}) if opened.GetAccepted() == nil { t.Fatalf("OpenAOPSession = %v", opened) } @@ -123,7 +123,7 @@ func TestRunAOPTurnPreservesClientMessageIdentity(t *testing.T) { Content: []*aop.Content{aop.Text("preserve my identity")}, } run := rt.RunAOPTurn(context.Background(), &aop.RunTurnRequest{ - RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", Input: input, + SessionId: "session-1", TurnId: "turn-1", Input: input, }) if run.GetAccepted() == nil { t.Fatalf("RunAOPTurn = %v", run) @@ -150,10 +150,7 @@ func TestRunAOPTurnPreservesClientMessageIdentity(t *testing.T) { } func TestConsoleRuntimeAdapterPreservesTotalContextTokens(t *testing.T) { - provider := &runtimeSemanticProvider{usage: &agent.Usage{ - PromptTokens: 8192, - TotalTokens: 8200, - }} + provider := &runtimeSemanticProvider{usage: provider.TokenUsage(8192, 0, 8200, 0, 0)} rt := newBareRuntime(t, nil, provider) session, err := rt.OpenSession(context.Background(), SessionOptions{ID: "session-1"}) if err != nil { diff --git a/pkg/runner/runtime_session.go b/pkg/runner/runtime_session.go index b7b532a8..e3c8b7a4 100644 --- a/pkg/runner/runtime_session.go +++ b/pkg/runner/runtime_session.go @@ -13,11 +13,12 @@ import ( "github.com/chainreactors/aiscan/agent/evaluator" inboxpkg "github.com/chainreactors/aiscan/agent/inbox" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" + toolpkg "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/tui" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "github.com/chainreactors/aiscan/skills" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" @@ -30,7 +31,7 @@ type SessionOptions struct { ParentSessionID string ParentToolCallID string AgentName string - Messages []agent.ChatMessage + Messages []*aop.Message } type SessionCloseReason string @@ -56,7 +57,7 @@ type RunInput struct { type RunResult struct { Output string Stop agent.StopReason - Usage agent.Usage + Usage *aop.TokenUsage ContextTokens int } @@ -165,7 +166,7 @@ func (e *turnEmitter) start() { } func (e *turnEmitter) end(result RunResult, runErr error) { - ended := &aop.TurnEnded{StopReason: string(result.Stop), Usage: runtimeUsageData(result.Usage), ContextTokens: uint64(max(result.ContextTokens, 0))} + ended := &aop.TurnEnded{StopReason: string(result.Stop), Usage: result.Usage, ContextTokens: uint64(max(result.ContextTokens, 0))} if runErr != nil { ended.Error = &aop.ProtocolError{Message: runErr.Error()} } @@ -277,7 +278,7 @@ func (s *commandSession) executeBash(ctx context.Context, line, command string) if err != nil { return commandOutcome{err: err} } - return commandText(line, CommandPresentationPreformatted, strings.TrimRight(result.Text(), " \t\r\n")) + return commandText(line, CommandPresentationPreformatted, strings.TrimRight(toolpkg.ResultText(result), " \t\r\n")) } func commandText(line, presentation, text string) commandOutcome { @@ -633,7 +634,7 @@ func (s *Session) ID() string { } -func (s *Session) MessagesSnapshot() []agent.ChatMessage { +func (s *Session) MessagesSnapshot() []*aop.Message { if s == nil || s.state == nil { return nil } @@ -735,16 +736,15 @@ func (s *sessionState) executeRun(ctx context.Context, turnID string, input RunI if len(message.Content) == 1 && message.Content[0].GetText() != nil { message.Content[0].GetText().Text = skills.ExpandCommand(message.Content[0].GetText().Text, s.runtime.app.Skills) } - agentInput := agent.InputFromAOPMessage(message) if input.EvalCriteria != "" { provider, model, logger := s.runtime.providerSnapshot() - evalConfig := evaluator.NewLoopConfigWithInput(provider, model, logger, agentInput, input.EvalCriteria, input.EvalMaxRounds) + evalConfig := evaluator.NewLoopConfigWithInput(provider, model, logger, message, input.EvalCriteria, input.EvalMaxRounds) evalConfig.TurnID = turnID result, _, err := evaluator.RunWithEval(ctx, s.agent, evalConfig, agent.WithTurnID(turnID), agent.WithRunMaxTurns(input.MaxTurns)) return result, err } - return s.agent.Run(ctx, agentInput, agent.WithTurnID(turnID), agent.WithRunMaxTurns(input.MaxTurns)) + return s.agent.Run(ctx, message, agent.WithTurnID(turnID), agent.WithRunMaxTurns(input.MaxTurns)) } func runInputContent(input RunInput) []*aop.Content { @@ -896,16 +896,6 @@ func (rt *AgentRuntime) providerSnapshot() (agent.Provider, string, telemetry.Lo return rt.config.Provider, rt.config.Model, rt.config.Logger } -func runtimeUsageData(usage agent.Usage) *aop.TokenUsage { - if usage == (agent.Usage{}) { - return nil - } - return &aop.TokenUsage{ - InputTokens: uint64(max(usage.PromptTokens, 0)), OutputTokens: uint64(max(usage.CompletionTokens, 0)), TotalTokens: uint64(max(usage.TotalTokens, 0)), - Detail: map[string]uint64{"cache_read": uint64(max(usage.CacheReadTokens, 0)), "cache_write": uint64(max(usage.CacheWriteTokens, 0))}, - } -} - func (rt *AgentRuntime) consoleAppInfo() tui.AppInfo { rt.mu.RLock() defer rt.mu.RUnlock() diff --git a/pkg/runner/runtime_session_isolation_test.go b/pkg/runner/runtime_session_isolation_test.go index fbf50472..a8bc6d69 100644 --- a/pkg/runner/runtime_session_isolation_test.go +++ b/pkg/runner/runtime_session_isolation_test.go @@ -31,6 +31,11 @@ func newBareRuntime(t *testing.T, reg *commands.CommandRegistry, provider agent. bus: publicBus, kernelBus: kernelBus, sessionEvents: events, config: agent.Config{Provider: provider, Tools: reg, Bus: kernelBus, Logger: telemetry.NopLogger()}, } + mux, err := newRuntimeNamespaceMux(rt) + if err != nil { + t.Fatal(err) + } + rt.namespaceMux = mux t.Cleanup(rt.Close) return rt } diff --git a/pkg/runner/stdio.go b/pkg/runner/stdio.go index e0d2b7df..26c96757 100644 --- a/pkg/runner/stdio.go +++ b/pkg/runner/stdio.go @@ -9,13 +9,12 @@ import ( "sync" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" "google.golang.org/protobuf/encoding/protojson" ) -// RunStdio carries generated ServerFrame/AgentFrame messages as protobuf JSONL. +// RunStdio carries AOP Envelope messages as protobuf JSONL. func RunStdio(ctx context.Context, option *cfg.Option, logger telemetry.Logger, input io.Reader, output io.Writer) error { host := newStdioHost(ctx, option, logger, output) if err := host.init(); err != nil { @@ -23,20 +22,54 @@ func RunStdio(ctx context.Context, option *cfg.Option, logger telemetry.Logger, } defer host.close() + stream := newStdioEnvelopeStream(input, host.emit) + err := host.rt.ServeEnvelopeStream(ctx, stream) + host.drain() + if writeErr := host.err(); writeErr != nil { + return writeErr + } + if err != nil { + return fmt.Errorf("stdio protocol: %w", err) + } + return nil +} + +// stdioEnvelopeStream is the stdio framing adapter: one protobuf-JSON Envelope +// per line. It owns no runtime or namespace semantics. +type stdioEnvelopeStream struct { + scanner *bufio.Scanner + send func(*aop.Envelope) error +} + +func newStdioEnvelopeStream(input io.Reader, send func(*aop.Envelope) error) *stdioEnvelopeStream { scanner := bufio.NewScanner(input) scanner.Buffer(make([]byte, 0, 1<<20), 64<<20) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + return &stdioEnvelopeStream{scanner: scanner, send: send} +} + +func (s *stdioEnvelopeStream) Recv() (*aop.Envelope, error) { + for s.scanner.Scan() { + line := strings.TrimSpace(s.scanner.Text()) if line == "" { continue } - host.accept(line) + envelope := new(aop.Envelope) + if err := protojson.Unmarshal([]byte(line), envelope); err != nil { + return nil, fmt.Errorf("decode stdio envelope: %w", err) + } + return envelope, nil } - if err := scanner.Err(); err != nil { - host.emitError("", fmt.Errorf("read stdin: %w", err)) + if err := s.scanner.Err(); err != nil { + return nil, fmt.Errorf("read stdin: %w", err) } - host.drain() - return host.err() + return nil, io.EOF +} + +func (s *stdioEnvelopeStream) Send(envelope *aop.Envelope) error { + if s.send == nil { + return fmt.Errorf("stdio envelope sender is unavailable") + } + return s.send(envelope) } type stdioHost struct { @@ -64,7 +97,10 @@ func (h *stdioHost) init() error { } h.rt = rt rt.Subscribe(func(event *aop.Event) { - _ = h.emit(&transport.AgentFrame{CorrelationId: event.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) + envelope, err := aop.Wrap(runtimeEnvelopeID(), "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: event}}) + if err == nil { + _ = h.emit(envelope) + } }) return nil } @@ -75,7 +111,7 @@ func (h *stdioHost) close() { } } -func (h *stdioHost) emit(message *transport.AgentFrame) error { +func (h *stdioHost) emit(message *aop.Envelope) error { h.encMu.Lock() defer h.encMu.Unlock() if h.encErr != nil { @@ -94,7 +130,7 @@ func (h *stdioHost) emit(message *transport.AgentFrame) error { } func (h *stdioHost) emitError(correlationID string, err error) { - _ = h.emit(operationError(correlationID, correlationID, err.Error())) + _ = h.emit(runtimeReply(correlationID, runtimeProtocolError("STDIO_PROTOCOL_ERROR", err.Error()))) } func (h *stdioHost) err() error { @@ -107,13 +143,13 @@ func (h *stdioHost) err() error { } func (h *stdioHost) accept(line string) { - message := new(transport.ServerFrame) + message := new(aop.Envelope) if err := protojson.Unmarshal([]byte(line), message); err != nil { h.emitError("", fmt.Errorf("decode frame: %w", err)) return } - if h.rt == nil || !h.rt.HandleServerFrame(h.ctx, message, func(response *transport.AgentFrame) { _ = h.emit(response) }) { - h.emitError(message.CorrelationId, fmt.Errorf("unsupported server frame")) + if h.rt == nil || !h.rt.HandleEnvelope(h.ctx, message, func(response *aop.Envelope) { _ = h.emit(response) }) { + h.emitError(message.Id, fmt.Errorf("unsupported protocol message")) } } diff --git a/pkg/runner/stdio_concurrency_test.go b/pkg/runner/stdio_concurrency_test.go index 93489961..d8a0570d 100644 --- a/pkg/runner/stdio_concurrency_test.go +++ b/pkg/runner/stdio_concurrency_test.go @@ -8,8 +8,8 @@ import ( "time" "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" ) // stdioGateProvider blocks every call until the gate closes, recording the @@ -27,7 +27,7 @@ func newStdioGateProvider() *stdioGateProvider { func (p *stdioGateProvider) Name() string { return "stdio-gate" } -func (p *stdioGateProvider) ChatCompletion(ctx context.Context, req *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) { +func (p *stdioGateProvider) ChatCompletion(ctx context.Context, req *provider.ChatCompletionRequest) (*provider.ChatCompletionResponse, error) { p.mu.Lock() p.prompts = append(p.prompts, lastUserText(req.Messages)) p.mu.Unlock() @@ -36,8 +36,8 @@ func (p *stdioGateProvider) ChatCompletion(ctx context.Context, req *agent.ChatC case <-ctx.Done(): return nil, ctx.Err() } - return &agent.ChatCompletionResponse{ - Choices: []agent.Choice{{Message: agent.NewTextMessage("assistant", "done")}}, + return &provider.ChatCompletionResponse{ + Choices: []provider.Choice{{Message: provider.TextMessage("assistant", "done")}}, }, nil } @@ -53,10 +53,10 @@ func (p *stdioGateProvider) promptsSnapshot() []string { return append([]string(nil), p.prompts...) } -func lastUserText(messages []agent.ChatMessage) string { +func lastUserText(messages []*aop.Message) string { for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "user" && messages[i].Content != nil { - return *messages[i].Content + if messages[i].Role == "user" { + return provider.MessageText(messages[i]) } } return "" @@ -78,7 +78,7 @@ func newRuntimeStdioHost(t *testing.T, output *bytes.Buffer, prov agent.Provider h.rt.config.Model = "test" h.rt.config.MaxTurns = 4 h.rt.Subscribe(func(event *aop.Event) { - _ = h.emit(&transport.AgentFrame{CorrelationId: event.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) + _ = h.emit(aop.MustWrap(runtimeEnvelopeID(), "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: event}})) }) return h } @@ -136,7 +136,7 @@ func TestStdioSessionsRunConcurrently(t *testing.T) { // Interleaved output must stay valid AOP: every line decodes, and both // sessions produced their session brackets. - events := decodeAOPMessages(decodeAgentFrames(t, &output)) + events := decodeAOPMessages(decodeEnvelopes(t, &output)) starts := map[string]bool{} ends := map[string]bool{} for _, e := range events { diff --git a/pkg/runner/stdio_test.go b/pkg/runner/stdio_test.go index 91d91a32..d85f2e2e 100644 --- a/pkg/runner/stdio_test.go +++ b/pkg/runner/stdio_test.go @@ -10,18 +10,19 @@ import ( "testing" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/telemetry" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" "google.golang.org/protobuf/encoding/protojson" + protobuf "google.golang.org/protobuf/proto" ) func newTestStdioHost(output io.Writer) *stdioHost { return newStdioHost(context.Background(), nil, telemetry.NopLogger(), output) } -func protocolLine(t *testing.T, frame *transport.ServerFrame) string { +func protocolLine(t *testing.T, id string, message protobuf.Message) string { t.Helper() - data, err := protojson.Marshal(frame) + data, err := protojson.Marshal(aop.MustWrap(id, "", message)) if err != nil { t.Fatal(err) } @@ -29,53 +30,44 @@ func protocolLine(t *testing.T, frame *transport.ServerFrame) string { } func openSessionLine(t *testing.T, sessionID string) string { - return protocolLine(t, &transport.ServerFrame{ - CorrelationId: "open-" + sessionID, - Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ - RequestId: "open-" + sessionID, SessionId: sessionID, - }}, - }) + id := "open-" + sessionID + return protocolLine(t, id, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionRequest{OpenSessionRequest: &aop.OpenSessionRequest{ + SessionId: sessionID, + }}}) } func runLine(t *testing.T, sessionID, turnID, text string) string { - return protocolLine(t, &transport.ServerFrame{ - CorrelationId: turnID, - Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{ - RequestId: turnID, SessionId: sessionID, TurnId: turnID, - Input: &aop.Message{Id: "input-" + turnID, Role: "user", Content: []*aop.Content{aop.Text(text)}}, - }}, - }) + return protocolLine(t, turnID, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_RunTurnRequest{RunTurnRequest: &aop.RunTurnRequest{ + SessionId: sessionID, TurnId: turnID, + Input: &aop.Message{Id: "input-" + turnID, Role: "user", Content: []*aop.Content{aop.Text(text)}}, + }}}) } func closeSessionLine(t *testing.T, sessionID, reason string) string { - return protocolLine(t, &transport.ServerFrame{ - CorrelationId: "close-" + sessionID, - Payload: &transport.ServerFrame_CloseSession{CloseSession: &aop.CloseSessionRequest{ - RequestId: "close-" + sessionID, SessionId: sessionID, Reason: reason, - }}, - }) + id := "close-" + sessionID + return protocolLine(t, id, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CloseSessionRequest{CloseSessionRequest: &aop.CloseSessionRequest{ + SessionId: sessionID, Reason: reason, + }}}) } func TestStdioAcceptRejectsMalformedJSON(t *testing.T) { var output bytes.Buffer h := newTestStdioHost(&output) h.accept("not json") - frames := decodeAgentFrames(t, &output) - if len(frames) != 1 || frames[0].GetOperationError() == nil { - t.Fatalf("frames = %#v", frames) - } - if !strings.Contains(frames[0].GetOperationError().Message, "decode frame") { - t.Fatalf("error = %+v", frames[0].GetOperationError()) + envelopes := decodeEnvelopes(t, &output) + message := unwrapCore(t, envelopes[0]) + if len(envelopes) != 1 || message.GetProtocolError() == nil || !strings.Contains(message.GetProtocolError().Message, "decode frame") { + t.Fatalf("envelopes = %#v", envelopes) } } func TestStdioAcceptRejectsUnsupportedFrame(t *testing.T) { var output bytes.Buffer h := newTestStdioHost(&output) - h.accept(protocolLine(t, &transport.ServerFrame{CorrelationId: "future"})) - frames := decodeAgentFrames(t, &output) - if len(frames) != 1 || frames[0].GetOperationError() == nil || frames[0].CorrelationId != "future" { - t.Fatalf("frames = %#v", frames) + h.accept(protocolLine(t, "future", &aop.ProtocolMessage{})) + envelopes := decodeEnvelopes(t, &output) + if len(envelopes) != 1 || unwrapCore(t, envelopes[0]).GetProtocolError() == nil || envelopes[0].ReplyTo != "future" { + t.Fatalf("envelopes = %#v", envelopes) } } @@ -84,9 +76,9 @@ func TestStdioRunRequiresOpenSession(t *testing.T) { h := newRuntimeStdioHost(t, &output, nil) defer h.rt.Close() h.accept(runLine(t, "s1", "turn-1", "hello")) - frames := decodeAgentFrames(t, &output) - if len(frames) != 1 || frames[0].GetRunTurn().GetRejected() == nil { - t.Fatalf("frames = %#v", frames) + envelopes := decodeEnvelopes(t, &output) + if len(envelopes) != 1 || unwrapCore(t, envelopes[0]).GetRunTurnResponse().GetRejected() == nil { + t.Fatalf("envelopes = %#v", envelopes) } } @@ -97,9 +89,18 @@ func TestStdioRunRejectsEmptyPrompt(t *testing.T) { h.accept(openSessionLine(t, "s1")) h.accept(runLine(t, "s1", "turn-1", " ")) h.drain() - frames := decodeAgentFrames(t, &output) - if frames[len(frames)-1].GetRunTurn().GetRejected() == nil { - t.Fatalf("frames = %#v", frames) + envelopes := decodeEnvelopes(t, &output) + var rejected bool + for _, envelope := range envelopes { + message, err := aop.Unwrap(envelope) + if err == nil { + if core, ok := message.(*aop.ProtocolMessage); ok && core.GetRunTurnResponse().GetRejected() != nil { + rejected = true + } + } + } + if !rejected { + t.Fatalf("envelopes = %#v", envelopes) } } @@ -108,19 +109,18 @@ func TestStdioCommandUsesIndependentCorrelationID(t *testing.T) { h := newRuntimeStdioHost(t, &output, nil) defer h.rt.Close() h.accept(openSessionLine(t, "s1")) - h.accept(protocolLine(t, &transport.ServerFrame{ - CorrelationId: "command-correlation", - Payload: &transport.ServerFrame_Command{Command: &transport.CommandRequest{ - TaskId: "command-1", SessionId: "s1", Line: "/help", - }}, - })) + h.accept(protocolLine(t, "command-correlation", &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Request{Request: &commandpb.Request{ + SessionId: "s1", Line: "/help", + }}})) h.drain() - for _, frame := range decodeAgentFrames(t, &output) { - if frame.GetCommandResult() == nil { + for _, envelope := range decodeEnvelopes(t, &output) { + message, err := aop.Unwrap(envelope) + command, ok := message.(*commandpb.ProtocolMessage) + if err != nil || !ok || command.GetResult() == nil { continue } - if frame.CorrelationId != "command-correlation" || frame.GetCommandResult().TaskId != "command-1" { - t.Fatalf("command result correlation = %+v", frame) + if envelope.ReplyTo != "command-correlation" { + t.Fatalf("command result correlation = %+v", envelope) } return } @@ -140,28 +140,45 @@ func TestStdioDrainWithoutRuns(t *testing.T) { newTestStdioHost(&output).drain() } -func decodeAgentFrames(t *testing.T, input *bytes.Buffer) []*transport.AgentFrame { +func decodeEnvelopes(t *testing.T, input *bytes.Buffer) []*aop.Envelope { t.Helper() - var frames []*transport.AgentFrame + var envelopes []*aop.Envelope scanner := bufio.NewScanner(bytes.NewReader(input.Bytes())) for scanner.Scan() { - frame := new(transport.AgentFrame) - if err := protojson.Unmarshal(scanner.Bytes(), frame); err != nil { + envelope := new(aop.Envelope) + if err := protojson.Unmarshal(scanner.Bytes(), envelope); err != nil { t.Fatal(err) } - frames = append(frames, frame) + envelopes = append(envelopes, envelope) } if err := scanner.Err(); err != nil { t.Fatal(err) } - return frames + return envelopes +} + +func unwrapCore(t *testing.T, envelope *aop.Envelope) *aop.ProtocolMessage { + t.Helper() + message, err := aop.Unwrap(envelope) + if err != nil { + t.Fatal(err) + } + core, ok := message.(*aop.ProtocolMessage) + if !ok { + t.Fatalf("message = %T", message) + } + return core } -func decodeAOPMessages(frames []*transport.AgentFrame) []*aop.Event { +func decodeAOPMessages(envelopes []*aop.Envelope) []*aop.Event { var events []*aop.Event - for _, frame := range frames { - if event := frame.GetEvent(); event != nil { - events = append(events, event) + for _, envelope := range envelopes { + message, err := aop.Unwrap(envelope) + if err != nil { + continue + } + if core, ok := message.(*aop.ProtocolMessage); ok && core.GetEvent() != nil { + events = append(events, core.GetEvent()) } } return events diff --git a/pkg/runner/subagent_handoff.go b/pkg/runner/subagent_handoff.go index 7263b159..c93c40aa 100644 --- a/pkg/runner/subagent_handoff.go +++ b/pkg/runner/subagent_handoff.go @@ -10,9 +10,9 @@ import ( "github.com/chainreactors/aiscan/agent" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/telemetry" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "github.com/chainreactors/ioa/protocols" ) diff --git a/pkg/runner/subagent_handoff_test.go b/pkg/runner/subagent_handoff_test.go index 27f61cc7..480b9de0 100644 --- a/pkg/runner/subagent_handoff_test.go +++ b/pkg/runner/subagent_handoff_test.go @@ -7,8 +7,8 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" "github.com/chainreactors/aiscan/core/eventbus" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "github.com/chainreactors/ioa/protocols" ) diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index 329b1d22..8a0c14ae 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -7,10 +7,10 @@ import ( "strings" "github.com/chainreactors/aiscan/agent" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" "github.com/chainreactors/aiscan/skills" ) @@ -217,14 +217,14 @@ func redactURLUserinfoFallback(raw string) string { // WebMenuSpecs extracts the web-visible command metadata from a Command list. // Run-control commands (/stop, /followup, /eval, /loop, /exit) are excluded // because the web expresses those through UI controls, not slash text. -func WebMenuSpecs(cmds []Command) []*transport.CommandSpec { +func WebMenuSpecs(cmds []Command) []*commandpb.Spec { hidden := map[string]bool{"/stop": true, "/continue": true, "/followup": true, "/eval": true, "/loop": true, "/exit": true} - var specs []*transport.CommandSpec + var specs []*commandpb.Spec for _, c := range cmds { if c.Hidden || hidden[c.Name] { continue } - specs = append(specs, &transport.CommandSpec{ + specs = append(specs, &commandpb.Spec{ Name: c.Name, Aliases: c.Aliases, Description: c.Description, diff --git a/pkg/tui/console.go b/pkg/tui/console.go index f304f35c..c599cecc 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -21,6 +21,7 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" outputpkg "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" + coretool "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/tui/console" @@ -1512,7 +1513,7 @@ func (r *AgentConsole) executeBashDirect(ctx context.Context, cmdLine string) er if err != nil { return err } - if text := result.Text(); text != "" { + if text := coretool.ResultText(result); text != "" { fmt.Fprint(r.stdout, text) if !strings.HasSuffix(text, "\n") { fmt.Fprintln(r.stdout) diff --git a/pkg/tui/console_test.go b/pkg/tui/console_test.go index a5ba5c25..534059b2 100644 --- a/pkg/tui/console_test.go +++ b/pkg/tui/console_test.go @@ -14,11 +14,14 @@ import ( "time" "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" + aop "github.com/chainreactors/aiscan/aop" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/tui/readline/inputrc" rlterm "github.com/chainreactors/tui/readline/terminal" + "google.golang.org/protobuf/encoding/protojson" ) func TestIsLocalAgentTerminal(t *testing.T) { @@ -66,11 +69,11 @@ func (p *captureConsoleProvider) Name() string { return "capture" } func (p *captureConsoleProvider) ChatCompletion(_ context.Context, req *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) { cp := *req - cp.Messages = append([]agent.ChatMessage(nil), req.Messages...) + cp.Messages = append([]*aop.Message(nil), req.Messages...) p.requests = append(p.requests, &cp) return &agent.ChatCompletionResponse{ Choices: []agent.Choice{{ - Message: agent.NewTextMessage("assistant", "ok"), + Message: agent.TextMessage("assistant", "ok"), }}, }, nil } @@ -90,10 +93,10 @@ type consoleTextTool struct { output string } -func (t *consoleTextTool) Name() string { return "bash" } -func (t *consoleTextTool) Description() string { return "console output test tool" } -func (t *consoleTextTool) Definition() tool.Definition { return tool.Definition{} } -func (t *consoleTextTool) Execute(context.Context, string) (tool.Result, error) { +func (t *consoleTextTool) Name() string { return "bash" } +func (t *consoleTextTool) Description() string { return "console output test tool" } +func (t *consoleTextTool) Definition() *tool.Definition { return &tool.Definition{} } +func (t *consoleTextTool) Execute(context.Context, string) (*tool.Result, error) { return tool.TextResult(t.output), nil } @@ -299,9 +302,9 @@ func TestAgentConsoleResumeLoadsSessionMessages(t *testing.T) { if err := agent.SaveSession(dir, &agent.SessionData{ Model: "test-model", Provider: "capture", - Messages: []agent.ChatMessage{ - agent.NewTextMessage("user", "previous user"), - agent.NewTextMessage("assistant", "previous assistant"), + Messages: []*aop.Message{ + agent.TextMessage("user", "previous user"), + agent.TextMessage("assistant", "previous assistant"), }, }); err != nil { t.Fatalf("SaveSession: %v", err) @@ -337,9 +340,7 @@ func TestAgentConsoleResumeLoadsSessionMessages(t *testing.T) { } var contents []string for _, msg := range prov.requests[0].Messages { - if msg.Content != nil { - contents = append(contents, *msg.Content) - } + contents = append(contents, provider.MessageText(msg)) } joined := strings.Join(contents, "\n") for _, want := range []string{"previous user", "previous assistant", "new prompt"} { @@ -384,11 +385,15 @@ func TestAgentConsoleResumeListsAndSelectsSession(t *testing.T) { func writeConsoleSession(t *testing.T, path, model, content string, updatedAt time.Time) { t.Helper() - raw, err := json.Marshal(agent.SessionData{ - Version: 1, - UpdatedAt: updatedAt, - Model: model, - Messages: []agent.ChatMessage{agent.NewTextMessage("user", content)}, + msgRaw, err := protojson.Marshal(agent.TextMessage("user", content)) + if err != nil { + t.Fatalf("marshal message: %v", err) + } + raw, err := json.Marshal(map[string]any{ + "version": 1, + "updated_at": updatedAt, + "model": model, + "messages": []json.RawMessage{msgRaw}, }) if err != nil { t.Fatalf("marshal session: %v", err) diff --git a/pkg/tui/controller_test.go b/pkg/tui/controller_test.go index 8717d7b9..b331c324 100644 --- a/pkg/tui/controller_test.go +++ b/pkg/tui/controller_test.go @@ -31,7 +31,7 @@ func (p *gateProvider) ChatCompletion(ctx context.Context, _ *agent.ChatCompleti } } return &agent.ChatCompletionResponse{ - Choices: []agent.Choice{{Message: agent.NewTextMessage("assistant", "done")}}, + Choices: []agent.Choice{{Message: agent.TextMessage("assistant", "done")}}, }, nil } diff --git a/pkg/tui/format.go b/pkg/tui/format.go index 1be36d52..ee4a89de 100644 --- a/pkg/tui/format.go +++ b/pkg/tui/format.go @@ -11,7 +11,7 @@ import ( "unicode" "unicode/utf8" - "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/core/util" @@ -214,14 +214,14 @@ const ( ) // formatTokenUsage formats token usage like: "↑2,378 ↓27 ↻95%". -func formatTokenUsage(u *agent.Usage) string { +func formatTokenUsage(u *aop.TokenUsage) string { if u == nil { return "" } s := fmt.Sprintf("%s%s %s%s", - inputTokenMarker, util.FormatNumber(u.PromptTokens), - outputTokenMarker, util.FormatNumber(u.CompletionTokens)) - if ratio := u.CacheHitRatio(); ratio > 0 { + inputTokenMarker, util.FormatNumber(int(u.InputTokens)), + outputTokenMarker, util.FormatNumber(int(u.OutputTokens))) + if ratio := provider.CacheHitRatio(u); ratio > 0 { s += fmt.Sprintf(" %s%.0f%%", cacheHitMarker, ratio*100) } return s diff --git a/pkg/tui/live.go b/pkg/tui/live.go index b981b08e..21304ab7 100644 --- a/pkg/tui/live.go +++ b/pkg/tui/live.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/chainreactors/aiscan/agent" + aop "github.com/chainreactors/aiscan/aop" "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/core/util" ) @@ -39,7 +39,7 @@ type LiveStatus struct { turn int turnToolCalls int - turnUsage *agent.Usage + turnUsage *aop.TokenUsage outputEstimate int contextTokens int contextWindow int @@ -128,11 +128,11 @@ func (l *LiveStatus) NoteDelta(textDelta bool) { } } -func (l *LiveStatus) SetTurnUsage(usage agent.Usage) { +func (l *LiveStatus) SetTurnUsage(usage *aop.TokenUsage) { if l == nil { return } - l.turnUsage = &usage + l.turnUsage = usage } func (l *LiveStatus) SetOutputEstimate(tokens int) { @@ -232,8 +232,8 @@ func (l *LiveStatus) FinishTurn(contextTokens int) { } if contextTokens > 0 { l.contextTokens = contextTokens - } else if l.turnUsage != nil && l.turnUsage.PromptTokens > 0 { - l.contextTokens = l.turnUsage.PromptTokens + } else if l.turnUsage != nil && l.turnUsage.InputTokens > 0 { + l.contextTokens = int(l.turnUsage.InputTokens) } l.turnUsage = nil } @@ -379,8 +379,8 @@ func (l *LiveStatus) formatTurnDetails() string { contextTokens := l.contextTokens if l.turnUsage != nil { parts = append(parts, formatTokenUsage(l.turnUsage)) - if l.turnUsage.PromptTokens > 0 { - contextTokens = l.turnUsage.PromptTokens + if l.turnUsage.InputTokens > 0 { + contextTokens = int(l.turnUsage.InputTokens) } } else if l.outputEstimate > 0 { parts = append(parts, outputTokenMarker+"≈"+util.FormatNumber(l.outputEstimate)) @@ -410,16 +410,6 @@ func (l *LiveStatus) ContextUsage(tokens int) string { formatUsagePercent(tokens, l.contextWindow)) } -func usageTotal(usage *agent.Usage) int { - if usage == nil { - return 0 - } - if usage.TotalTokens > 0 { - return usage.TotalTokens - } - return usage.PromptTokens + usage.CompletionTokens -} - func formatUsagePercent(used, total int) string { if used <= 0 || total <= 0 { return "0%" diff --git a/pkg/tui/output.go b/pkg/tui/output.go index 5159f6d4..6cc281cf 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -10,12 +10,13 @@ import ( "time" "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/truncate" "github.com/chainreactors/aiscan/core/util" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" "golang.org/x/term" ) @@ -63,8 +64,8 @@ type AgentOutput struct { deltas map[string]*deltaAccumulator lastAssistant *aop.Message hasAssistant bool - turnUsage *agent.Usage - totalUsage agent.Usage + turnUsage *aop.TokenUsage + totalUsage *aop.TokenUsage turnToolCalls int contextTokens int runCount int @@ -442,7 +443,7 @@ func (o *AgentOutput) HandleEvent(event *aop.Event) { o.runCount++ o.stream.NewTurn() o.turnUsage = nil - o.totalUsage = agent.Usage{} + o.totalUsage = nil o.turnToolCalls = 0 o.lastAssistant = nil o.hasAssistant = false @@ -581,20 +582,19 @@ func (o *AgentOutput) HandleEvent(event *aop.Event) { } case *aop.Event_Usage: - data := payload.Usage - usage := agent.Usage{ - PromptTokens: int(data.InputTokens), - CompletionTokens: int(data.OutputTokens), - TotalTokens: int(data.TotalTokens), - CacheReadTokens: int(data.Detail["cache_read"]), - CacheWriteTokens: int(data.Detail["cache_write"]), + usage := payload.Usage + o.turnUsage = usage + if o.totalUsage == nil { + o.totalUsage = &aop.TokenUsage{} } - o.turnUsage = &usage - o.totalUsage.PromptTokens += usage.PromptTokens - o.totalUsage.CompletionTokens += usage.CompletionTokens + o.totalUsage.InputTokens += usage.InputTokens + o.totalUsage.OutputTokens += usage.OutputTokens o.totalUsage.TotalTokens += usage.TotalTokens - o.totalUsage.CacheReadTokens += usage.CacheReadTokens - o.totalUsage.CacheWriteTokens += usage.CacheWriteTokens + if o.totalUsage.Detail == nil { + o.totalUsage.Detail = map[string]uint64{} + } + o.totalUsage.Detail["cache_read"] += usage.Detail["cache_read"] + o.totalUsage.Detail["cache_write"] += usage.Detail["cache_write"] if o.policy.Usage { o.live.SetTurnUsage(usage) } @@ -811,7 +811,7 @@ func (o *AgentOutput) beginRun() { o.lastAssistant = nil o.hasAssistant = false o.turnUsage = nil - o.totalUsage = agent.Usage{} + o.totalUsage = nil o.turnToolCalls = 0 o.contextTokens = 0 } @@ -868,14 +868,16 @@ func (o *AgentOutput) turnEnd(turn int) { } if o.turnUsage != nil { cache := "" - if o.turnUsage.CacheReadTokens > 0 || o.turnUsage.CacheWriteTokens > 0 { + cacheRead := o.turnUsage.Detail["cache_read"] + cacheWrite := o.turnUsage.Detail["cache_write"] + if cacheRead > 0 || cacheWrite > 0 { cache = fmt.Sprintf(" cache_read=%d cache_write=%d (%.0f%%)", - o.turnUsage.CacheReadTokens, o.turnUsage.CacheWriteTokens, - o.turnUsage.CacheHitRatio()*100) + cacheRead, cacheWrite, + provider.CacheHitRatio(o.turnUsage)*100) } fmt.Fprintf(w, "%s[debug] [turn %d] prompt=%d completion=%d total=%d context=%d%s%s\n", o.color.Code(output.ANSIDim), turn, - o.turnUsage.PromptTokens, o.turnUsage.CompletionTokens, o.turnUsage.TotalTokens, + o.turnUsage.InputTokens, o.turnUsage.OutputTokens, o.turnUsage.TotalTokens, o.contextTokens, cache, o.color.Code(output.ANSIReset)) } } @@ -896,8 +898,8 @@ func (o *AgentOutput) agentEnd(data *aop.TurnEnded) { } parts = append(parts, toolPart) } - if usageTotal(&o.totalUsage) > 0 { - parts = append(parts, formatTokenUsage(&o.totalUsage)) + if provider.UsageTotalTokens(o.totalUsage) > 0 { + parts = append(parts, formatTokenUsage(o.totalUsage)) } parts = append(parts, util.FormatDuration(elapsed)) if data.Error != nil { @@ -1078,9 +1080,6 @@ func flattenToolResult(content []*aop.Content) string { parts = append(parts, text) continue } - if opaque := part.GetOpaque(); opaque != nil { - parts = append(parts, string(opaque.Value.GetData())) - } } return strings.Join(parts, "\n") } diff --git a/pkg/tui/output_test.go b/pkg/tui/output_test.go index 2e9bd002..7d903754 100644 --- a/pkg/tui/output_test.go +++ b/pkg/tui/output_test.go @@ -10,10 +10,11 @@ import ( "time" "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) type syncedBuffer struct { @@ -125,11 +126,7 @@ func TestRenderAgentMarkdownPlainFallback(t *testing.T) { } func TestFormatTokenUsageUsesCompactMarkers(t *testing.T) { - got := formatTokenUsage(&agent.Usage{ - PromptTokens: 1832, - CompletionTokens: 63, - CacheReadTokens: 1026, - }) + got := formatTokenUsage(provider.TokenUsage(1832, 63, 0, 1026, 0)) if got != "↑1,832 ↓63 ↻56%" { t.Fatalf("formatTokenUsage() = %q", got) } From 2d91a53b155d67abe3ea67baa08bfa89c8c2fe5c Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sun, 2 Aug 2026 12:02:18 +0800 Subject: [PATCH 160/348] refactor(web): unify AOP transport and product ConnectRPC --- README_CN.md | 2 +- cmd/aiscan/cli.go | 16 +- cmd/aiscan/cli_test.go | 17 +- cmd/aiscan/setup.go | 2 +- cmd/aiscan/web_full.go | 169 +++-- cmd/aiscan/web_full_test.go | 29 +- core/config/agent_server.go | 63 ++ core/config/agent_server_test.go | 51 ++ core/config/distribute.go | 148 ++--- core/config/distribute_test.go | 30 +- core/config/loader.go | 1 + core/config/options.go | 21 +- core/config/remote.go | 7 - docs/agent-runtime-multipath-analysis.md | 19 +- docs/mechanisms.md | 23 +- docs/web-chat-api.md | 413 ------------ examples/aop-chat/client.go | 93 --- examples/external-go-client/go.mod | 21 - examples/external-go-client/go.sum | 38 -- examples/external-go-client/main.go | 164 ----- examples/web-chat/client.go | 303 --------- examples/web-chat/client_test.go | 129 ---- examples/web-chat/main.go | 66 -- pkg/transport/transport.go | 2 - pkg/web/agent/agent.go | 113 ++-- pkg/web/agent/agent_test.go | 4 +- pkg/web/agent/aop_tool.go | 8 +- pkg/web/agent/aop_tool_test.go | 48 +- pkg/web/agent/connection.go | 33 +- pkg/web/agent/connection_lifecycle_test.go | 82 --- pkg/web/agent/exec_test.go | 23 +- pkg/web/agent/file_test.go | 20 +- pkg/web/agent/identity.go | 47 +- pkg/web/agent/proto_connection.go | 626 +++++++++++------- pkg/web/agent/remote.go | 102 --- pkg/web/agent/remote_test.go | 46 -- pkg/web/agent/stream.go | 17 +- pkg/web/agent/toolnode.go | 25 +- pkg/web/agent/toolnode_test.go | 109 +-- pkg/web/agent/upload_test.go | 4 +- pkg/web/agent_connect.go | 55 ++ pkg/web/agent_stream.go | 165 ++--- pkg/web/agent_stream_handler.go | 299 ++++++--- pkg/web/agents.go | 615 +++++------------ pkg/web/agents_session_end_test.go | 14 +- pkg/web/agents_test.go | 533 ++++++++------- pkg/web/aop_chat.go | 490 ++++++++++++++ pkg/web/aop_endpoint.go | 37 ++ pkg/web/aop_grpc.go | 503 -------------- pkg/web/aop_transport_test.go | 217 +++--- pkg/web/aop_ws.go | 333 ++++++++++ pkg/web/broker.go | 2 +- pkg/web/broker_test.go | 37 +- pkg/web/command_test.go | 4 +- pkg/web/config_connect.go | 85 +++ pkg/web/config_profiles_test.go | 90 +-- pkg/web/config_reload_test.go | 129 ++-- pkg/web/config_transaction_test.go | 36 +- pkg/web/conn_probe_test.go | 73 +- pkg/web/connect.go | 74 +-- pkg/web/connect_protocol_test.go | 42 ++ pkg/web/connect_test.go | 268 -------- pkg/web/eval_forward_test.go | 22 +- pkg/web/grpc.go | 61 -- pkg/web/handler.go | 275 +------- pkg/web/handler_import.go | 81 --- pkg/web/ioa_auth.go | 33 + pkg/web/ioa_auth_test.go | 59 ++ pkg/web/ioa_console.go | 59 -- pkg/web/ioa_console_test.go | 60 -- pkg/web/llm_probe_test.go | 37 +- pkg/web/localagent.go | 104 +-- pkg/web/probe.go | 36 +- pkg/web/replay_test.go | 30 +- pkg/web/report.go | 238 ++++++- pkg/web/scan_connect.go | 8 +- pkg/web/scan_grpc.go | 44 -- pkg/web/scan_lifecycle_test.go | 237 +++---- pkg/web/scan_rpc.go | 146 ++-- pkg/web/sco_connect.go | 106 +++ pkg/web/service.go | 491 +++++++------- pkg/web/service_test.go | 201 +----- pkg/web/session_connect.go | 161 +---- pkg/web/store_sqlite.go | 736 +++++++-------------- pkg/web/store_sqlite_test.go | 270 ++------ pkg/web/system_connect.go | 29 + pkg/web/terminal/codec.go | 133 ++-- pkg/web/terminal/codec_test.go | 30 - pkg/web/types.go | 222 ++----- pkg/web/upload_test.go | 55 +- pkg/web/validation.go | 8 +- pkg/web/validation_test.go | 4 +- 92 files changed, 4561 insertions(+), 6650 deletions(-) create mode 100644 core/config/agent_server.go create mode 100644 core/config/agent_server_test.go delete mode 100644 core/config/remote.go delete mode 100644 docs/web-chat-api.md delete mode 100644 examples/aop-chat/client.go delete mode 100644 examples/external-go-client/go.mod delete mode 100644 examples/external-go-client/go.sum delete mode 100644 examples/external-go-client/main.go delete mode 100644 examples/web-chat/client.go delete mode 100644 examples/web-chat/client_test.go delete mode 100644 examples/web-chat/main.go delete mode 100644 pkg/web/agent/connection_lifecycle_test.go delete mode 100644 pkg/web/agent/remote.go delete mode 100644 pkg/web/agent/remote_test.go create mode 100644 pkg/web/agent_connect.go create mode 100644 pkg/web/aop_chat.go create mode 100644 pkg/web/aop_endpoint.go delete mode 100644 pkg/web/aop_grpc.go create mode 100644 pkg/web/aop_ws.go create mode 100644 pkg/web/config_connect.go create mode 100644 pkg/web/connect_protocol_test.go delete mode 100644 pkg/web/connect_test.go delete mode 100644 pkg/web/grpc.go delete mode 100644 pkg/web/handler_import.go create mode 100644 pkg/web/ioa_auth.go create mode 100644 pkg/web/ioa_auth_test.go delete mode 100644 pkg/web/ioa_console.go delete mode 100644 pkg/web/ioa_console_test.go delete mode 100644 pkg/web/scan_grpc.go create mode 100644 pkg/web/sco_connect.go create mode 100644 pkg/web/system_connect.go delete mode 100644 pkg/web/terminal/codec_test.go diff --git a/README_CN.md b/README_CN.md index c50dd0e9..7c1ce289 100644 --- a/README_CN.md +++ b/README_CN.md @@ -209,7 +209,7 @@ llm: | [Scan 模式详解](docs/scan.md) | 扫描流水线、AI 增强、输出格式 | | [Agent 模式详解](docs/agent.md) | Agent 工具集、Goal Evaluation、REPL | | [IOA 协作](docs/ioa.md) | 多 Agent 协作架构、Space/Node/Message 模型 | -| [Web 自然语言 API](docs/web-chat-api.md) | API 接口、Go 接入示例和调试排障 | +| [协议与传输架构](docs/protocol-architecture.md) | AOP WebSocket、Connect 管理平面、namespace 与身份边界 | | [参考手册](docs/reference.md) | 配置、LLM Provider、全局参数、扫描器用法、FAQ | | [Changelog](docs/changelog.md) | 版本变更记录 | diff --git a/cmd/aiscan/cli.go b/cmd/aiscan/cli.go index 82771790..8ebb5c32 100644 --- a/cmd/aiscan/cli.go +++ b/cmd/aiscan/cli.go @@ -64,12 +64,13 @@ type serveCommand struct { } type ioaCommand struct { - cfg.IOAOptions `group:"Server Options"` - Serve struct{} `command:"serve" description:"Run the standalone agent server"` - Spaces struct{} `command:"spaces" description:"List all spaces"` - Messages ioaMessagesCmd `command:"messages" description:"List start messages in a space"` - Context ioaContextCmd `command:"context" description:"View message thread/context"` - Nodes ioaNodesCmd `command:"nodes" description:"List nodes"` + cfg.IOAOptions `group:"Server Options"` + LegacyServerURL string `long:"server-url" description:"Deprecated alias for --ioa-url" hidden:"true"` + Serve struct{} `command:"serve" description:"Run the standalone agent server"` + Spaces struct{} `command:"spaces" description:"List all spaces"` + Messages ioaMessagesCmd `command:"messages" description:"List start messages in a space"` + Context ioaContextCmd `command:"context" description:"View message thread/context"` + Nodes ioaNodesCmd `command:"nodes" description:"List nodes"` } type ioaMessagesCmd struct { @@ -371,6 +372,9 @@ func buildOption(cli *cliOptions, parser *goflags.Parser) cfg.Option { opt.ReconOptions = cli.Web.ReconOptions case "ioa": opt.IOAOptions = cli.IOA.IOAOptions + if opt.IOAURL == "" { + opt.IOAURL = cli.IOA.LegacyServerURL + } } return opt diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go index a1b57f29..3c1ee2fa 100644 --- a/cmd/aiscan/cli_test.go +++ b/cmd/aiscan/cli_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/chainreactors/aiscan/agent" + "github.com/chainreactors/aiscan/agent/provider" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/runner" @@ -33,11 +34,11 @@ type fakeConsoleProvider struct { func (p *fakeConsoleProvider) Name() string { return "fake" } -func (p *fakeConsoleProvider) ChatCompletion(_ context.Context, req *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) { +func (p *fakeConsoleProvider) ChatCompletion(_ context.Context, req *provider.ChatCompletionRequest) (*provider.ChatCompletionResponse, error) { p.requests++ - return &agent.ChatCompletionResponse{ - Choices: []agent.Choice{{ - Message: agent.NewTextMessage("assistant", "ok"), + return &provider.ChatCompletionResponse{ + Choices: []provider.Choice{{ + Message: provider.TextMessage("assistant", "ok"), }}, }, nil } @@ -556,11 +557,11 @@ func TestParseCLIAgentIOAFlag(t *testing.T) { } } -func TestParseCLIAgentWebURL(t *testing.T) { +func TestParseCLIAgentServerURL(t *testing.T) { parsed, err := parseCLI([]string{ "agent", - "--web-url", "http://127.0.0.1:8080", - "--server-url", "http://token@127.0.0.1:8080/ioa", + "--server-url", "http://token@127.0.0.1:8080", + "--ioa-url", "http://ioa-token@ioa.example:8765", "--space", "case-1", "--node-name", "worker-1", }) @@ -571,7 +572,7 @@ func TestParseCLIAgentWebURL(t *testing.T) { t.Fatalf("mode = %s, want %s", parsed.Mode, cfg.RunModeAgent) } opt := parsed.Option - if opt.WebURL != "http://127.0.0.1:8080" || opt.IOAURL != "http://token@127.0.0.1:8080/ioa" || opt.Space != "case-1" || opt.IOANodeName != "worker-1" { + if opt.ServerURL != "http://token@127.0.0.1:8080" || opt.IOAURL != "http://ioa-token@ioa.example:8765" || opt.Space != "case-1" || opt.IOANodeName != "worker-1" { t.Fatalf("option = %#v", opt) } } diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index c87dd44c..804eff65 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -222,7 +222,7 @@ func ioaServe(ctx context.Context, option *cfg.Option, logger telemetry.Logger) listenURL = "http://127.0.0.1:8765" } if u, err := url.Parse(listenURL); err == nil { - logger.Infof(" agent connect: aiscan agent --server-url http://%s@%s", accessKey, u.Host) + logger.Infof(" agent IOA connect: aiscan agent --transport local --ioa-url http://%s@%s", accessKey, u.Host) } return ioaserver.RunServer(ctx, ioaserver.ServerOptions{ diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 9e7421ee..70428896 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -19,13 +19,11 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/runner" + configpb "github.com/chainreactors/aiscan/pkg/types/config" "github.com/chainreactors/aiscan/pkg/web" webstatic "github.com/chainreactors/aiscan/web" "github.com/chainreactors/ioa/protocols" ioaserver "github.com/chainreactors/ioa/server" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" - "gopkg.in/yaml.v3" ) func init() { @@ -65,7 +63,16 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom if _, err := runner.ResolveRuntimeConfigCandidate(&candidateOption); err != nil { return nil, err } - candidate, err := initWebApp(ctx, &candidateOption, logger) + // The candidate app runs exactly the proto config being committed — + // no second parse of the staged YAML through cfg.Option. + appCfg := runner.AppConfigFromDistribute(prepared.Config, runner.RuntimeFeatures{ + ProviderEnabled: true, + ProviderOptional: true, + ToolsEnabled: true, + AIEnabled: true, + }, logger) + appCfg = runner.MergeOptionExtras(appCfg, &candidateOption) + candidate, err := initWebAppFromConfig(ctx, appCfg) if err != nil { return nil, err } @@ -85,7 +92,6 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom } else { pool = web.NewAgentPool(service.Hub()) } - pool.SetRecordStore(store) pool.SetSCOStore(store) service.SetAgentPool(pool) @@ -99,7 +105,20 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom accessKey = protocols.NewToken() } ioaSvc := ioaserver.NewService(ioaserver.NewMemoryStore(), accessKey) - ioaHandler := ioaserver.AuthMiddleware(ioaSvc)(ioaserver.NewHandler(ioaSvc)) + ioaWebIdentity, err := ioaSvc.AuthRegister(ctx, protocols.AuthRegister{ + Name: "aiscan.web", + Description: "AIScan Web console", + AccessKey: accessKey, + Meta: map[string]any{"role": "web"}, + }) + if err != nil { + return fmt.Errorf("register IOA web identity: %w", err) + } + ioaHandler := web.ShareWebAuthWithIOA( + accessKey, + ioaWebIdentity.Token, + ioaserver.AuthMiddleware(ioaSvc)(ioaserver.NewHandler(ioaSvc)), + ) listener, err := net.Listen("tcp", opts.Addr) if err != nil { @@ -119,28 +138,15 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom localAgents.StopAll() }() - httpHandler := web.NewHandler(service, pool, localAgents, ioaHandler, newSPAFileServer(staticSub), accessKey, ioaSvc) - grpcServer := web.NewGRPCServer(accessKey, service, pool) - handler := h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/aop.ChatService/") || strings.HasPrefix(r.URL.Path, "/aiscan.chat.SessionService/") { - httpHandler.ServeHTTP(w, r) - return - } - if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") { - grpcServer.ServeHTTP(w, r) - return - } - httpHandler.ServeHTTP(w, r) - }), &http2.Server{}) + httpHandler := web.NewHandler(service, pool, localAgents, ioaHandler, newSPAFileServer(staticSub), accessKey) srv := &http.Server{ Addr: opts.Addr, - Handler: handler, + Handler: httpHandler, } go func() { <-ctx.Done() - grpcServer.Stop() shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutCancel() _ = srv.Shutdown(shutCtx) @@ -148,11 +154,11 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom logger.Infof("aiscan server listening on http://%s", listenAddr) logger.Infof(" web access token: %s", accessKey) - logger.Infof(" agent connect: aiscan agent --server-url http://%s@%s/ioa", accessKey, listenAddr) + logger.Infof(" agent connect: aiscan agent --server-url http://%s@%s --node-name ", accessKey, listenAddr) if localAgent, err := localAgents.Launch(ctx); err != nil { logger.Warnf("auto-start local agent: %s", err) } else { - logger.Infof("auto-started local agent name=%s pid=%d", localAgent.Name, localAgent.PID) + logger.Infof("auto-started local agent name=%s pid=%d", localAgent.Name, localAgent.Pid) } if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { return err @@ -165,11 +171,7 @@ func wireWebApp(application *runner.App, store *web.SQLiteStore) { return } application.SCOSidecar.OnNodes = func(callID string, nodes []json.RawMessage) { - scanID := callID - if scanID == "" { - scanID = "standalone" - } - _ = store.UpsertSCONodes(context.Background(), scanID, nodes) + _ = store.UpsertSCONodes(context.Background(), callID, nodes) } } @@ -217,6 +219,10 @@ func initWebApp(ctx context.Context, baseOption *cfg.Option, logger telemetry.Lo ToolsEnabled: true, AIEnabled: true, }, logger) + return initWebAppFromConfig(ctx, appCfg) +} + +func initWebAppFromConfig(ctx context.Context, appCfg runner.ApplicationConfig) (*runner.App, error) { appCfg.SkipEngines = true appCfg.Scanner.VerifyMode = "off" @@ -240,37 +246,36 @@ type webConfigStore struct { mu sync.Mutex } -func (s *webConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, cfg.DistributeConfig, error) { +func (s *webConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, *configpb.DistributeConfig, error) { if err := ctx.Err(); err != nil { - return "", false, cfg.DistributeConfig{}, err + return "", false, nil, err } p, loaded := s.resolveConfigPath() if !loaded { - return p, false, cfg.DistributeConfig{}, nil + return p, false, &configpb.DistributeConfig{}, nil } data, err := os.ReadFile(p) if err != nil { - return p, false, cfg.DistributeConfig{}, err + return p, false, nil, err } dc := parseDistributeConfig(data) return p, true, dc, nil } -// parseDistributeConfig decodes the YAML settings file and migrates a legacy -// flat llm section into the provider profile list — the only place the flat -// representation is still accepted. -func parseDistributeConfig(data []byte) cfg.DistributeConfig { - var dc cfg.DistributeConfig - _ = yaml.Unmarshal(data, &dc) - var legacy struct { - LLM cfg.LLMProviderConfig `yaml:"llm"` - } - _ = yaml.Unmarshal(data, &legacy) - cfg.MigrateLLMConfig(&dc.LLM, legacy.LLM) +// parseDistributeConfig decodes the final protobuf-shaped YAML configuration. +func parseDistributeConfig(data []byte) *configpb.DistributeConfig { + dc, err := cfg.LoadDistributeConfigYAML(data) + if err != nil || dc == nil { + dc = &configpb.DistributeConfig{} + } + if dc.Llm == nil { + dc.Llm = &configpb.LLMConfig{} + } + cfg.NormalizeLLMConfig(dc.Llm) return dc } -func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming cfg.DistributeConfig) (*web.PreparedConfig, error) { +func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming *configpb.DistributeConfig) (*web.PreparedConfig, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -278,26 +283,36 @@ func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming c defer s.mu.Unlock() p, loaded := s.resolveConfigPath() - var current cfg.DistributeConfig + var current *configpb.DistributeConfig if loaded { data, err := os.ReadFile(p) if err != nil { return nil, err } current = parseDistributeConfig(data) + } else { + current = &configpb.DistributeConfig{} } - cfg.MigrateLLMConfig(&incoming.LLM, cfg.LLMProviderConfig{}) + if incoming == nil { + incoming = &configpb.DistributeConfig{} + } + if incoming.Llm == nil { + incoming.Llm = &configpb.LLMConfig{} + } + cfg.NormalizeLLMConfig(incoming.Llm) // Preserve existing secrets when incoming value is empty. - preserveLLMProfileSecrets(&incoming.LLM, current.LLM) - preserveSecret(&incoming.Cyberhub.Key, current.Cyberhub.Key) - preserveSecret(&incoming.Recon.FofaKey, current.Recon.FofaKey) - preserveSecret(&incoming.Recon.HunterToken, current.Recon.HunterToken) - preserveSecret(&incoming.Recon.HunterAPIKey, current.Recon.HunterAPIKey) - preserveSecret(&incoming.Search.TavilyKeys, current.Search.TavilyKeys) - preserveSecret(&incoming.IOA.Token, current.IOA.Token) - - next, err := yaml.Marshal(&incoming) + preserveLLMProfileSecrets(incoming.Llm, current.GetLlm()) + incoming.Cyberhub = preserveConfigSection(incoming.Cyberhub, current.GetCyberhub(), func(c *configpb.CyberhubConfig) { preserveSecret(&c.Key, current.GetCyberhub().GetKey()) }) + incoming.Recon = preserveConfigSection(incoming.Recon, current.GetRecon(), func(c *configpb.ReconConfig) { + preserveSecret(&c.FofaKey, current.GetRecon().GetFofaKey()) + preserveSecret(&c.HunterToken, current.GetRecon().GetHunterToken()) + preserveSecret(&c.HunterApiKey, current.GetRecon().GetHunterApiKey()) + }) + incoming.Search = preserveConfigSection(incoming.Search, current.GetSearch(), func(c *configpb.SearchConfig) { preserveSecret(&c.TavilyKeys, current.GetSearch().GetTavilyKeys()) }) + incoming.Ioa = preserveConfigSection(incoming.Ioa, current.GetIoa(), func(c *configpb.IOAConfig) { preserveSecret(&c.Token, current.GetIoa().GetToken()) }) + + next, err := cfg.MarshalDistributeConfigYAML(incoming) if err != nil { return nil, err } @@ -374,23 +389,45 @@ func preserveSecret(incoming *string, existing string) { } } -func preserveLLMProfileSecrets(incoming *cfg.LLMConfig, existing cfg.LLMConfig) { - byID := make(map[string]cfg.LLMProviderConfig, len(existing.Providers)) - for _, profile := range existing.Providers { - if profile.ID != "" { - byID[profile.ID] = profile +// preserveConfigSection ensures section is non-nil, then applies fn to it. +// current is the on-disk value used to backfill empty secrets. +func preserveConfigSection[T any](incoming *T, current *T, fn func(*T)) *T { + if incoming == nil { + if current != nil { + return current + } + return new(T) + } + fn(incoming) + return incoming +} + +func preserveLLMProfileSecrets(incoming *configpb.LLMConfig, existing *configpb.LLMConfig) { + if incoming == nil { + return + } + byID := make(map[string]*configpb.LLMProviderConfig) + if existing != nil { + for _, profile := range existing.Providers { + if profile.Id != "" { + byID[profile.Id] = profile + } } } - for i := range incoming.Providers { - if strings.TrimSpace(incoming.Providers[i].APIKey) != "" { + var existingProviders []*configpb.LLMProviderConfig + if existing != nil { + existingProviders = existing.Providers + } + for i, profile := range incoming.Providers { + if profile == nil || strings.TrimSpace(profile.ApiKey) != "" { continue } - if current, ok := byID[incoming.Providers[i].ID]; ok { - incoming.Providers[i].APIKey = current.APIKey + if current, ok := byID[profile.Id]; ok { + profile.ApiKey = current.ApiKey continue } - if i < len(existing.Providers) { - incoming.Providers[i].APIKey = existing.Providers[i].APIKey + if i < len(existingProviders) { + profile.ApiKey = existingProviders[i].GetApiKey() } } } diff --git a/cmd/aiscan/web_full_test.go b/cmd/aiscan/web_full_test.go index fe5b7ea7..a4bb263e 100644 --- a/cmd/aiscan/web_full_test.go +++ b/cmd/aiscan/web_full_test.go @@ -13,14 +13,14 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/runner" + configpb "github.com/chainreactors/aiscan/pkg/types/config" "github.com/chainreactors/aiscan/pkg/web" - "gopkg.in/yaml.v3" ) func TestWebConfigStoreStagesBeforeAtomicCommit(t *testing.T) { path := filepath.Join(t.TempDir(), "aiscan.yaml") old := configForWebStore("old-model", "secret-key") - oldBytes, err := yaml.Marshal(&old) + oldBytes, err := cfg.MarshalDistributeConfigYAML(old) if err != nil { t.Fatal(err) } @@ -53,7 +53,7 @@ func TestWebConfigStoreStagesBeforeAtomicCommit(t *testing.T) { if perm := info.Mode().Perm(); runtime.GOOS != "windows" && perm != 0600 { t.Fatalf("candidate permissions = %o, want 600", perm) } - if got := prepared.Config.LLM.Active().APIKey; got != "secret-key" { + if got := cfg.ActiveLLMProvider(prepared.Config.GetLlm()).GetApiKey(); got != "secret-key" { t.Fatalf("prepared API key = %q, want preserved secret", got) } @@ -64,8 +64,9 @@ func TestWebConfigStoreStagesBeforeAtomicCommit(t *testing.T) { if err != nil { t.Fatal(err) } - if !loaded || committed.LLM.Active().Model != "new-model" || committed.LLM.Active().APIKey != "secret-key" { - t.Fatalf("committed config = %+v", committed.LLM) + active := cfg.ActiveLLMProvider(committed.GetLlm()) + if !loaded || active.GetModel() != "new-model" || active.GetApiKey() != "secret-key" { + t.Fatalf("committed config = %+v", committed.Llm) } } @@ -82,7 +83,7 @@ func TestWireWebAppBindsSCONodesForReloadedApp(t *testing.T) { t.Fatal("reloaded app SCO sidecar callback was not bound") } application.SCOSidecar.OnNodes("scan-1", []json.RawMessage{ - json.RawMessage(`{"cstx_id":"node-1","cstx_type":"asset","data":{}}`), + json.RawMessage(`{"cstx_id":"ip:127.0.0.1","cstx_type":"ip","ip":"127.0.0.1"}`), }) nodes, err := store.ListSCONodesByScanID(context.Background(), "scan-1", "", 10) if err != nil { @@ -93,11 +94,13 @@ func TestWireWebAppBindsSCONodesForReloadedApp(t *testing.T) { } } -func configForWebStore(model, apiKey string) cfg.DistributeConfig { - var value cfg.DistributeConfig - value.LLM.ActiveProfile = "primary" - value.LLM.Providers = []cfg.LLMProviderConfig{{ - ID: "primary", Provider: "openai", Model: model, APIKey: apiKey, - }} - return value +func configForWebStore(model, apiKey string) *configpb.DistributeConfig { + return &configpb.DistributeConfig{ + Llm: &configpb.LLMConfig{ + ActiveProfile: "primary", + Providers: []*configpb.LLMProviderConfig{{ + Id: "primary", Provider: "openai", Model: model, ApiKey: apiKey, + }}, + }, + } } diff --git a/core/config/agent_server.go b/core/config/agent_server.go new file mode 100644 index 00000000..e40bb5f3 --- /dev/null +++ b/core/config/agent_server.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +// ResolveAgentServerURLs makes --server-url the canonical Web/AOP endpoint. +// Deprecated --web-url is only an alias. IOA remains independently configurable +// and falls back to the Web server's same-origin /ioa endpoint when omitted. +func ResolveAgentServerURLs(option *Option) error { + if option == nil { + return fmt.Errorf("agent options are required") + } + serverURL := strings.TrimSpace(option.ServerURL) + legacyWebURL := strings.TrimSpace(option.WebURL) + if serverURL == "" && legacyWebURL == "" { + return fmt.Errorf("--server-url is required for web transport") + } + if serverURL != "" && legacyWebURL != "" && normalizeServerURL(serverURL) != normalizeServerURL(legacyWebURL) { + return fmt.Errorf("--server-url and deprecated --web-url refer to different AIScan servers") + } + if serverURL == "" { + serverURL = legacyWebURL + } + serverURL, err := validateAgentServerURL(serverURL) + if err != nil { + return err + } + option.ServerURL = serverURL + option.WebURL = serverURL + if strings.TrimSpace(option.IOAURL) == "" { + option.IOAURL = deriveIOAURL(serverURL) + } + return nil +} + +func validateAgentServerURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid AIScan server URL %q", raw) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("AIScan server URL must use http or https") + } + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/"), nil +} + +func deriveIOAURL(serverURL string) string { + parsed, err := url.Parse(serverURL) + if err != nil { + return "" + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/ioa" + parsed.RawPath = "" + return strings.TrimRight(parsed.String(), "/") +} + +func normalizeServerURL(value string) string { + return strings.TrimRight(strings.TrimSpace(value), "/") +} diff --git a/core/config/agent_server_test.go b/core/config/agent_server_test.go new file mode 100644 index 00000000..d0edf59e --- /dev/null +++ b/core/config/agent_server_test.go @@ -0,0 +1,51 @@ +package config + +import "testing" + +func TestResolveAgentTransportDerivesSameOriginEndpoints(t *testing.T) { + option := &Option{ + AgentOptions: AgentOptions{ServerURL: "https://token@example.test/base"}, + } + transport, err := ResolveAgentTransport(option) + if err != nil { + t.Fatal(err) + } + if transport != AgentTransportWeb { + t.Fatalf("transport = %q, want web", transport) + } + if option.ServerURL != "https://token@example.test/base" || option.WebURL != option.ServerURL || option.IOAURL != "https://token@example.test/base/ioa" { + t.Fatalf("resolved endpoints = server %q web %q ioa %q", option.ServerURL, option.WebURL, option.IOAURL) + } +} + +func TestResolveAgentTransportKeepsIndependentIOAURL(t *testing.T) { + option := &Option{ + AgentOptions: AgentOptions{ServerURL: "http://web-token@127.0.0.1:8080"}, + IOAOptions: IOAOptions{IOAURL: "https://ioa-token@ioa.example/api"}, + } + if _, err := ResolveAgentTransport(option); err != nil { + t.Fatal(err) + } + if option.IOAURL != "https://ioa-token@ioa.example/api" { + t.Fatalf("IOAURL = %q", option.IOAURL) + } +} + +func TestResolveAgentTransportKeepsLegacyWebURLCompatible(t *testing.T) { + option := &Option{AgentOptions: AgentOptions{WebURL: "https://token@example.test"}} + if _, err := ResolveAgentTransport(option); err != nil { + t.Fatal(err) + } + if option.IOAURL != "https://token@example.test/ioa" { + t.Fatalf("IOAURL = %q", option.IOAURL) + } +} + +func TestResolveAgentTransportRejectsConflictingURLs(t *testing.T) { + option := &Option{ + AgentOptions: AgentOptions{ServerURL: "https://one.example", WebURL: "https://two.example"}, + } + if _, err := ResolveAgentTransport(option); err == nil { + t.Fatal("expected conflicting server URLs to fail") + } +} diff --git a/core/config/distribute.go b/core/config/distribute.go index ef94125d..53302d7f 100644 --- a/core/config/distribute.go +++ b/core/config/distribute.go @@ -1,70 +1,99 @@ package config import ( + "encoding/json" "fmt" "strings" + + configpb "github.com/chainreactors/aiscan/pkg/types/config" + "google.golang.org/protobuf/encoding/protojson" + "gopkg.in/yaml.v3" ) -// LLMProviderConfig is one named LLM profile distributed by the Web server. -type LLMProviderConfig struct { - ID string `json:"id" yaml:"id,omitempty"` - Name string `json:"name" yaml:"name,omitempty"` - Provider string `json:"provider" yaml:"provider"` - BaseURL string `json:"base_url" yaml:"base_url"` - APIKey string `json:"api_key,omitempty" yaml:"api_key"` - Model string `json:"model" yaml:"model"` - Proxy string `json:"proxy" yaml:"proxy"` - MaxTokens int `json:"max_tokens,omitempty" yaml:"max_tokens,omitempty"` - ContextWindow int `json:"context_window,omitempty" yaml:"context_window,omitempty"` +// LoadDistributeConfigYAML parses an aiscan.yaml file into the canonical proto +// representation. It bridges YAML's snake-case keys with the proto message. +func LoadDistributeConfigYAML(data []byte) (*configpb.DistributeConfig, error) { + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("unmarshal yaml: %w", err) + } + jsonData, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("convert yaml to json: %w", err) + } + pb := new(configpb.DistributeConfig) + if err := protojson.Unmarshal(jsonData, pb); err != nil { + return nil, fmt.Errorf("unmarshal proto json: %w", err) + } + return pb, nil } -type LLMConfig struct { - ActiveProfile string `json:"active_profile,omitempty" yaml:"active_profile,omitempty"` - Providers []LLMProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"` +// MarshalDistributeConfigYAML serializes the canonical proto config to YAML. +func MarshalDistributeConfigYAML(pb *configpb.DistributeConfig) ([]byte, error) { + if pb == nil { + return nil, nil + } + jsonData, err := protojson.Marshal(pb) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(jsonData, &raw); err != nil { + return nil, err + } + return yaml.Marshal(raw) } -func (c LLMConfig) Active() LLMProviderConfig { - if len(c.Providers) == 0 { - return LLMProviderConfig{} +// ActiveLLMProvider returns the selected LLM profile, or the first when the +// active id is missing/unknown, or nil when no profiles exist. +func ActiveLLMProvider(llm *configpb.LLMConfig) *configpb.LLMProviderConfig { + if llm == nil || len(llm.Providers) == 0 { + return nil } - for _, provider := range c.Providers { - if provider.ID == c.ActiveProfile { + for _, provider := range llm.Providers { + if provider.Id == llm.ActiveProfile { return NormalizeLLMProvider(provider) } } - return NormalizeLLMProvider(c.Providers[0]) + return NormalizeLLMProvider(llm.Providers[0]) } -func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { - if len(llm.Providers) == 0 { - if flat.Provider == "" && flat.BaseURL == "" && flat.Model == "" { - return - } - flat.ID = llm.ActiveProfile - if flat.ID == "" { - flat.ID = "default" - } - llm.Providers = []LLMProviderConfig{flat} +// NormalizeLLMConfig canonicalizes the final profile-list representation. Old +// flat LLM configuration is intentionally not accepted. +func NormalizeLLMConfig(llm *configpb.LLMConfig) { + if llm == nil { + return } - for index := range llm.Providers { - llm.Providers[index] = NormalizeLLMProvider(llm.Providers[index]) - if llm.Providers[index].ID == "" { - llm.Providers[index].ID = fmt.Sprintf("profile-%d", index+1) + for index, provider := range llm.Providers { + llm.Providers[index] = NormalizeLLMProvider(provider) + provider = llm.Providers[index] + if provider == nil { + continue } - if llm.Providers[index].Name == "" { - llm.Providers[index].Name = llm.Providers[index].Model - if llm.Providers[index].Name == "" { - llm.Providers[index].Name = llm.Providers[index].Provider + if provider.Id == "" { + provider.Id = fmt.Sprintf("profile-%d", index+1) + } + if provider.Name == "" { + provider.Name = provider.Model + if provider.Name == "" { + provider.Name = provider.Provider } } } - llm.ActiveProfile = llm.Active().ID + if active := ActiveLLMProvider(llm); active != nil { + llm.ActiveProfile = active.Id + } } -func NormalizeLLMProvider(profile LLMProviderConfig) LLMProviderConfig { +// NormalizeLLMProvider trims and canonicalizes the provider protocol, inferring +// it from the base URL when blank. +func NormalizeLLMProvider(profile *configpb.LLMProviderConfig) *configpb.LLMProviderConfig { + if profile == nil { + return nil + } profile.Provider = strings.ToLower(strings.TrimSpace(profile.Provider)) if profile.Provider == "" { - if strings.Contains(strings.ToLower(profile.BaseURL), "anthropic.com") { + if strings.Contains(strings.ToLower(profile.BaseUrl), "anthropic.com") { profile.Provider = "anthropic" } else { profile.Provider = "openai" @@ -72,40 +101,3 @@ func NormalizeLLMProvider(profile LLMProviderConfig) LLMProviderConfig { } return profile } - -// DistributeConfig is the shared configuration document loaded by the Web -// server and consumed by remote agents. HTTP masking stays in pkg/web. -type DistributeConfig struct { - LLM LLMConfig `json:"llm" yaml:"llm"` - Cyberhub struct { - URL string `json:"url" yaml:"url"` - Key string `json:"key,omitempty" yaml:"key"` - Mode string `json:"mode" yaml:"mode"` - Proxy string `json:"proxy" yaml:"proxy"` - } `json:"cyberhub" yaml:"cyberhub"` - Recon struct { - FofaEmail string `json:"fofa_email" yaml:"fofa_email"` - FofaKey string `json:"fofa_key,omitempty" yaml:"fofa_key"` - HunterToken string `json:"hunter_token,omitempty" yaml:"hunter_token"` - HunterAPIKey string `json:"hunter_api_key,omitempty" yaml:"hunter_api_key"` - Proxy string `json:"proxy" yaml:"proxy"` - Limit *int `json:"limit,omitempty" yaml:"limit,omitempty"` - } `json:"recon" yaml:"recon"` - Scan struct { - Verify string `json:"verify" yaml:"verify"` - } `json:"scan" yaml:"scan"` - Search struct { - TavilyKeys string `json:"tavily_keys,omitempty" yaml:"tavily_keys"` - } `json:"search" yaml:"search"` - IOA struct { - URL string `json:"url" yaml:"url"` - Token string `json:"token,omitempty" yaml:"token"` - NodeName string `json:"node_name" yaml:"node_name"` - Space string `json:"space" yaml:"space"` - } `json:"ioa" yaml:"ioa"` - Agent struct { - Tools []string `json:"tools,omitempty" yaml:"tools,omitempty"` - Timeout int `json:"timeout" yaml:"timeout"` - SaveSession bool `json:"save_session" yaml:"save_session"` - } `json:"agent" yaml:"agent"` -} diff --git a/core/config/distribute_test.go b/core/config/distribute_test.go index 2ee6ff12..f5d0de3f 100644 --- a/core/config/distribute_test.go +++ b/core/config/distribute_test.go @@ -1,22 +1,26 @@ package config -import "testing" +import ( + "testing" -func TestMigrateLLMConfigCanonicalizesProviderProtocol(t *testing.T) { - config := LLMConfig{Providers: []LLMProviderConfig{ - {ID: "openai", Provider: " OPENAI ", BaseURL: "https://api.deepseek.com/v1"}, - {ID: "claude", Provider: "ANTHROPIC"}, - {ID: "invalid", Provider: "deepseek"}, + configpb "github.com/chainreactors/aiscan/pkg/types/config" +) + +func TestNormalizeLLMConfigCanonicalizesProviderProtocol(t *testing.T) { + llm := &configpb.LLMConfig{Providers: []*configpb.LLMProviderConfig{ + {Id: "openai", Provider: " OPENAI ", BaseUrl: "https://api.deepseek.com/v1"}, + {Id: "claude", Provider: "ANTHROPIC"}, + {Id: "invalid", Provider: "deepseek"}, }} - MigrateLLMConfig(&config, LLMProviderConfig{}) + NormalizeLLMConfig(llm) - if config.Providers[0].Provider != "openai" { - t.Fatalf("OpenAI-compatible provider = %q", config.Providers[0].Provider) + if llm.Providers[0].Provider != "openai" { + t.Fatalf("OpenAI-compatible provider = %q", llm.Providers[0].Provider) } - if config.Providers[1].Provider != "anthropic" { - t.Fatalf("Anthropic provider = %q", config.Providers[1].Provider) + if llm.Providers[1].Provider != "anthropic" { + t.Fatalf("Anthropic provider = %q", llm.Providers[1].Provider) } - if config.Providers[2].Provider != "deepseek" { - t.Fatalf("unsupported provider must not be rewritten, got %q", config.Providers[2].Provider) + if llm.Providers[2].Provider != "deepseek" { + t.Fatalf("unsupported provider must not be rewritten, got %q", llm.Providers[2].Provider) } } diff --git a/core/config/loader.go b/core/config/loader.go index df66bba0..067e370b 100644 --- a/core/config/loader.go +++ b/core/config/loader.go @@ -113,6 +113,7 @@ func mergeOption(dst, src *Option) { dst.ReconLimit = src.ReconLimit } dst.Proxy = ResolveString(dst.Proxy, src.Proxy) + dst.ServerURL = ResolveString(dst.ServerURL, src.ServerURL) dst.WebURL = ResolveString(dst.WebURL, src.WebURL) dst.Transport = ResolveString(dst.Transport, src.Transport) dst.IOAURL = ResolveString(dst.IOAURL, src.IOAURL) diff --git a/core/config/options.go b/core/config/options.go index 2bb8333b..ab93e21c 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -83,8 +83,9 @@ type AgentOptions struct { EvalCriteria string `short:"e" long:"eval" config:"eval_criteria" description:"Goal evaluation criteria — an independent LLM evaluates whether the task was achieved"` EvalModel string `long:"eval-model" config:"eval_model" description:"Model for goal evaluation (defaults to main model)"` EvalMaxRetries int `long:"eval-retries" config:"eval_retries" description:"Max goal evaluation retry rounds" default:"3"` - WebURL string `long:"web-url" config:"web_url" description:"AIScan web server URL for remote REPL and PTY access"` - Transport string `long:"transport" config:"transport" description:"Agent transport: auto, local, web, grpc, or stdio" default:"auto"` + ServerURL string `long:"server-url" config:"server_url" description:"AIScan Web server URL for AOP, remote REPL and PTY access"` + WebURL string `long:"web-url" config:"web_url" description:"Deprecated alias for --server-url" hidden:"true"` + Transport string `long:"transport" config:"transport" description:"Agent transport: auto, local, web, or stdio" default:"auto"` Resume string `long:"resume" description:"Resume session from a saved session file path"` SaveSession bool `long:"save-session" config:"save_session" description:"Auto-save conversation to .aiscan/sessions/ after each agent run (default: off)"` CaptureProviderFrames bool `long:"capture-provider-frames" config:"capture_provider_frames" description:"Emit exact provider request/response frames as sensitive AOP events"` @@ -96,7 +97,6 @@ const ( AgentTransportAuto AgentTransport = "auto" AgentTransportLocal AgentTransport = "local" AgentTransportWeb AgentTransport = "web" - AgentTransportGRPC AgentTransport = "grpc" AgentTransportStdio AgentTransport = "stdio" ) @@ -107,24 +107,27 @@ func ResolveAgentTransport(opt *Option) (AgentTransport, error) { } switch value { case AgentTransportAuto: - if strings.TrimSpace(opt.WebURL) != "" { + if strings.TrimSpace(opt.ServerURL) != "" || strings.TrimSpace(opt.WebURL) != "" { + if err := ResolveAgentServerURLs(opt); err != nil { + return "", err + } return AgentTransportWeb, nil } return AgentTransportLocal, nil case AgentTransportLocal, AgentTransportStdio: return value, nil - case AgentTransportWeb, AgentTransportGRPC: - if strings.TrimSpace(opt.WebURL) == "" { - return "", fmt.Errorf("--transport %s requires --web-url", value) + case AgentTransportWeb: + if err := ResolveAgentServerURLs(opt); err != nil { + return "", err } return value, nil default: - return "", fmt.Errorf("unsupported agent transport %q: use auto, local, web, grpc, or stdio", opt.Transport) + return "", fmt.Errorf("unsupported agent transport %q: use auto, local, web, or stdio", opt.Transport) } } type IOAOptions struct { - IOAURL string `long:"server-url" config:"url" description:"Server URL for agent connection (supports http://token@host:port)"` + IOAURL string `long:"ioa-url" config:"url" description:"Optional independent IOA URL (defaults to /ioa for Web agents)"` IOAToken string `long:"server-token" config:"token" description:"Server access key (auto-generated if empty)"` IOANodeID string `long:"node-id" description:"Existing node id for agent tools"` IOANodeName string `long:"node-name" config:"node_name" description:"Node name when auto-registering"` diff --git a/core/config/remote.go b/core/config/remote.go deleted file mode 100644 index a5d16d31..00000000 --- a/core/config/remote.go +++ /dev/null @@ -1,7 +0,0 @@ -package config - -// MergeRemoteOption merges remote config into local option. Local (non-empty) -// fields take priority. -func MergeRemoteOption(local *Option, remote *Option) { - mergeOption(local, remote) -} diff --git a/docs/agent-runtime-multipath-analysis.md b/docs/agent-runtime-multipath-analysis.md index e9f88b0c..9a80f0c0 100644 --- a/docs/agent-runtime-multipath-analysis.md +++ b/docs/agent-runtime-multipath-analysis.md @@ -50,21 +50,24 @@ session.end ## Transport -stdio 与 WebSocket 共用生成的 `aiscan.transport.ServerFrame/AgentFrame`, -分别以标准 protobuf JSON 传输;gRPC 使用同一消息的 protobuf binary: +stdio 与 WebSocket 共用 `aop.Envelope` 和同一个 Runtime protobuf loop: + +- WebSocket 使用 protobuf binary; +- stdio 使用 protobuf JSONL; +- ConnectRPC 只处理管理/query,不进入 Agent Runtime。 ```text open_session / close_session run_turn / cancel_turn command / command_result event -operation_error +protocol_error ``` - Web Run API 只使用 `turn_id` 关联;协议中不存在独立的 `run_id`; -- Runner 身份使用注册消息中的 `NodeRef.ID`;它是节点路由身份,不进入 `Session → Run` 领域模型,也不与 `turn_id` 混用; +- Runner 注册携带本地 `agent_id + authority`;组合后的 `node_uri` 是唯一节点路由身份,不进入 `Session → Run` 领域模型,也不与 `turn_id` 混用; - direct structured tool execution 使用 `tool_call` / AOP `tool_result`; -- PTY、file RPC、node status/config 仍属于各自控制或终端平面,不伪装成 Agent Turn。 +- PTY、file、exec、tool 属于 AOP namespace,不伪装成 Agent Turn,也不创建独立传输。 ## 并发与异步输入 @@ -80,6 +83,6 @@ operation_error 这是一次性 breaking cutover: - 不双读、双写旧协议; -- SQLite 保留 sessions、messages、assets、records; -- `chat_aop_events.event_json` 只存标准 protobuf JSON;迁移保留已有历史,不做双读写; -- SQLite delivery 列统一为 `cursor`,旧 `hub_seq` 仅执行一次列重命名。 +- SQLite 保存 protobuf Session/Scan 与 AOP Event ProtoJSON; +- Scanner 输出只保存 libcstx SCO JSONL; +- 不保留 assets、records 或旧协议双读写。 diff --git a/docs/mechanisms.md b/docs/mechanisms.md index e32ec2fe..a52c22fa 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -8,7 +8,7 @@ **问题**: hub 原来每次 WS 连接都 `generateID()` 生成随机 key。chat session 在创建时冻结 `agent_id`,agent 断连重连后 id 变化,session 绑定的旧 id 解析到空,消息被拒为 "not connected"。 -**机制**: `agentKey()` 从生成的 `transport.AgentHello` 中提取稳定标识,作为 pool 的唯一 key。重连的 agent 覆盖旧 slot 而非新建。 +**机制**: `agentKey()` 从生成的 `aop.AgentHello` 中提取稳定标识,作为 pool 的唯一 key。重连的 agent 覆盖旧 slot 而非新建。 **守卫**: - `register()` 检测旧连接并 Close,触发旧 read loop 退出 @@ -29,7 +29,7 @@ 持久化重放由 `chat_aop_events` 和 Scan snapshot 负责,live protobuf 不经过 JSON envelope。 -**文件**: `pkg/web/broker.go`, `pkg/web/aop_grpc.go`, `pkg/web/scan_rpc.go` +**文件**: `pkg/web/broker.go`, `pkg/web/aop_ws.go`, `pkg/web/scan_rpc.go` --- @@ -67,17 +67,18 @@ Settings UI 保存 ## 4. Goal 模式 AOP 扩展 Goal 参数不再定义 Chat DTO。`RunTurnRequest` 是唯一输入;AIScan 专属字段编码为 -`aiscan.transport.RunOptions` 的标准 protobuf JSON,并放入 namespace -`io.chainreactors.aiscan.run`。普通对话和 evaluator 复用同一 Run/Turn 生命周期。 +`Any` 并放入 `RunTurnRequest.extensions`,类型身份只由标准 +`type.googleapis.com/aiscan.agent.RunOptions` 表达。普通对话和 evaluator 复用同一 +Run/Turn 生命周期。 -**文件**: `proto/aiscan/transport/operation.proto`, `pkg/runner/runtime_protocol.go`, `pkg/web/service.go` +**文件**: `proto/aiscan/types/agent.proto`, `pkg/runner/runtime_protocol.go`, `pkg/web/service.go` --- ## 5. Eval 事件透传与持久化 -agent 在 producer 边缘生成 `aop.Event`;hub 通过生成的 `AgentFrame.event` 原样转发。 -评估字段使用 `aiscan.transport.EvalDetail` protobuf JSON 扩展,不做 flatten。 +agent 在 producer 边缘生成 `aop.Event`;hub 通过 `aop.Envelope` 原样转发。 +评估字段使用 `aiscan.agent.EvalDetail` protobuf `Any` 扩展,不做 flatten。 eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事件,但不会再投影成另一套 agent 事件或 system message。会话正文只持久化到 `chat_aop_events`,刷新后从同一 AOP 源重建。 @@ -185,7 +186,7 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。 - `fallback`: 英文文本,供非 i18n 消费者 / 日志 / 测试使用 AOP error 事件把 code 保存在 `ProtocolError.code`,params 使用 -`aiscan.transport.WebMessageExtension`,通过标准 protobuf JSON 放入扩展。通用 reducer +`Any` 放入 Event extension。通用 reducer 保留该扩展,因此实时流和重放使用同一参数来源。 已定义的 code: @@ -223,8 +224,8 @@ AOP error 事件把 code 保存在 `ProtocolError.code`,params 使用 **机制**: Runtime 产生的 typed AOP event 是 Agent 消息、工具调用和 turn 状态的唯一语义来源。Web 层直接转发和持久化这些事件,不再合成第二套 assistant 完成事件,也不再为中间轮次维护独立的聊天事件协议。 -AIScan 产品事件使用 typed AOP `ExtensionEvent`;例如 scan 完成通过 -`io.chainreactors.aiscan.scan` 携带 `scan.SessionScanEvent`。不再维护 `DomainEvent`。 +AIScan 产品事件使用 AOP core 的 typed Any 插槽;例如 scan 完成通过 +`Event.extension = Any` 表达。`Any.type_url` 是唯一类型身份,不再维护 `ExtensionEvent`、namespace 字符串或 `DomainEvent`。 **文件**: `pkg/runner/`, `aop/`, `pkg/web/service.go` @@ -252,7 +253,7 @@ AIScan 产品事件使用 typed AOP `ExtensionEvent`;例如 scan 完成通过 跨界面 Runtime 命令通过 typed AOP command detail 标记 `presentation: preformatted`。Web timeline 在最终展示边界生成自适应 Markdown code fence;Runtime、Session 和 transport 不再处理 Markdown 或终端格式。 -**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `aop/aiscan/extensions/extensions.go`, `core/output/timeline.go` +**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/types/extensions/extensions.go`, `core/output/timeline.go` --- diff --git a/docs/web-chat-api.md b/docs/web-chat-api.md deleted file mode 100644 index 71e3600f..00000000 --- a/docs/web-chat-api.md +++ /dev/null @@ -1,413 +0,0 @@ -# AIScan ConnectRPC Chat 接入手册 - -AIScan Chat 现在以 protobuf 为唯一接口模型,由 ConnectRPC 同时提供 Connect、 -gRPC-Web 和原生 gRPC。这里没有额外的 JSON-RPC 2.0 envelope,也不需要维护一套 -手写 REST DTO 或 SSE 事件协议。 - -旧的 `/api/chat/*` 与 `/api/scans/*` REST/SSE 已下线。Chat、会话管理和扫描都通过 -同一套 protobuf + ConnectRPC 接口提供。 - -## 对外接口形态 - -外部接入者仍只需要理解 `aop.ChatService` 的六个方法: - -| 方法 | 语义 | Connect/gRPC procedure | -| --- | --- | --- | -| `OpenSession` | 打开一个 Agent 会话 | `/aop.ChatService/OpenSession` | -| `RunTurn` | 提交一轮输入 | `/aop.ChatService/RunTurn` | -| `CancelTurn` | 按 `session_id + turn_id` 精确取消 | `/aop.ChatService/CancelTurn` | -| `CloseSession` | 关闭会话 | `/aop.ChatService/CloseSession` | -| `ListEvents` | 按 cursor 读取持久化历史 | `/aop.ChatService/ListEvents` | -| `WatchEvents` | 单向服务端流式监听事件 | `/aop.ChatService/WatchEvents` | - -AIScan 产品自己的会话管理能力放在独立的 -`aiscan.chat.SessionService`,不会污染通用 AOP Chat 语义: - -- `ListSessions`、`GetSession`、`DeleteSession` -- `ResetSession` -- `ListCommands`、`ExecuteCommand` -- `UploadSessionFile` - -其 procedure 前缀为 `/aiscan.chat.SessionService/`。 - -扫描能力位于 `aiscan.scan.ScanService`: - -- `SubmitScan`、`GetScan`、`ListScans`、`CancelScan` -- `WatchScanEvents`(服务端单向流式) -- `GetScanReport` - -外部 Go 调用方不需要分别初始化这些生成 client。稳定入口 -`aop/aiscan.Client` 将它们统一暴露为 `Chat`、`Sessions`、`Scans` 三个 API group。 -底层 group 仍保持独立的 protobuf service 边界,但共享同一个 HTTP client、base URL、 -认证 interceptor 和 Connect 选项。 - -## 传输形态 - -```text -Browser / TypeScript - createConnectTransport + generated client - │ Connect protobuf JSON(或 binary) - ▼ - AIScan HTTP handler - │ 同一 protobuf service implementation - ┌─────────┼──────────┐ - │ │ │ - Connect gRPC-Web native gRPC -``` - -ConnectRPC 解决的是“同一 protobuf API 适配浏览器和 gRPC 客户端”,不是把 gRPC -转换成 JSON-RPC 2.0。浏览器默认使用标准 Protobuf JSON;grpc-go 使用 protobuf -binary,但两者的方法名、字段、错误码和流式终止语义完全相同。 - -服务端同时接受: - -- Connect protocol(浏览器和普通 HTTP client) -- gRPC-Web -- 原生 gRPC(需要 HTTP/2) - -Connect handler 最大 wire message 为 72 MiB(给 Protobuf JSON 的 bytes/base64 留出 -空间),业务文件上传上限仍严格为 50 MiB。 - -## 独立 Go 工具完整接入 - -公共生成代码位于可被仓库外模块导入的路径: - -```text -github.com/chainreactors/aiscan/aop -github.com/chainreactors/aiscan/aop/aiscan -``` - -不要引用 `aop/aiscan/transport`。`aop/aiscan/transport` 只服务 AIScan AgentTransport, -不属于外部 Chat SDK。 - -### 1. 启动 AIScan Web 和 Agent - -```bash -aiscan web --addr 127.0.0.1:8080 --token dev-token -``` - -确认至少一个 Agent 已连接,然后取得它的 participant ID: - -```bash -curl -H "Authorization: Bearer dev-token" \ - http://127.0.0.1:8080/api/agents -``` - -取返回数组中的 `id`,例如 `agent-1`。该值用于 `OpenSession.participant`。 - -### 2. 创建完全独立的 Go module - -```bash -mkdir aiscan-connect-client -cd aiscan-connect-client -go mod init example.com/aiscan-connect-client -go get connectrpc.com/connect@v1.20.0 -go get github.com/chainreactors/aiscan@latest -``` - -如果是在 AIScan 源码 checkout 内验证尚未发布的版本,可临时添加: - -```go -replace github.com/chainreactors/aiscan => /absolute/path/to/aiscan -``` - -发布后的独立项目应删除 `replace` 并锁定明确的 AIScan tag/version。 - -业务代码只初始化一次根客户端: - -```go -client := aiscan.NewClient( - http.DefaultClient, - "http://127.0.0.1:8080", - connect.WithProtoJSON(), -) - -// 通用对话协议 -client.Chat.OpenSession(...) -client.Chat.WatchEvents(...) - -// AIScan 会话管理 -client.Sessions.ListSessions(...) - -// AIScan 扫描 -client.Scans.SubmitScan(...) -client.Scans.WatchScanEvents(...) -``` - -原生 gRPC 也使用相同分组形态,并复用一条 `grpc.ClientConnInterface`: - -```go -client := aiscan.NewGRPCClient(conn) -client.Chat.RunTurn(...) -client.Sessions.GetSession(...) -client.Scans.GetScan(...) -``` - -### 3. 运行可复制的完整客户端 - -仓库提供了一个拥有自己 `go.mod` 的独立示例: - -```bash -cd examples/external-go-client -go run . \ - -url http://127.0.0.1:8080 \ - -token dev-token \ - -agent '' \ - -prompt '请用一句话介绍你的能力' -``` - -这个程序通过公共的 `aop/aiscan.Client` 门面初始化一次,并使用 `client.Chat` 完整执行: - -```text -OpenSession - ├─ 并发建立 WatchEvents - ├─ RunTurn 发送自然语言 Message - ├─ 持续输出 message_delta - ├─ 使用完整 message 作为可靠结果 - ├─ 收到 turn_ended 后结束 - └─ 断线时使用最后的 EventDelivery.cursor 自动重连 -``` - -预期输出形态: - -```text -我是 AIScan,可以协助分析安全目标。 -stop=completed cursor=6 session=session-... turn=turn-... -``` - -实现文件:`examples/external-go-client/main.go`。 - -仓库的端到端回归会把该目录作为 `example.com/aiscan-external-client` 独立 module, -启动真实 HTTP Connect handler 后以子进程执行 `go run .`。因此它能捕获误用 -`internal` 包、认证失败、procedure 不兼容和流式终止缺失等问题。 - -### 4. SDK 重新生成 - -修改 protobuf 后执行: - -```bash -go generate ./proto -``` - -生成代码统一位于 `aop/`;AgentTransport 位于 `aop/aiscan/transport`,但它是服务端与 -Agent 之间的内部运行时协议,不属于外部工具的公共业务 API。生成后必须同时运行独立 -module 编译和端到端测试。 - -该入口同时生成 Go、Connect-Go 与前端 TypeScript 文件;前端依赖尚未安装时,先在 -`web/frontend` 执行 `npm install`。 - -## TypeScript 接入 - -```ts -import { createClient } from '@connectrpc/connect' -import { createConnectTransport } from '@connectrpc/connect-web' -import { ChatService, ScanService, SessionService } from '@cyber/aop' - -const transport = createConnectTransport({ - baseUrl: window.location.origin, - useBinaryFormat: false, // 标准 Protobuf JSON,便于浏览器调试 -}) - -const aiscan = { - chat: createClient(ChatService, transport), - sessions: createClient(SessionService, transport), - scans: createClient(ScanService, transport), -} -``` - -一次完整调用: - -```ts -const sessionId = crypto.randomUUID() - -const opened = await aiscan.chat.openSession({ - requestId: crypto.randomUUID(), - sessionId, - participant: agentId, - title: 'demo', -}) -if (opened.outcome.case !== 'accepted') throw new Error(opened.outcome.value.message) - -let cursor = '' -const controller = new AbortController() - -void (async () => { - while (!controller.signal.aborted) { - try { - for await (const response of aiscan.chat.watchEvents( - { sessionId, afterCursor: cursor }, - { signal: controller.signal }, - )) { - const delivery = response.delivery - if (!delivery?.event) continue - cursor = delivery.cursor - - const event = delivery.event - if (event.payload.case === 'messageDelta') { - const delta = event.payload.value - if (delta.value.case === 'text') console.log(delta.value.value) - } - if (event.payload.case === 'turnEnded') { - console.log(event.payload.value.stopReason) - } - } - } catch { - // 使用最后确认的 delivery cursor 重连;服务端先订阅 live stream, - // 再从 SQLite replay,因此重连窗口不会丢 durable event。 - } - } -})() - -const turnId = crypto.randomUUID() -const run = await aiscan.chat.runTurn({ - requestId: crypto.randomUUID(), - sessionId, - turnId, - input: { - id: crypto.randomUUID(), - role: 'user', - name: 'operator', - content: [{ value: { case: 'text', value: { text: '你好' } } }], - }, -}) -if (run.outcome.case !== 'accepted') throw new Error(run.outcome.value.message) -``` - -`WatchEvents` 是 Connect 的 server-streaming RPC。浏览器端表现为生成 client 提供的 -异步迭代器,底层使用 HTTP response stream;不再使用 `EventSource`,也没有旧的 -`event: aop` / `data:` 文本帧。 - -## grpc-go 接入 - -原生 gRPC 客户端继续使用同一个 `aop.ChatServiceClient`,无需迁移业务调用: - -```go -import aop "github.com/chainreactors/aiscan/aop" - -conn, err := grpc.NewClient( - "127.0.0.1:8080", - grpc.WithTransportCredentials(insecure.NewCredentials()), -) -if err != nil { /* handle */ } -defer conn.Close() - -ctx := metadata.AppendToOutgoingContext( - context.Background(), - "authorization", "Bearer "+token, -) -client := aop.NewChatServiceClient(conn) - -opened, err := client.OpenSession(ctx, &aop.OpenSessionRequest{ - RequestId: "open-1", - SessionId: "demo", - Participant: agentID, -}) -``` - -仓库示例: - -```bash -# 原生 grpc-go -go run ./examples/aop-chat -addr 127.0.0.1:8080 -token dev-token -agent '' -prompt '你好' - -# 浏览器兼容的 Connect protobuf JSON -go run ./examples/web-chat -url http://127.0.0.1:8080 -token dev-token -agent '' -prompt '你好' - -# 拥有独立 go.mod、只使用公共 SDK 的外部工具形态 -cd examples/external-go-client -go run . -url http://127.0.0.1:8080 -token dev-token -agent '' -prompt '你好' -``` - -## Terminal WebSocket - -浏览器 terminal WebSocket 与 AgentTransport 共用生成的 -`aiscan.transport.TerminalFrame`。浏览器传输使用标准 ProtoJSON,AgentTransport 的 -gRPC bidi 使用 protobuf binary,Agent WebSocket 使用相同 message 的 ProtoJSON。 -`pkg/web/terminal` 是 `pty.Frame` 与生成类型之间唯一的 Go codec;浏览器不再发送一套 -手写 snake_case terminal DTO。 - -## 单向流式与重连语义 - -原 SSE 的单向输出由 `WatchEvents` 完整替代: - -1. client 提交 `session_id` 和可选 `after_cursor`。 -2. server 先注册 live subscription,再读取 SQLite backlog。 -3. 每个响应包含 `EventDelivery { cursor, event }`。 -4. client 处理成功后保存 `cursor`。 -5. 网络断开后以该 cursor 重建 `WatchEvents`。 - -`Event.seq` 是 AOP session 内的语义顺序;`EventDelivery.cursor` 是持久化位置。重连 -只能使用 cursor,不能拿 `seq` 代替。 - -`message_delta` 是低延迟增量,允许在背压下丢弃;完整 `message`、`turn_ended` 和 -生命周期事件是可靠结果。UI 应用完整 `message` 覆盖增量拼接结果,并以唯一的 -`turn_ended` 结束本轮。 - -## 请求幂等与错误 - -`OpenSession`、`RunTurn`、`CancelTurn`、`CloseSession` 以及 AIScan 的变更类 RPC -都要求非空 `request_id`: - -- 同一方法、同一请求体重试:返回 SQLite journal 中的原响应,不重复执行。 -- 同一 ID 对应不同方法或请求体:返回 `ALREADY_EXISTS` rejection。 -- 业务拒绝位于 response 的 `rejected` oneof;传输/认证故障使用 Connect/gRPC code。 - -浏览器认证可使用现有 HttpOnly 登录 cookie,也可发送: - -```text -Authorization: Bearer dev-token -``` - -## 全链路验收 - -```bash -go generate ./proto -go test ./... - -cd examples/external-go-client -GOWORK=off go test ./... - -cd ../../web/frontend -npm run build -npx playwright test -``` - -## ResetSession(`/clear`) - -前端 `/clear` 不再清空或覆盖原 session,而是调用原子的产品 RPC: - -```text -ResetSession(old_session) - ├─ 创建同 participant 的 clean session - ├─ 关闭 old session,reason = "reset" - └─ 返回 { previous, current } -``` - -旧 session 的消息和事件历史完整保留,只新增一次 `session_ended(reason=reset)`;新 -session 只包含自己的 `session_started` 生命周期,不继承旧 turn/message。相同 -`request_id` 重试不会重复创建或重复生命周期事件。 - -## 调试要点 - -- `UNAUTHENTICATED`:Bearer token/cookie 缺失或无效。 -- `ALREADY_EXISTS`:`request_id` 被不同请求复用。 -- `UNAVAILABLE`:participant 对应 Agent 未连接,或代理未正确转发 HTTP/2。 -- accepted 后没有最终答案:继续读取 `WatchEvents`;`RunTurn` accepted 只代表接收。 -- 重复事件:持久化并提交 delivery cursor。 -- 取消错轮次:调用 `CancelTurn` 时必须同时传准确的 `session_id` 和 `turn_id`。 -- `/api/chat/*` 返回 404:这是预期 cutover;改用生成的 Connect/gRPC client。 - -## 验证命令 - -```bash -# 独立 Go module 编译 -cd examples/external-go-client && go test ./... - -# 独立进程 → Connect HTTP → Hub → fake Agent → WatchEvents 全链路 -go test ./pkg/web -run TestExternalGoModuleConnectClientEndToEnd -count=1 - -# AIScan Web:CRUD、真实 LLM round-trip、独立 Go client、断线 cursor replay -cd web/frontend -npx playwright test e2e/aiscan-web.spec.ts \ - --grep "Chat Session CRUD|Chat LLM round-trip|External Go Connect client|Connect stream reconnect" -``` diff --git a/examples/aop-chat/client.go b/examples/aop-chat/client.go deleted file mode 100644 index 91561d48..00000000 --- a/examples/aop-chat/client.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "io" - "os" - "time" - - aop "github.com/chainreactors/aiscan/aop" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" -) - -func main() { - addr := flag.String("addr", "127.0.0.1:8080", "AIScan gRPC address") - token := flag.String("token", os.Getenv("AISCAN_WEB_TOKEN"), "AIScan access token") - agentID := flag.String("agent", "", "connected Agent ID") - prompt := flag.String("prompt", "你好", "natural-language prompt") - flag.Parse() - if *agentID == "" { - fmt.Fprintln(os.Stderr, "-agent is required") - os.Exit(2) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - if *token != "" { - ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+*token) - } - conn, err := grpc.NewClient(*addr, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - fatal(err) - } - defer conn.Close() - client := aop.NewChatServiceClient(conn) - sessionID := fmt.Sprintf("example-%d", time.Now().UnixNano()) - opened, err := client.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open:" + sessionID, SessionId: sessionID, Participant: *agentID}) - if err != nil { - fatal(err) - } - if rejected := opened.GetRejected(); rejected != nil { - fatal(fmt.Errorf("open rejected: %s: %s", rejected.Code, rejected.Message)) - } - watch, err := client.WatchEvents(ctx, &aop.WatchEventsRequest{SessionId: sessionID}) - if err != nil { - fatal(err) - } - turnID := "turn:" + sessionID - run, err := client.RunTurn(ctx, &aop.RunTurnRequest{ - RequestId: turnID, SessionId: sessionID, TurnId: turnID, - Input: &aop.Message{Id: "input:" + sessionID, Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: *prompt}}}}}, - }) - if err != nil { - fatal(err) - } - if rejected := run.GetRejected(); rejected != nil { - fatal(fmt.Errorf("run rejected: %s: %s", rejected.Code, rejected.Message)) - } - for { - response, err := watch.Recv() - if err == io.EOF { - return - } - if err != nil { - fatal(err) - } - event := response.GetDelivery().GetEvent() - switch payload := event.Payload.(type) { - case *aop.Event_MessageDelta: - fmt.Print(payload.MessageDelta.GetText()) - case *aop.Event_Message: - fmt.Println() - for _, content := range payload.Message.Content { - fmt.Print(content.GetText().GetText()) - } - fmt.Println() - case *aop.Event_TurnEnded: - if event.TurnId == turnID { - if payload.TurnEnded.Error != nil { - fatal(fmt.Errorf("turn failed: %s: %s", payload.TurnEnded.Error.Code, payload.TurnEnded.Error.Message)) - } - return - } - case *aop.Event_Error: - fmt.Fprintln(os.Stderr, payload.Error.Message) - } - } -} - -func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) } diff --git a/examples/external-go-client/go.mod b/examples/external-go-client/go.mod deleted file mode 100644 index 6e67944d..00000000 --- a/examples/external-go-client/go.mod +++ /dev/null @@ -1,21 +0,0 @@ -module example.com/aiscan-external-client - -go 1.25.7 - -require ( - connectrpc.com/connect v1.20.0 - github.com/chainreactors/aiscan v0.0.0 -) - -require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect - google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect -) - -// Repository-local validation. An external project should remove this line and -// use a released AIScan version instead. -replace github.com/chainreactors/aiscan => ../.. diff --git a/examples/external-go-client/go.sum b/examples/external-go-client/go.sum deleted file mode 100644 index 8a58506d..00000000 --- a/examples/external-go-client/go.sum +++ /dev/null @@ -1,38 +0,0 @@ -connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= -connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/examples/external-go-client/main.go b/examples/external-go-client/main.go deleted file mode 100644 index 606eb391..00000000 --- a/examples/external-go-client/main.go +++ /dev/null @@ -1,164 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "net/http" - "os" - "strings" - "time" - - "connectrpc.com/connect" - aop "github.com/chainreactors/aiscan/aop" - aiscan "github.com/chainreactors/aiscan/aop/aiscan" -) - -func main() { - baseURL := flag.String("url", "http://127.0.0.1:8080", "AIScan Web base URL") - token := flag.String("token", os.Getenv("AISCAN_WEB_TOKEN"), "AIScan access token") - agentID := flag.String("agent", os.Getenv("AISCAN_AGENT_ID"), "connected Agent participant ID") - prompt := flag.String("prompt", "请用一句话介绍你的能力", "natural-language prompt") - timeout := flag.Duration("timeout", 10*time.Minute, "overall timeout") - flag.Parse() - if strings.TrimSpace(*agentID) == "" { - fatal(errors.New("-agent or AISCAN_AGENT_ID is required")) - } - - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - client := aiscan.NewClient(http.DefaultClient, *baseURL, connect.WithProtoJSON()) - sessionID := newID("session") - opened, err := client.Chat.OpenSession(ctx, authenticated(*token, &aop.OpenSessionRequest{ - RequestId: newID("open"), SessionId: sessionID, Participant: *agentID, Title: "external Go client", - })) - if err != nil { - fatal(err) - } - if rejected := opened.Msg.GetRejected(); rejected != nil { - fatal(fmt.Errorf("OpenSession rejected: %s: %s", rejected.Code, rejected.Message)) - } - - type watchResult struct { - stream *connect.ServerStreamForClient[aop.WatchEventsResponse] - err error - } - watchCtx, stopWatch := context.WithCancel(ctx) - defer stopWatch() - watchReady := make(chan watchResult, 1) - go func() { - stream, watchErr := client.Chat.WatchEvents(watchCtx, authenticated(*token, &aop.WatchEventsRequest{SessionId: sessionID})) - watchReady <- watchResult{stream: stream, err: watchErr} - }() - - turnID := newID("turn") - run, err := client.Chat.RunTurn(ctx, authenticated(*token, &aop.RunTurnRequest{ - RequestId: newID("run"), SessionId: sessionID, TurnId: turnID, - Input: &aop.Message{Id: newID("message"), Role: "user", Name: "external-tool", Content: []*aop.Content{{ - Value: &aop.Content_Text{Text: &aop.TextContent{Text: *prompt}}, - }}}, - })) - if err != nil { - fatal(err) - } - if rejected := run.Msg.GetRejected(); rejected != nil { - fatal(fmt.Errorf("RunTurn rejected: %s: %s", rejected.Code, rejected.Message)) - } - - initial := <-watchReady - if initial.err != nil { - fatal(initial.err) - } - if err := receiveTurn(ctx, client, *token, sessionID, turnID, initial.stream); err != nil { - fatal(err) - } -} - -func receiveTurn( - ctx context.Context, - client *aiscan.Client, - token, sessionID, turnID string, - stream *connect.ServerStreamForClient[aop.WatchEventsResponse], -) error { - var cursor string - var sawDelta bool - retry := 250 * time.Millisecond - for { - for stream.Receive() { - delivery := stream.Msg().GetDelivery() - if delivery == nil || delivery.Event == nil { - continue - } - cursor = delivery.Cursor - event := delivery.Event - if event.TurnId != turnID { - continue - } - switch payload := event.Payload.(type) { - case *aop.Event_MessageDelta: - if text := payload.MessageDelta.GetText(); text != "" { - sawDelta = true - fmt.Print(text) - } - case *aop.Event_Message: - if !sawDelta && payload.Message.GetRole() == "assistant" { - fmt.Print(messageText(payload.Message)) - } - case *aop.Event_Error: - fmt.Fprintf(os.Stderr, "\nprotocol error: %s\n", payload.Error.GetMessage()) - case *aop.Event_TurnEnded: - ended := payload.TurnEnded - fmt.Printf("\nstop=%s cursor=%s session=%s turn=%s\n", ended.GetStopReason(), cursor, sessionID, turnID) - if failure := ended.GetError(); failure != nil { - return fmt.Errorf("turn failed: %s: %s", failure.Code, failure.Message) - } - return nil - } - } - if ctx.Err() != nil { - return ctx.Err() - } - if err := stream.Err(); err != nil { - fmt.Fprintf(os.Stderr, "watch disconnected: %v; resuming after cursor %s\n", err, cursor) - } - select { - case <-time.After(retry): - case <-ctx.Done(): - return ctx.Err() - } - retry = min(retry*2, 5*time.Second) - next, err := client.Chat.WatchEvents(ctx, authenticated(token, &aop.WatchEventsRequest{ - SessionId: sessionID, AfterCursor: cursor, - })) - if err != nil { - continue - } - stream = next - } -} - -func authenticated[T any](token string, message *T) *connect.Request[T] { - request := connect.NewRequest(message) - if token != "" { - request.Header().Set("Authorization", "Bearer "+token) - } - return request -} - -func messageText(message *aop.Message) string { - var text strings.Builder - for _, content := range message.GetContent() { - text.WriteString(content.GetText().GetText()) - } - return text.String() -} - -func newID(prefix string) string { - return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) -} - -func fatal(err error) { - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) -} diff --git a/examples/web-chat/client.go b/examples/web-chat/client.go deleted file mode 100644 index 681c95fb..00000000 --- a/examples/web-chat/client.go +++ /dev/null @@ -1,303 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "connectrpc.com/connect" - aop "github.com/chainreactors/aiscan/aop" - "github.com/chainreactors/aiscan/aop/aopconnect" -) - -// Client demonstrates browser-compatible ConnectRPC. The public chat surface -// is still exactly the six generated aop.ChatService methods; ListAgents is the -// existing product REST endpoint used only to discover a participant. -type Client struct { - baseURL string - token string - http *http.Client - chat aopconnect.ChatServiceClient -} - -type Agent struct { - ID string `json:"id"` - Name string `json:"name"` - Busy bool `json:"busy"` - Status AgentStatus `json:"status"` -} - -type AgentStatus struct { - Provider string `json:"provider"` - Model string `json:"model"` - ConfigError string `json:"config_error"` -} - -type AskResult struct { - SessionID string - AgentID string - TurnID string - Output string - Stop string - Usage *aop.TokenUsage -} - -func NewClient(baseURL, token string) (*Client, error) { - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - u, err := url.Parse(baseURL) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil, fmt.Errorf("invalid AIScan Web URL %q", baseURL) - } - httpClient := &http.Client{} - return &Client{ - baseURL: baseURL, - token: strings.TrimSpace(token), - http: httpClient, - chat: aopconnect.NewChatServiceClient(httpClient, baseURL, connect.WithProtoJSON()), - }, nil -} - -func requestWithToken[T any](token string, message *T) *connect.Request[T] { - request := connect.NewRequest(message) - if token != "" { - request.Header().Set("Authorization", "Bearer "+token) - } - return request -} - -func (c *Client) OpenSession(ctx context.Context, request *aop.OpenSessionRequest) (*aop.OpenSessionResponse, error) { - response, err := c.chat.OpenSession(ctx, requestWithToken(c.token, request)) - if err != nil { - return nil, err - } - return response.Msg, nil -} - -func (c *Client) RunTurn(ctx context.Context, request *aop.RunTurnRequest) (*aop.RunTurnResponse, error) { - response, err := c.chat.RunTurn(ctx, requestWithToken(c.token, request)) - if err != nil { - return nil, err - } - return response.Msg, nil -} - -func (c *Client) CancelTurn(ctx context.Context, request *aop.CancelTurnRequest) (*aop.CancelTurnResponse, error) { - response, err := c.chat.CancelTurn(ctx, requestWithToken(c.token, request)) - if err != nil { - return nil, err - } - return response.Msg, nil -} - -func (c *Client) CloseSession(ctx context.Context, request *aop.CloseSessionRequest) (*aop.CloseSessionResponse, error) { - response, err := c.chat.CloseSession(ctx, requestWithToken(c.token, request)) - if err != nil { - return nil, err - } - return response.Msg, nil -} - -func (c *Client) WatchEvents(ctx context.Context, request *aop.WatchEventsRequest) (*connect.ServerStreamForClient[aop.WatchEventsResponse], error) { - return c.chat.WatchEvents(ctx, requestWithToken(c.token, request)) -} - -func (c *Client) ListEvents(ctx context.Context, request *aop.ListEventsRequest) (*aop.ListEventsResponse, error) { - response, err := c.chat.ListEvents(ctx, requestWithToken(c.token, request)) - if err != nil { - return nil, err - } - return response.Msg, nil -} - -func (c *Client) ListAgents(ctx context.Context) ([]Agent, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/agents", nil) - if err != nil { - return nil, err - } - if c.token != "" { - request.Header.Set("Authorization", "Bearer "+c.token) - } - response, err := c.http.Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - data, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - return nil, fmt.Errorf("HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(data))) - } - var agents []Agent - if err := json.NewDecoder(response.Body).Decode(&agents); err != nil { - return nil, err - } - return agents, nil -} - -// Ask is convenience only: it composes OpenSession, WatchEvents, RunTurn and -// the terminal-event loop without introducing another wire protocol. -func (c *Client) Ask(ctx context.Context, prompt, requestedAgentID string, onDelta func(string)) (*AskResult, error) { - if strings.TrimSpace(prompt) == "" { - return nil, errors.New("prompt is required") - } - agents, err := c.ListAgents(ctx) - if err != nil { - return nil, fmt.Errorf("list agents: %w", err) - } - agent, err := pickAgent(agents, requestedAgentID) - if err != nil { - return nil, err - } - sessionID := "session-" + newID() - opened, err := c.OpenSession(ctx, &aop.OpenSessionRequest{ - RequestId: "open-" + newID(), SessionId: sessionID, Participant: agent.ID, - Title: "API: " + truncateRunes(prompt, 48), - }) - if err != nil { - return nil, fmt.Errorf("open session: %w", err) - } - if rejected := opened.GetRejected(); rejected != nil { - return nil, rejectionError("open session", rejected) - } - - // A server-streaming Connect call may wait for its first response before the - // client call returns. Start it concurrently with RunTurn; the server's - // subscribe-before-replay implementation makes this race-free in both orders. - type watchResult struct { - stream *connect.ServerStreamForClient[aop.WatchEventsResponse] - err error - } - watchCtx, stopWatch := context.WithCancel(ctx) - defer stopWatch() - watchReady := make(chan watchResult, 1) - go func() { - stream, watchErr := c.WatchEvents(watchCtx, &aop.WatchEventsRequest{SessionId: sessionID}) - watchReady <- watchResult{stream: stream, err: watchErr} - }() - turnID := "turn-" + newID() - run, err := c.RunTurn(ctx, &aop.RunTurnRequest{ - RequestId: "run-" + newID(), SessionId: sessionID, TurnId: turnID, - Input: &aop.Message{Id: "message-" + newID(), Role: "user", Content: []*aop.Content{{ - Value: &aop.Content_Text{Text: &aop.TextContent{Text: prompt}}, - }}}, - }) - if err != nil { - return nil, fmt.Errorf("run turn: %w", err) - } - if rejected := run.GetRejected(); rejected != nil { - return nil, rejectionError("run turn", rejected) - } - var watch *connect.ServerStreamForClient[aop.WatchEventsResponse] - select { - case result := <-watchReady: - if result.err != nil { - return nil, fmt.Errorf("watch events: %w", result.err) - } - watch = result.stream - case <-ctx.Done(): - return nil, ctx.Err() - } - - result := &AskResult{SessionID: sessionID, AgentID: agent.ID, TurnID: turnID} - var deltas strings.Builder - for watch.Receive() { - event := watch.Msg().GetDelivery().GetEvent() - if event == nil || event.TurnId != turnID { - continue - } - switch payload := event.Payload.(type) { - case *aop.Event_MessageDelta: - text := payload.MessageDelta.GetText() - deltas.WriteString(text) - if onDelta != nil && text != "" { - onDelta(text) - } - case *aop.Event_Message: - if payload.Message.GetRole() == "assistant" { - if text := messageText(payload.Message); text != "" { - result.Output = text - } - } - case *aop.Event_TurnEnded: - result.Stop = payload.TurnEnded.GetStopReason() - result.Usage = payload.TurnEnded.GetUsage() - if failure := payload.TurnEnded.GetError(); failure != nil { - return nil, fmt.Errorf("turn failed: %s: %s", failure.Code, failure.Message) - } - if result.Output == "" { - result.Output = deltas.String() - } - return result, nil - case *aop.Event_Error: - if payload.Error != nil { - return nil, fmt.Errorf("turn error: %s", payload.Error.Message) - } - } - } - if err := watch.Err(); err != nil { - return nil, err - } - return nil, errors.New("event stream closed before turn_ended") -} - -func rejectionError(operation string, rejected *aop.Rejection) error { - return fmt.Errorf("%s rejected: %s: %s", operation, rejected.GetCode(), rejected.GetMessage()) -} - -func messageText(message *aop.Message) string { - var text strings.Builder - for _, content := range message.GetContent() { - text.WriteString(content.GetText().GetText()) - } - return text.String() -} - -func pickAgent(agents []Agent, requestedID string) (Agent, error) { - if requestedID != "" { - for _, agent := range agents { - if agent.ID == requestedID { - if agent.Status.Provider == "" { - return Agent{}, fmt.Errorf("agent %q has no LLM provider", requestedID) - } - return agent, nil - } - } - return Agent{}, fmt.Errorf("agent %q is not connected", requestedID) - } - var busy *Agent - for index := range agents { - if agents[index].Status.Provider == "" { - continue - } - if !agents[index].Busy { - return agents[index], nil - } - if busy == nil { - busy = &agents[index] - } - } - if busy != nil { - return *busy, nil - } - return Agent{}, errors.New("no connected LLM-capable agent") -} - -func newID() string { - value := make([]byte, 16) - _, _ = rand.Read(value) - return hex.EncodeToString(value) -} - -func truncateRunes(value string, limit int) string { - runes := []rune(strings.TrimSpace(value)) - if len(runes) <= limit { - return string(runes) - } - return string(runes[:limit]) + "..." -} diff --git a/examples/web-chat/client_test.go b/examples/web-chat/client_test.go deleted file mode 100644 index db73ff9b..00000000 --- a/examples/web-chat/client_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "connectrpc.com/connect" - aop "github.com/chainreactors/aiscan/aop" - "github.com/chainreactors/aiscan/aop/aopconnect" -) - -type exampleChatHandler struct { - aopconnect.UnimplementedChatServiceHandler - t *testing.T - token string - ready chan struct{} - events chan *aop.Event - once sync.Once -} - -func (h *exampleChatHandler) authenticate(header http.Header) { - h.t.Helper() - if got := header.Get("Authorization"); got != "Bearer "+h.token { - h.t.Errorf("Authorization = %q", got) - } -} - -func (h *exampleChatHandler) OpenSession(_ context.Context, request *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { - h.authenticate(request.Header()) - return connect.NewResponse(&aop.OpenSessionResponse{RequestId: request.Msg.RequestId, Outcome: &aop.OpenSessionResponse_Accepted{Accepted: &aop.Session{ - Id: request.Msg.SessionId, Participant: request.Msg.Participant, State: "open", Title: request.Msg.Title, - }}}), nil -} - -func (h *exampleChatHandler) RunTurn(ctx context.Context, request *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { - h.authenticate(request.Header()) - select { - case <-h.ready: - case <-ctx.Done(): - return nil, ctx.Err() - } - if request.Msg.Input.GetRole() != "user" || request.Msg.Input.GetId() == "" { - h.t.Errorf("RunTurn input = %v", request.Msg.Input) - } - turnID := request.Msg.TurnId - h.events <- &aop.Event{SessionId: request.Msg.SessionId, TurnId: turnID, Payload: &aop.Event_MessageDelta{MessageDelta: &aop.MessageDelta{ - MessageId: "assistant-1", Value: &aop.MessageDelta_Text{Text: "hello"}, - }}} - h.events <- &aop.Event{SessionId: request.Msg.SessionId, TurnId: turnID, Payload: &aop.Event_Message{Message: &aop.Message{ - Id: "assistant-1", Role: "assistant", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}, - }}} - h.events <- &aop.Event{SessionId: request.Msg.SessionId, TurnId: turnID, Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{ - StopReason: "completed", Usage: &aop.TokenUsage{TotalTokens: 3}, - }}} - return connect.NewResponse(&aop.RunTurnResponse{RequestId: request.Msg.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ - SessionId: request.Msg.SessionId, TurnId: turnID, State: "running", - }}}), nil -} - -func (h *exampleChatHandler) WatchEvents(ctx context.Context, request *connect.Request[aop.WatchEventsRequest], stream *connect.ServerStream[aop.WatchEventsResponse]) error { - h.authenticate(request.Header()) - h.once.Do(func() { close(h.ready) }) - cursor := 0 - for { - select { - case event := <-h.events: - cursor++ - if err := stream.Send(&aop.WatchEventsResponse{Delivery: &aop.EventDelivery{Cursor: string(rune('0' + cursor)), Event: event}}); err != nil { - return err - } - case <-ctx.Done(): - return ctx.Err() - } - } -} - -func TestAskUsesConnectChatServiceEndToEnd(t *testing.T) { - const token = "test-token" - handler := &exampleChatHandler{t: t, token: token, ready: make(chan struct{}), events: make(chan *aop.Event, 8)} - path, connectHandler := aopconnect.NewChatServiceHandler(handler) - mux := http.NewServeMux() - mux.Handle(path, connectHandler) - mux.HandleFunc("GET /api/agents", func(w http.ResponseWriter, request *http.Request) { - handler.authenticate(request.Header) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"id":"agent-1","name":"worker","status":{"provider":"openai","model":"test"}}]`)) - }) - server := httptest.NewServer(mux) - defer server.Close() - - client, err := NewClient(server.URL, token) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - var streamed strings.Builder - result, err := client.Ask(ctx, "hello", "", func(delta string) { streamed.WriteString(delta) }) - if err != nil { - t.Fatal(err) - } - if result.Output != "hello" || streamed.String() != "hello" || result.Stop != "completed" { - t.Fatalf("result = %+v streamed=%q", result, streamed.String()) - } - if result.SessionID == "" || result.TurnID == "" || result.AgentID != "agent-1" || result.Usage.GetTotalTokens() != 3 { - t.Fatalf("result identity/usage = %+v", result) - } -} - -func TestAskRequiresLLMCapableAgent(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"id":"scanner-only","status":{}}]`)) - })) - defer server.Close() - client, err := NewClient(server.URL, "") - if err != nil { - t.Fatal(err) - } - _, err = client.Ask(context.Background(), "hello", "", nil) - if err == nil || !strings.Contains(err.Error(), "no connected LLM-capable agent") { - t.Fatalf("error = %v", err) - } -} diff --git a/examples/web-chat/main.go b/examples/web-chat/main.go deleted file mode 100644 index dfcd0914..00000000 --- a/examples/web-chat/main.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "os" - "strings" - "time" -) - -func main() { - baseURL := flag.String("url", envOr("AISCAN_WEB_URL", "http://127.0.0.1:8080"), "AIScan Web base URL") - token := flag.String("token", os.Getenv("AISCAN_WEB_TOKEN"), "Web access token (or AISCAN_WEB_TOKEN)") - agentID := flag.String("agent", "", "connected agent ID; empty selects an LLM-capable agent") - prompt := flag.String("prompt", "", "natural-language input") - timeout := flag.Duration("timeout", 10*time.Minute, "maximum time to wait for turn_ended") - stream := flag.Bool("stream", false, "print text deltas while the agent runs") - flag.Parse() - - input := strings.TrimSpace(*prompt) - if input == "" { - input = strings.TrimSpace(strings.Join(flag.Args(), " ")) - } - if input == "" { - fmt.Fprintln(os.Stderr, "usage: go run ./examples/web-chat -prompt \"summarize the authorized target\"") - os.Exit(2) - } - client, err := NewClient(*baseURL, *token) - if err != nil { - fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - printedDelta := false - var onDelta func(string) - if *stream { - onDelta = func(delta string) { - printedDelta = true - fmt.Print(delta) - } - } - result, err := client.Ask(ctx, input, *agentID, onDelta) - if err != nil { - fatal(err) - } - if printedDelta { - fmt.Println() - } else { - fmt.Println(result.Output) - } - fmt.Fprintf(os.Stderr, "session=%s agent=%s stop=%s\n", result.SessionID, result.AgentID, result.Stop) -} - -func envOr(name, fallback string) string { - if value := strings.TrimSpace(os.Getenv(name)); value != "" { - return value - } - return fallback -} - -func fatal(err error) { - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) -} diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go index 8cd8a401..b970f960 100644 --- a/pkg/transport/transport.go +++ b/pkg/transport/transport.go @@ -20,8 +20,6 @@ func Run(ctx context.Context, option *cfg.Option, logger telemetry.Logger, input switch selected { case cfg.AgentTransportWeb: return webagent.RunWebSocket(ctx, option, logger) - case cfg.AgentTransportGRPC: - return webagent.RunGRPC(ctx, option, logger) case cfg.AgentTransportStdio: return runner.RunStdio(ctx, option, logger, input, output) default: diff --git a/pkg/web/agent/agent.go b/pkg/web/agent/agent.go index 6b22970c..d28acdde 100644 --- a/pkg/web/agent/agent.go +++ b/pkg/web/agent/agent.go @@ -7,35 +7,26 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/chainreactors/aiscan/agent" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + filepb "github.com/chainreactors/aiscan/aop/file" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/runner" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + configpb "github.com/chainreactors/aiscan/pkg/types/config" + reloadpb "github.com/chainreactors/aiscan/pkg/types/reload" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" ) func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { - return runRemoteAgent(ctx, option, logger, false) + return runRemoteAgent(ctx, option, logger) } -func RunGRPC(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { - return runRemoteAgent(ctx, option, logger, true) -} - -func runRemoteAgent(ctx context.Context, option *cfg.Option, logger telemetry.Logger, grpcTransport bool) error { - if option.WebURL != "" { - remoteOpt, err := fetchRemoteConfig(option.WebURL) - if err != nil { - logger.Warnf("fetch remote config from %s: %s (continuing with local config)", option.WebURL, err) - } else { - logger.Infof("fetched remote config from %s", option.WebURL) - cfg.MergeRemoteOption(option, remoteOpt) - } - } +func runRemoteAgent(ctx context.Context, option *cfg.Option, logger telemetry.Logger) error { if strings.TrimSpace(option.IOAURL) == "" { return fmt.Errorf("ioa.url is required for web node identity") } @@ -63,25 +54,22 @@ func runRemoteAgent(ctx context.Context, option *cfg.Option, logger telemetry.Lo defer rt.Close() chatHandler := &chatAgentHandler{ - rt: rt, - serverURL: option.WebURL, - app: application, - option: option, - logger: logger, + rt: rt, + app: application, + option: option, + logger: logger, + ready: make(chan struct{}), } connectionDone := make(chan struct{}) go func() { defer close(connectionDone) _ = application.WaitEngines(ctx) - transportName := "websocket" - if grpcTransport { - transportName = "grpc" - } - logger.Debugf("%s transport connection to %s", transportName, option.WebURL) + dialURL, _ := SplitAccessKey(option.ServerURL) + logger.Debugf("websocket transport connection to %s", dialURL) connection := connectionConfig{ - ServerURL: option.WebURL, + ServerURL: option.ServerURL, Name: runner.ResolveIOANodeName(option), Registry: application.Commands, AgentSubscribe: rt.Subscribe, @@ -91,17 +79,21 @@ func runRemoteAgent(ctx context.Context, option *cfg.Option, logger telemetry.Lo Chat: chatHandler, Node: identityRef, Runtime: DefaultRuntime(), - Status: func() *transport.AgentStatus { return agentStatus(option, application) }, - Menu: func() []*transport.CommandSpec { return agentCommandCatalog(application) }, + Status: func() *aop.AgentStatus { return agentStatus(option, application) }, + Menu: func() []*commandpb.Spec { return agentCommandCatalog(application) }, PTYRouter: func() (*pty.Router, error) { return NewPTYRouter(application.Commands), nil }, } - if grpcTransport { - _ = connectGenerated(ctx, connection, true) - } else { - _ = connect(ctx, connection) - } + _ = connect(ctx, connection) }() + if application.Provider == nil { + select { + case <-chatHandler.ready: + case <-ctx.Done(): + <-connectionDone + return nil + } + } if application.Provider == nil { logger.Warnf("no LLM provider configured; remote REPL and PTY are available, autonomous agent loop is disabled") <-ctx.Done() @@ -140,10 +132,11 @@ func runRemoteAgent(ctx context.Context, option *cfg.Option, logger telemetry.Lo type chatAgentHandler struct { rt *runner.AgentRuntime - serverURL string app *runner.App option *cfg.Option logger telemetry.Logger + ready chan struct{} + readyOnce sync.Once } func (h *chatAgentHandler) OpenSession(ctx context.Context, req *aop.OpenSessionRequest) *aop.OpenSessionResponse { @@ -162,7 +155,7 @@ func (h *chatAgentHandler) CloseSession(ctx context.Context, req *aop.CloseSessi return h.rt.CloseAOPSession(ctx, req) } -func (h *chatAgentHandler) Command(ctx context.Context, req *transport.CommandRequest) (*transport.CommandResult, error) { +func (h *chatAgentHandler) Command(ctx context.Context, req *commandpb.Request) (*commandpb.Result, error) { if h.rt == nil || req == nil || strings.TrimSpace(req.Line) == "" { return nil, fmt.Errorf("command line is required") } @@ -174,10 +167,10 @@ func (h *chatAgentHandler) Command(ctx context.Context, req *transport.CommandRe if err != nil { return nil, err } - return &transport.CommandResult{TaskId: req.TaskId, Result: encoded, MediaType: "application/json"}, nil + return &commandpb.Result{Data: encoded, MediaType: "application/json"}, nil } -func (h *chatAgentHandler) Upload(req *transport.FileUploadRequest) (*transport.FileResult, error) { +func (h *chatAgentHandler) Upload(req *filepb.UploadRequest) (*filepb.Result, error) { if req == nil { return nil, fmt.Errorf("upload request is required") } @@ -193,12 +186,17 @@ func (h *chatAgentHandler) Upload(req *transport.FileUploadRequest) (*transport. if err := os.WriteFile(dest, req.Data, 0o644); err != nil { return nil, err } - return &transport.FileResult{TaskId: req.TaskId, Filename: filename, Path: dest, Size: int64(len(req.Data))}, nil + return &filepb.Result{Filename: filename, Path: dest, Size: int64(len(req.Data))}, nil } -func (h *chatAgentHandler) ReloadConfig(serverURL string) (*transport.ConfigReloadResult, *transport.AgentStatus) { - provider, model, err := reloadAgentConfig(serverURL, h.rt, h.app, h.logger) - result := &transport.ConfigReloadResult{Ok: err == nil, Model: model} +func (h *chatAgentHandler) ReloadConfig(config *configpb.DistributeConfig) (*reloadpb.Result, *aop.AgentStatus) { + defer h.readyOnce.Do(func() { + if h.ready != nil { + close(h.ready) + } + }) + provider, model, err := reloadAgentConfig(config, h.rt, h.app, h.option, h.logger) + result := &reloadpb.Result{Ok: err == nil, Model: model} if err != nil { result.Error = err.Error() return result, nil @@ -208,26 +206,22 @@ func (h *chatAgentHandler) ReloadConfig(serverURL string) (*transport.ConfigRelo } // --------------------------------------------------------------------------- -// reloadAgentConfig re-fetches the hub config and hot-swaps the LLM provider so -// a running agent picks up a Settings change without a restart. Best-effort: a -// fetch/build failure leaves the current provider in place. serverURL is the hub -// base the agent already dials. Returns the live provider, resolved model, and -// true when the swap succeeded, so the caller can re-announce identity. +// reloadAgentConfig hot-swaps the LLM provider from the protobuf config carried +// by the application WebSocket. A build failure leaves the current provider in +// place and is reported through the reload result and AgentStatus. // --------------------------------------------------------------------------- -func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, app *runner.App, logger telemetry.Logger) (agent.Provider, string, error) { +func reloadAgentConfig(distribute *configpb.DistributeConfig, rt *runner.AgentRuntime, app *runner.App, option *cfg.Option, logger telemetry.Logger) (agent.Provider, string, error) { if rt == nil { return nil, "", fmt.Errorf("agent runtime is not configured") } if logger == nil { logger = telemetry.NopLogger() } - remoteOpt, err := fetchRemoteConfig(serverURL) - if err != nil { - logger.Warnf("config reload: fetch remote config: %s", err) - return nil, "", err + if distribute == nil { + return nil, "", fmt.Errorf("remote config is required") } - providerConfig := runner.ProviderConfig(remoteOpt) + providerConfig := runner.ProviderConfigFromProto(distribute.GetLlm()) resolved, err := agent.ResolveProvider(&providerConfig) if err != nil { logger.Warnf("config reload: resolve provider: %s", err) @@ -242,6 +236,9 @@ func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, app *runner.Ap app.Provider = provider app.ProviderConfig = *resolved rt.SetProvider(provider, *resolved) + if option != nil { + runner.ApplyResolvedProviderOptions(option, *resolved) + } logger.Importantf("config reloaded: provider=%s model=%s", provider.Name(), model) return provider, model, nil } @@ -254,7 +251,7 @@ func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, app *runner.Ap // hub on register: the static agent-scope menu commands plus one per loaded (and // non-internal) skill. The hub merges it with its hub-scope commands to build // the web "/" menu and /help, so the menu reflects what this agent can run. -func agentCommandCatalog(app *runner.App) []*transport.CommandSpec { +func agentCommandCatalog(app *runner.App) []*commandpb.Spec { specs := runner.RuntimeCommandSpecs() if app == nil || app.Skills == nil { return specs @@ -263,7 +260,7 @@ func agentCommandCatalog(app *runner.App) []*transport.CommandSpec { if strings.TrimSpace(sk.Name) == "" || sk.Internal { continue } - specs = append(specs, &transport.CommandSpec{ + specs = append(specs, &commandpb.Spec{ Name: "/skill:" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"), Description: sk.Description, }) @@ -271,8 +268,8 @@ func agentCommandCatalog(app *runner.App) []*transport.CommandSpec { return specs } -func agentStatus(option *cfg.Option, app *runner.App) *transport.AgentStatus { - status := new(transport.AgentStatus) +func agentStatus(option *cfg.Option, app *runner.App) *aop.AgentStatus { + status := new(aop.AgentStatus) if option != nil { status.Space = option.Space } @@ -318,7 +315,7 @@ func webNodeRef(option *cfg.Option) (protocols.NodeRef, error) { if option == nil { return protocols.NodeRef{}, fmt.Errorf("web node configuration is required") } - authority, err := protocols.CanonicalAuthority(option.WebURL) + authority, err := protocols.CanonicalAuthority(option.ServerURL) if err != nil { return protocols.NodeRef{}, fmt.Errorf("web node authority: %w", err) } diff --git a/pkg/web/agent/agent_test.go b/pkg/web/agent/agent_test.go index 85c98a2b..4d62d954 100644 --- a/pkg/web/agent/agent_test.go +++ b/pkg/web/agent/agent_test.go @@ -8,7 +8,7 @@ import ( func TestWebNodeRefUsesWebIdentity(t *testing.T) { ref, err := webNodeRef(&cfg.Option{ - AgentOptions: cfg.AgentOptions{WebURL: "https://secret@example.test/hub"}, + AgentOptions: cfg.AgentOptions{ServerURL: "https://secret@example.test/hub"}, IOAOptions: cfg.IOAOptions{IOANodeName: "worker-1"}, }) if err != nil { @@ -17,7 +17,7 @@ func TestWebNodeRefUsesWebIdentity(t *testing.T) { if ref.ID != "worker-1" || ref.Authority != "https://example.test/hub" { t.Fatalf("node ref = %#v", ref) } - if _, err := webNodeRef(&cfg.Option{AgentOptions: cfg.AgentOptions{WebURL: "https://example.test"}}); err == nil { + if _, err := webNodeRef(&cfg.Option{AgentOptions: cfg.AgentOptions{ServerURL: "https://example.test"}}); err == nil { t.Fatal("expected missing ioa.node_name error") } } diff --git a/pkg/web/agent/aop_tool.go b/pkg/web/agent/aop_tool.go index 53cc70ab..69a4dbba 100644 --- a/pkg/web/agent/aop_tool.go +++ b/pkg/web/agent/aop_tool.go @@ -14,7 +14,7 @@ import ( ) type aopToolExecutor interface { - ExecuteTool(context.Context, string, string) (tool.Result, error) + ExecuteTool(context.Context, string, string) (*tool.Result, error) } // toolResolver is an optional executor capability exposing the concrete tool @@ -27,13 +27,13 @@ type toolResolver interface { // foregroundTool is implemented by tools that run a command in the foreground // with streaming output, bypassing the agent-facing auto-background behavior. type foregroundTool interface { - RunForegroundTool(context.Context, string, commands.BashExecOptions) (tool.Result, error) + RunForegroundTool(context.Context, string, commands.BashExecOptions) (*tool.Result, error) } // executeCall runs the tool call. Tools with foreground capability stream // stdout lines as tool.data progress events on dataBus while running; all // other tools take the plain ExecuteTool path. -func executeCall(ctx context.Context, executor aopToolExecutor, call *aop.ToolCall, dataBus *eventbus.Bus[output.ToolDataEvent], callID string) (tool.Result, error) { +func executeCall(ctx context.Context, executor aopToolExecutor, call *aop.ToolCall, dataBus *eventbus.Bus[output.ToolDataEvent], callID string) (*tool.Result, error) { arguments := call.GetArguments().GetData() if len(arguments) == 0 { arguments = []byte("{}") @@ -43,7 +43,7 @@ func executeCall(ctx context.Context, executor aopToolExecutor, call *aop.ToolCa if fg, ok := resolved.(foregroundTool); ok { args, err := tool.ParseArgs[commands.BashArgs](string(arguments)) if err != nil { - return tool.Result{}, err + return nil, err } progress := newProgressStreamer(dataBus, call.Name, callID) result, err := fg.RunForegroundTool(ctx, args.Command, commands.BashExecOptions{ diff --git a/pkg/web/agent/aop_tool_test.go b/pkg/web/agent/aop_tool_test.go index fa7a25b6..9a425c3b 100644 --- a/pkg/web/agent/aop_tool_test.go +++ b/pkg/web/agent/aop_tool_test.go @@ -2,14 +2,13 @@ package agent import ( "context" - "encoding/base64" "errors" "strings" "testing" "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + toolpb "github.com/chainreactors/aiscan/aop/tool" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/tool" @@ -18,7 +17,7 @@ import ( type aopTestExecutor struct{} -func (aopTestExecutor) ExecuteTool(_ context.Context, name, arguments string) (tool.Result, error) { +func (aopTestExecutor) ExecuteTool(_ context.Context, name, arguments string) (*tool.Result, error) { return tool.TextResult(name + ":" + arguments), nil } @@ -26,20 +25,19 @@ type structuredResultExecutor struct { err error } -func (e structuredResultExecutor) ExecuteTool(context.Context, string, string) (tool.Result, error) { - return tool.Result{ - Content: []tool.ContentBlock{ - tool.TextBlock("partial"), - tool.ImageBlock("image/png", base64.StdEncoding.EncodeToString([]byte("image"))), +func (e structuredResultExecutor) ExecuteTool(context.Context, string, string) (*tool.Result, error) { + return &tool.Result{ + Output: []*aop.Content{ + aop.Text("partial"), + aop.Image("image/png", []byte("image")), }, - Details: map[string]any{"ports": float64(3)}, IsError: e.err == nil, Terminate: true, }, e.err } func TestExecuteToolRequestPreservesStructuredResult(t *testing.T) { - event, err := executeToolRequest(context.Background(), toolRequest(t, "call-structured", "scan", nil), structuredResultExecutor{}, nil) + event, err := executeToolRequest(context.Background(), "call-structured", toolRequest(t, "call-structured", "scan", nil), structuredResultExecutor{}, nil) if err != nil { t.Fatal(err) } @@ -50,14 +48,10 @@ func TestExecuteToolRequestPreservesStructuredResult(t *testing.T) { if len(result.Output) != 2 || result.Output[0].GetText().GetText() != "partial" || string(result.Output[1].GetMedia().GetResource().GetData()) != "image" { t.Fatalf("result output = %+v", result.Output) } - detail, err := aop.DecodeJSON[map[string]float64](result.Detail) - if err != nil || detail["ports"] != 3 { - t.Fatalf("detail = %+v, err=%v", detail, err) - } } func TestExecuteToolRequestUsesExecutionErrorText(t *testing.T) { - event, err := executeToolRequest(context.Background(), toolRequest(t, "call-error", "scan", nil), structuredResultExecutor{err: errors.New("failed")}, nil) + event, err := executeToolRequest(context.Background(), "call-error", toolRequest(t, "call-error", "scan", nil), structuredResultExecutor{err: errors.New("failed")}, nil) if err != nil { t.Fatal(err) } @@ -67,17 +61,17 @@ func TestExecuteToolRequestUsesExecutionErrorText(t *testing.T) { } } -func toolRequest(t *testing.T, id, name string, arguments map[string]any) *transport.ToolCallRequest { +func toolRequest(t *testing.T, id, name string, arguments map[string]any) *toolpb.Call { t.Helper() value, err := aop.JSONValue(arguments) if err != nil { t.Fatal(err) } - return &transport.ToolCallRequest{TaskId: id, SessionId: "session-1", TurnId: "turn-1", Call: &aop.ToolCall{Id: id, Name: name, Arguments: value}} + return &toolpb.Call{SessionId: "session-1", TurnId: "turn-1", Call: &aop.ToolCall{Id: id, Name: name, Arguments: value}} } func TestExecuteToolRequest(t *testing.T) { - event, err := executeToolRequest(context.Background(), toolRequest(t, "call-1", "echo", map[string]any{"value": "hello"}), aopTestExecutor{}, nil) + event, err := executeToolRequest(context.Background(), "call-1", toolRequest(t, "call-1", "echo", map[string]any{"value": "hello"}), aopTestExecutor{}, nil) if err != nil { t.Fatal(err) } @@ -89,8 +83,7 @@ func TestExecuteToolRequest(t *testing.T) { func TestExecuteToolRequestRejectsMismatchedCorrelation(t *testing.T) { request := toolRequest(t, "call-1", "echo", map[string]any{"value": "hello"}) - request.TaskId = "other" - if _, err := executeToolRequest(context.Background(), request, aopTestExecutor{}, nil); err == nil { + if _, err := executeToolRequest(context.Background(), "other", request, aopTestExecutor{}, nil); err == nil { t.Fatal("expected correlation error") } } @@ -102,20 +95,19 @@ type recordingBash struct { func (*recordingBash) Name() string { return "bash" } func (*recordingBash) Description() string { return "test bash" } -func (*recordingBash) Definition() tool.Definition { +func (*recordingBash) Definition() *tool.Definition { return tool.Def("bash", "test bash", struct { Command string `json:"command"` }{}) } -func (*recordingBash) Execute(context.Context, string) (tool.Result, error) { - return tool.Result{}, nil +func (*recordingBash) Execute(context.Context, string) (*tool.Result, error) { + return nil, nil } -func (b *recordingBash) RunForegroundTool(_ context.Context, command string, options commands.BashExecOptions) (tool.Result, error) { +func (b *recordingBash) RunForegroundTool(_ context.Context, command string, options commands.BashExecOptions) (*tool.Result, error) { b.command = command b.options = options options.OnOutput([]byte("streamed\n")) result := tool.TextResult("streamed") - result.Details = &output.Result{Summary: output.Summary{Targets: 2}} return result, nil } @@ -130,7 +122,7 @@ func TestExecuteToolRequestForeground(t *testing.T) { progress = append(progress, event) } }) - event, err := executeToolRequest(context.Background(), toolRequest(t, "task-1", "bash", map[string]any{"command": "echo test", "timeout": 7}), registry, dataBus) + event, err := executeToolRequest(context.Background(), "task-1", toolRequest(t, "task-1", "bash", map[string]any{"command": "echo test", "timeout": 7}), registry, dataBus) if err != nil { t.Fatal(err) } @@ -144,8 +136,4 @@ func TestExecuteToolRequestForeground(t *testing.T) { if result.IsError || result.Output[0].GetText().Text != "streamed" { t.Fatalf("result = %+v", result) } - structured, err := aop.DecodeJSON[output.Result](result.Detail) - if err != nil || structured.Summary.Targets != 2 { - t.Fatalf("detail = %+v, err=%v", structured, err) - } } diff --git a/pkg/web/agent/connection.go b/pkg/web/agent/connection.go index fa378935..11282772 100644 --- a/pkg/web/agent/connection.go +++ b/pkg/web/agent/connection.go @@ -4,47 +4,38 @@ import ( "context" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" ) -const DefaultWSPath = "/api/agent/ws" +const DefaultWSPath = "/api/aop/ws" type connectionConfig struct { - ServerURL string - WSPath string - Name string - Token string + ServerURL string + WSPath string + Name string + Token string + Capabilities []string Registry *commands.CommandRegistry AgentSubscribe func(func(*aop.Event)) func() DataBus *eventbus.Bus[output.ToolDataEvent] SCO *output.SCOSidecar Logger telemetry.Logger - Chat chatHandler + Chat *chatAgentHandler Node protocols.NodeRef - Runtime *transport.AgentRuntimeInfo - Status func() *transport.AgentStatus - Menu func() []*transport.CommandSpec + Runtime *aop.AgentRuntimeInfo + Status func() *aop.AgentStatus + Menu func() []*commandpb.Spec RunnerFileRPC bool PTYRouter func() (*pty.Router, error) } -type chatHandler interface { - OpenSession(context.Context, *aop.OpenSessionRequest) *aop.OpenSessionResponse - RunTurn(context.Context, *aop.RunTurnRequest) *aop.RunTurnResponse - CancelTurn(*aop.CancelTurnRequest) *aop.CancelTurnResponse - CloseSession(context.Context, *aop.CloseSessionRequest) *aop.CloseSessionResponse - Command(context.Context, *transport.CommandRequest) (*transport.CommandResult, error) - Upload(*transport.FileUploadRequest) (*transport.FileResult, error) - ReloadConfig(string) (*transport.ConfigReloadResult, *transport.AgentStatus) -} - func connect(ctx context.Context, config connectionConfig) error { - return connectGenerated(ctx, config, false) + return connectGenerated(ctx, config) } diff --git a/pkg/web/agent/connection_lifecycle_test.go b/pkg/web/agent/connection_lifecycle_test.go deleted file mode 100644 index 0d0580a1..00000000 --- a/pkg/web/agent/connection_lifecycle_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package agent - -import ( - "context" - "fmt" - "io" - "sync" - "testing" - "time" - - aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" - "github.com/chainreactors/aiscan/core/telemetry" - "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/ioa/protocols" -) - -type disconnectChatHandler struct { - started chan struct{} - canceled chan struct{} - once sync.Once -} - -func (h *disconnectChatHandler) OpenSession(context.Context, *aop.OpenSessionRequest) *aop.OpenSessionResponse { - return nil -} -func (h *disconnectChatHandler) RunTurn(ctx context.Context, request *aop.RunTurnRequest) *aop.RunTurnResponse { - h.once.Do(func() { close(h.started) }) - go func() { <-ctx.Done(); close(h.canceled) }() - return &aop.RunTurnResponse{RequestId: request.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: request.SessionId, TurnId: request.TurnId}}} -} -func (*disconnectChatHandler) CancelTurn(*aop.CancelTurnRequest) *aop.CancelTurnResponse { return nil } -func (*disconnectChatHandler) CloseSession(context.Context, *aop.CloseSessionRequest) *aop.CloseSessionResponse { - return nil -} -func (*disconnectChatHandler) Command(context.Context, *transport.CommandRequest) (*transport.CommandResult, error) { - return nil, fmt.Errorf("unused") -} -func (*disconnectChatHandler) Upload(*transport.FileUploadRequest) (*transport.FileResult, error) { - return nil, fmt.Errorf("unused") -} -func (*disconnectChatHandler) ReloadConfig(string) (*transport.ConfigReloadResult, *transport.AgentStatus) { - return nil, nil -} - -type disconnectStream struct { - ctx context.Context - handler *disconnectChatHandler - index int -} - -func (s *disconnectStream) Context() context.Context { return s.ctx } -func (*disconnectStream) Send(*transport.AgentFrame) error { return nil } -func (s *disconnectStream) Recv() (*transport.ServerFrame, error) { - s.index++ - switch s.index { - case 1: - return &transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{AgentId: "worker"}}}, nil - case 2: - return &transport.ServerFrame{CorrelationId: "turn-1", Payload: &transport.ServerFrame_RunTurn{RunTurn: &aop.RunTurnRequest{RequestId: "turn-1", SessionId: "chat-1", TurnId: "turn-1", Input: &aop.Message{Role: "user"}}}}, nil - default: - select { - case <-s.handler.started: - return nil, io.EOF - case <-time.After(time.Second): - return nil, fmt.Errorf("chat handler did not start") - } - } -} - -func TestAgentConnectionCancelsChatWhenStreamDisconnects(t *testing.T) { - handler := &disconnectChatHandler{started: make(chan struct{}), canceled: make(chan struct{})} - err := serveAgentConnection(context.Background(), connectionConfig{Name: "worker", Registry: commands.NewRegistry(), Chat: handler, Node: protocols.NodeRef{ID: "worker", Authority: "local"}}, telemetry.NopLogger(), &disconnectStream{ctx: context.Background(), handler: handler}) - if err == nil { - t.Fatal("connection returned nil after disconnect") - } - select { - case <-handler.canceled: - case <-time.After(500 * time.Millisecond): - t.Fatal("chat context remained alive after disconnect") - } -} diff --git a/pkg/web/agent/exec_test.go b/pkg/web/agent/exec_test.go index a47d666c..427ff003 100644 --- a/pkg/web/agent/exec_test.go +++ b/pkg/web/agent/exec_test.go @@ -5,7 +5,8 @@ import ( "runtime" "testing" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + execpb "github.com/chainreactors/aiscan/aop/exec" + protobuf "google.golang.org/protobuf/proto" ) func TestExecRequestCompletesWithOutput(t *testing.T) { @@ -13,10 +14,14 @@ func TestExecRequestCompletesWithOutput(t *testing.T) { if runtime.GOOS == "windows" { command = "echo|set /p=hello" } - var frames []*transport.AgentFrame - handleExecRequest(context.Background(), &transport.ExecRequest{TaskId: "exec-1", Command: command, TimeoutSeconds: 5}, t.TempDir(), func(frame *transport.AgentFrame) { frames = append(frames, frame) }) - if len(frames) != 2 || string(frames[0].GetExecOutput().Data) != "hello" || frames[1].GetExecResult().State != "completed" { - t.Fatalf("unexpected frames: %#v", frames) + var messages []*execpb.ProtocolMessage + handleExecRequest(context.Background(), &execpb.Request{Command: command, TimeoutSeconds: 5}, t.TempDir(), "exec-1", func(_ string, message protobuf.Message) { + if value, ok := message.(*execpb.ProtocolMessage); ok { + messages = append(messages, value) + } + }) + if len(messages) != 2 || string(messages[0].GetOutput().Data) != "hello" || messages[1].GetResult().State != "completed" { + t.Fatalf("unexpected messages: %#v", messages) } } @@ -25,10 +30,10 @@ func TestExecRequestReportsExitCode(t *testing.T) { if runtime.GOOS == "windows" { command = "exit /b 7" } - var result *transport.ExecResult - handleExecRequest(context.Background(), &transport.ExecRequest{TaskId: "exec-2", Command: command, TimeoutSeconds: 5}, t.TempDir(), func(frame *transport.AgentFrame) { - if frame.GetExecResult() != nil { - result = frame.GetExecResult() + var result *execpb.Result + handleExecRequest(context.Background(), &execpb.Request{Command: command, TimeoutSeconds: 5}, t.TempDir(), "exec-2", func(_ string, message protobuf.Message) { + if value, ok := message.(*execpb.ProtocolMessage); ok && value.GetResult() != nil { + result = value.GetResult() } }) if result == nil || result.ExitCode != 7 { diff --git a/pkg/web/agent/file_test.go b/pkg/web/agent/file_test.go index edd131f7..5413ab08 100644 --- a/pkg/web/agent/file_test.go +++ b/pkg/web/agent/file_test.go @@ -5,11 +5,17 @@ import ( "path/filepath" "testing" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + filepb "github.com/chainreactors/aiscan/aop/file" + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/ioa/protocols" ) func TestDefaultAgentRuntimeDoesNotAdvertiseRunnerFileRPCs(t *testing.T) { - for _, capability := range DefaultRuntime().Capabilities { + hello, err := BuildHello("agent", commands.NewRegistry(), protocols.NodeRef{ID: "agent", Authority: "local"}, DefaultRuntime()) + if err != nil { + t.Fatal(err) + } + for _, capability := range hello.Capabilities { if capability == "file.list" || capability == "file.mkdir" { t.Fatalf("regular agent advertised runner-only capability %q", capability) } @@ -24,14 +30,14 @@ func TestFileListReturnsStructuredEntries(t *testing.T) { if err := os.Mkdir(filepath.Join(base, "nested"), 0o755); err != nil { t.Fatal(err) } - value := fileList(&transport.FileListRequest{TaskId: "list-1", Path: "."}, base) + value := fileList(&filepb.ListRequest{Path: "."}, base) if value.err != nil { t.Fatal(value.err) } if value.result.Path != "." || len(value.result.Entries) != 2 { t.Fatalf("result = %+v", value.result) } - byName := map[string]*transport.FileEntry{} + byName := map[string]*filepb.Entry{} for _, entry := range value.result.Entries { byName[entry.Name] = entry } @@ -45,14 +51,14 @@ func TestFileListReturnsStructuredEntries(t *testing.T) { func TestNativeFileRPCsResolveRelativeToRuntimeWorkdir(t *testing.T) { base := t.TempDir() - if value := fileMkdir(&transport.FileMkdirRequest{TaskId: "mkdir-1", Path: "nested"}, base); value.err != nil { + if value := fileMkdir(&filepb.MkdirRequest{Path: "nested"}, base); value.err != nil { t.Fatal(value.err) } path := filepath.Join("nested", "proof.txt") - if value := fileWrite(&transport.FileWriteRequest{TaskId: "write-1", Path: path, Data: []byte("hello")}, base); value.err != nil { + if value := fileWrite(&filepb.WriteRequest{Path: path, Data: []byte("hello")}, base); value.err != nil { t.Fatal(value.err) } - value := fileRead(&transport.FileReadRequest{TaskId: "read-1", Path: path}, base) + value := fileRead(&filepb.ReadRequest{Path: path}, base) if value.err != nil || string(value.result.Data) != "hello" { t.Fatalf("read data = %q, err = %v", value.result.Data, value.err) } diff --git a/pkg/web/agent/identity.go b/pkg/web/agent/identity.go index c080a17e..fcea91c4 100644 --- a/pkg/web/agent/identity.go +++ b/pkg/web/agent/identity.go @@ -9,21 +9,20 @@ import ( "strings" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/ioa/protocols" + "google.golang.org/protobuf/types/known/structpb" ) // DefaultRuntime returns OS process metadata without introducing another // identity beside the IOA NodeRef. -func DefaultRuntime() *transport.AgentRuntimeInfo { - metadata, _ := aop.JSONValue(map[string]any{"client": "aiscan", "transport": "websocket"}) - runtimeInfo := &transport.AgentRuntimeInfo{ - Os: runtime.GOOS, - Arch: runtime.GOARCH, - Pid: int32(os.Getpid()), - Capabilities: []string{"repl", "pty", "tmux", "ioa"}, - Metadata: metadata, +func DefaultRuntime() *aop.AgentRuntimeInfo { + metadata, _ := structpb.NewStruct(map[string]any{"client": "aiscan", "transport": "websocket"}) + runtimeInfo := &aop.AgentRuntimeInfo{ + Os: runtime.GOOS, + Arch: runtime.GOARCH, + Pid: int32(os.Getpid()), + Metadata: metadata, } if host, err := os.Hostname(); err == nil { runtimeInfo.Hostname = host @@ -37,38 +36,18 @@ func DefaultRuntime() *transport.AgentRuntimeInfo { return runtimeInfo } -// BuildHello builds the transport-native agent registration frame. -func BuildHello(name string, reg *commands.CommandRegistry, ref protocols.NodeRef, runtimeInfo *transport.AgentRuntimeInfo, statusFn func() *transport.AgentStatus, menuFn func() []*transport.CommandSpec, stats *transport.AgentStats) (*transport.AgentHello, error) { +// BuildHello builds the AOP core agent registration message. +func BuildHello(name string, reg *commands.CommandRegistry, ref protocols.NodeRef, runtimeInfo *aop.AgentRuntimeInfo) (*aop.AgentHello, error) { if !ref.Valid() { return nil, fmt.Errorf("valid node reference is required") } if runtimeInfo == nil || runtimeInfo.Os == "" { runtimeInfo = DefaultRuntime() } - var status *transport.AgentStatus - if statusFn != nil { - status = statusFn() - } - if status == nil { - status = &transport.AgentStatus{} - } - if stats == nil { - stats = &transport.AgentStats{} - } - var menu []*transport.CommandSpec - if menuFn != nil { - menu = menuFn() - } - hello := &transport.AgentHello{ + hello := &aop.AgentHello{ AgentId: ref.ID, Authority: ref.Authority, Name: name, - Commands: reg.Names(), CommandMenu: menu, Runtime: runtimeInfo, Status: status, Stats: stats, - } - for _, definition := range reg.ToolDefinitions() { - schema, _ := aop.JSONValue(definition.Function.Parameters) - hello.Tools = append(hello.Tools, &transport.ToolDefinition{ - Type: definition.Type, Name: definition.Function.Name, - Description: definition.Function.Description, InputSchema: schema, - }) + Capabilities: []string{"repl", "pty", "tmux", "ioa", "file", "exec", "sco"}, + Runtime: runtimeInfo, Tools: reg.ToolDefinitions(), } return hello, nil } diff --git a/pkg/web/agent/proto_connection.go b/pkg/web/agent/proto_connection.go index 4b9ea65f..4e639f4e 100644 --- a/pkg/web/agent/proto_connection.go +++ b/pkg/web/agent/proto_connection.go @@ -3,87 +3,67 @@ package agent import ( "bytes" "context" - "crypto/tls" - "encoding/base64" "errors" "fmt" "net/http" - "net/url" "os" "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/chainreactors/aiscan/agent" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + execpb "github.com/chainreactors/aiscan/aop/exec" + filepb "github.com/chainreactors/aiscan/aop/file" + ptypb "github.com/chainreactors/aiscan/aop/pty" + toolpb "github.com/chainreactors/aiscan/aop/tool" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/core/tool" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + reloadpb "github.com/chainreactors/aiscan/pkg/types/reload" terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" "github.com/chainreactors/utils/pty" "github.com/gorilla/websocket" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" - "google.golang.org/protobuf/encoding/protojson" protobuf "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) -type AgentServerStream interface { - Context() context.Context - Recv() (*transport.ServerFrame, error) - Send(*transport.AgentFrame) error -} - -type closeableAgentServerStream interface { - AgentServerStream - Close() error -} - -type webSocketServerStream struct { - ctx context.Context +type webSocketEnvelopeStream struct { conn *websocket.Conn mu sync.Mutex } -func (s *webSocketServerStream) Context() context.Context { return s.ctx } -func (s *webSocketServerStream) Close() error { return s.conn.Close() } -func (s *webSocketServerStream) Recv() (*transport.ServerFrame, error) { +func (s *webSocketEnvelopeStream) Close() error { return s.conn.Close() } +func (s *webSocketEnvelopeStream) Recv() (*aop.Envelope, error) { _, data, err := s.conn.ReadMessage() if err != nil { return nil, err } - frame := new(transport.ServerFrame) - if err := protojson.Unmarshal(data, frame); err != nil { - return nil, fmt.Errorf("decode server frame: %w", err) + envelope := new(aop.Envelope) + if err := protobuf.Unmarshal(data, envelope); err != nil { + return nil, fmt.Errorf("decode AOP envelope: %w", err) } - return frame, nil + return envelope, nil } -func (s *webSocketServerStream) Send(frame *transport.AgentFrame) error { - data, err := protojson.Marshal(frame) + +func (s *webSocketEnvelopeStream) Send(envelope *aop.Envelope) error { + data, err := protobuf.Marshal(envelope) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() - return s.conn.WriteMessage(websocket.TextMessage, data) + return s.conn.WriteMessage(websocket.BinaryMessage, data) } -type grpcServerStream struct { - transport.AgentTransportService_ConnectClient - conn *grpc.ClientConn -} - -func (s *grpcServerStream) Close() error { return s.conn.Close() } - -func dialProtoWebSocket(ctx context.Context, cc connectionConfig) (closeableAgentServerStream, error) { +func dialProtoWebSocket(ctx context.Context, cc connectionConfig) (*webSocketEnvelopeStream, error) { dialURL, accessKey := SplitAccessKey(cc.ServerURL) if cc.Token != "" { accessKey = cc.Token @@ -103,41 +83,10 @@ func dialProtoWebSocket(ctx context.Context, cc connectionConfig) (closeableAgen if err != nil { return nil, err } - return &webSocketServerStream{ctx: ctx, conn: conn}, nil + return &webSocketEnvelopeStream{conn: conn}, nil } -func dialProtoGRPC(ctx context.Context, cc connectionConfig) (closeableAgentServerStream, error) { - rawURL, accessKey := SplitAccessKey(cc.ServerURL) - if cc.Token != "" { - accessKey = cc.Token - } - u, err := url.Parse(rawURL) - if err != nil || u.Host == "" { - return nil, fmt.Errorf("invalid gRPC server URL %q", rawURL) - } - var creds credentials.TransportCredentials - if strings.EqualFold(u.Scheme, "https") { - creds = credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()}) - } else { - creds = insecure.NewCredentials() - } - conn, err := grpc.NewClient(u.Host, grpc.WithTransportCredentials(creds)) - if err != nil { - return nil, err - } - streamCtx := ctx - if accessKey != "" { - streamCtx = metadata.AppendToOutgoingContext(streamCtx, "authorization", "Bearer "+accessKey) - } - stream, err := transport.NewAgentTransportServiceClient(conn).Connect(streamCtx) - if err != nil { - conn.Close() - return nil, err - } - return &grpcServerStream{AgentTransportService_ConnectClient: stream, conn: conn}, nil -} - -func connectGenerated(ctx context.Context, cc connectionConfig, grpcTransport bool) error { +func connectGenerated(ctx context.Context, cc connectionConfig) error { logger := cc.Logger if logger == nil { logger = telemetry.NopLogger() @@ -147,13 +96,7 @@ func connectGenerated(ctx context.Context, cc connectionConfig, grpcTransport bo if ctx.Err() != nil { return ctx.Err() } - var stream closeableAgentServerStream - var err error - if grpcTransport { - stream, err = dialProtoGRPC(ctx, cc) - } else { - stream, err = dialProtoWebSocket(ctx, cc) - } + stream, err := dialProtoWebSocket(ctx, cc) if err == nil { done := make(chan struct{}) go func() { @@ -181,40 +124,68 @@ func connectGenerated(ctx context.Context, cc connectionConfig, grpcTransport bo } } -func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telemetry.Logger, stream AgentServerStream) error { +var envelopeSequence atomic.Uint64 + +func nextEnvelopeID(prefix string) string { + return prefix + ":" + strconv.FormatInt(time.Now().UnixNano(), 36) + ":" + strconv.FormatUint(envelopeSequence.Add(1), 36) +} + +func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telemetry.Logger, stream aop.EnvelopeStream) error { if cc.Registry == nil { return fmt.Errorf("command registry is nil") } - hello, err := BuildHello(cc.Name, cc.Registry, cc.Node, cc.Runtime, cc.Status, cc.Menu, &transport.AgentStats{}) + hello, err := BuildHello(cc.Name, cc.Registry, cc.Node, cc.Runtime) + if err != nil { + return err + } + if len(cc.Capabilities) > 0 { + hello.Capabilities = append([]string(nil), cc.Capabilities...) + } else if cc.Chat == nil { + hello.Capabilities = []string{"pty", "file", "exec", "tool", "sco"} + } + helloEnvelope, err := aop.Wrap(nextEnvelopeID("hello"), "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentHello{AgentHello: hello}}) if err != nil { return err } - if err := stream.Send(&transport.AgentFrame{Payload: &transport.AgentFrame_Hello{Hello: hello}}); err != nil { + if err := stream.Send(helloEnvelope); err != nil { return err } - accepted, err := stream.Recv() + acceptedEnvelope, err := stream.Recv() if err != nil { return err } - if accepted.GetAccepted() == nil { - return fmt.Errorf("expected connection acceptance") + acceptedMessage, err := aop.Unwrap(acceptedEnvelope) + if err != nil { + return err + } + coreAccepted, ok := acceptedMessage.(*aop.ProtocolMessage) + if !ok || coreAccepted.GetAgentAccepted() == nil || acceptedEnvelope.ReplyTo != helloEnvelope.Id { + return fmt.Errorf("expected AOP agent acceptance") } connectionCtx, cancelConnection := context.WithCancel(ctx) defer cancelConnection() - sendCh := make(chan *transport.AgentFrame, 64) + sendCh := make(chan *aop.Envelope, 64) writeErr := make(chan error, 1) - send := func(frame *transport.AgentFrame) { + send := func(replyTo string, message protobuf.Message) { + envelope, wrapErr := aop.Wrap(nextEnvelopeID("agent"), replyTo, message) + if wrapErr != nil { + logger.Warnf("encode AOP message: %v", wrapErr) + return + } select { - case sendCh <- frame: + case sendCh <- envelope: case <-connectionCtx.Done(): } } go func() { for { select { - case frame := <-sendCh: - if err := stream.Send(frame); err != nil { + case envelope := <-sendCh: + if envelope == nil { + continue + } + if err := stream.Send(envelope); err != nil { select { case writeErr <- err: default: @@ -228,13 +199,20 @@ func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telem } }() + if cc.Menu != nil { + send("", &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Catalog{Catalog: &commandpb.Catalog{Commands: cc.Menu()}}}) + } stats := NewAgentStatsTracker() if cc.AgentSubscribe != nil { unsubscribe := cc.AgentSubscribe(func(event *aop.Event) { if next, changed := stats.Observe(event); changed { - send(&transport.AgentFrame{Payload: &transport.AgentFrame_Stats{Stats: next}}) + send("", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentStats{AgentStats: next}}) } - send(&transport.AgentFrame{CorrelationId: event.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) + replyTo := "" + if event.GetToolResult() != nil { + replyTo = event.GetToolResult().GetCallId() + } + send(replyTo, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: event}}) }) defer unsubscribe() } @@ -242,22 +220,26 @@ func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telem defer detach() } if cc.Status != nil { - go func(last *transport.AgentStatus) { + initial := cc.Status() + if initial != nil { + send("", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentStatus{AgentStatus: initial}}) + } + go func(last *aop.AgentStatus) { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-ticker.C: next := cc.Status() - if !protobuf.Equal(next, last) { - send(&transport.AgentFrame{Payload: &transport.AgentFrame_Status{Status: next}}) - last = protobuf.Clone(next).(*transport.AgentStatus) + if next != nil && !protobuf.Equal(next, last) { + send("", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentStatus{AgentStatus: next}}) + last = protobuf.Clone(next).(*aop.AgentStatus) } case <-connectionCtx.Done(): return } } - }(protobuf.Clone(hello.GetStatus()).(*transport.AgentStatus)) + }(cloneAgentStatus(initial)) } var router *pty.Router @@ -273,7 +255,7 @@ func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telem if cc.PTYRouter == nil { if manager := RegistryPTYManager(cc.Registry); manager != nil { unsubscribe := SubscribePTYSessions(connectionCtx, manager, router, func(frame pty.Frame) { - send(&transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}) + send("", terminalcodec.ToProto(frame)) }) defer unsubscribe() } @@ -281,8 +263,12 @@ func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telem var operationsMu sync.Mutex operations := make(map[string]context.CancelFunc) + namespaceMux, err := newAgentConnectionNamespaceMux(cc, router, send, &operationsMu, operations) + if err != nil { + return fmt.Errorf("register connection namespaces: %w", err) + } for { - frame, err := stream.Recv() + envelope, err := stream.Recv() if err != nil { select { case writerErr := <-writeErr: @@ -291,113 +277,266 @@ func serveAgentConnection(ctx context.Context, cc connectionConfig, logger telem } return err } - switch payload := frame.Payload.(type) { - case *transport.ServerFrame_OpenSession: - if cc.Chat != nil { - send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_OpenSession{OpenSession: cc.Chat.OpenSession(connectionCtx, payload.OpenSession)}}) - } - case *transport.ServerFrame_RunTurn: - if cc.Chat != nil { - send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_RunTurn{RunTurn: cc.Chat.RunTurn(connectionCtx, payload.RunTurn)}}) - } - case *transport.ServerFrame_CancelTurn: - if cc.Chat != nil { - send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CancelTurn{CancelTurn: cc.Chat.CancelTurn(payload.CancelTurn)}}) - } - case *transport.ServerFrame_CloseSession: - if cc.Chat != nil { - send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CloseSession{CloseSession: cc.Chat.CloseSession(connectionCtx, payload.CloseSession)}}) - } - case *transport.ServerFrame_Command: - go func(request *transport.CommandRequest, correlation string) { - if cc.Chat == nil { - send(operationFailure(request.GetTaskId(), "command handler is unavailable")) - return - } - result, err := cc.Chat.Command(connectionCtx, request) - if err != nil { - send(operationFailure(request.GetTaskId(), err.Error())) - return - } - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_CommandResult{CommandResult: result}}) - }(payload.Command, frame.CorrelationId) - case *transport.ServerFrame_ToolCall: - request := payload.ToolCall - taskCtx, taskCancel := context.WithCancel(connectionCtx) - trackOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) - go func() { - defer finishOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) - event, err := executeToolRequest(taskCtx, request, cc.Registry, cc.DataBus) - if err != nil { - send(operationFailure(request.GetTaskId(), err.Error())) - return - } - send(&transport.AgentFrame{CorrelationId: request.GetTaskId(), Payload: &transport.AgentFrame_Event{Event: event}}) - }() - case *transport.ServerFrame_FileRead: - go sendFileResult(frame.CorrelationId, fileRead(payload.FileRead, cc.Runtime.GetWorkingDir()), send) - case *transport.ServerFrame_FileWrite: - go sendFileResult(frame.CorrelationId, fileWrite(payload.FileWrite, cc.Runtime.GetWorkingDir()), send) - case *transport.ServerFrame_FileList: - if cc.RunnerFileRPC { - go sendFileResult(frame.CorrelationId, fileList(payload.FileList, cc.Runtime.GetWorkingDir()), send) - } - case *transport.ServerFrame_FileMkdir: - if cc.RunnerFileRPC { - go sendFileResult(frame.CorrelationId, fileMkdir(payload.FileMkdir, cc.Runtime.GetWorkingDir()), send) - } - case *transport.ServerFrame_FileUpload: - go func(request *transport.FileUploadRequest, correlation string) { - if cc.Chat == nil { - send(operationFailure(request.GetTaskId(), "upload handler is unavailable")) - return - } - result, err := cc.Chat.Upload(request) - if err != nil { - send(operationFailure(request.GetTaskId(), err.Error())) - return - } - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_FileResult{FileResult: result}}) - }(payload.FileUpload, frame.CorrelationId) - case *transport.ServerFrame_Exec: - request := payload.Exec - taskCtx, taskCancel := context.WithCancel(connectionCtx) - trackOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) - go func() { - defer finishOperation(&operationsMu, operations, request.GetTaskId(), taskCancel) - handleExecRequest(taskCtx, request, cc.Runtime.GetWorkingDir(), send) - }() - case *transport.ServerFrame_CancelOperation: - operationsMu.Lock() - operationCancel := operations[payload.CancelOperation.GetTaskId()] - operationsMu.Unlock() - if operationCancel != nil { - operationCancel() + handled, err := namespaceMux.Dispatch(connectionCtx, envelope, func(*aop.Envelope) error { return nil }) + if err != nil { + send(envelope.GetId(), protocolFailure("INVALID_PAYLOAD", err.Error())) + continue + } + if !handled { + send(envelope.GetId(), protocolFailure("UNSUPPORTED_NAMESPACE", "unsupported AOP namespace")) + } + } +} + +func cloneAgentStatus(value *aop.AgentStatus) *aop.AgentStatus { + if value == nil { + return nil + } + return protobuf.Clone(value).(*aop.AgentStatus) +} + +func newAgentConnectionNamespaceMux( + cc connectionConfig, + router *pty.Router, + send func(string, protobuf.Message), + operationsMu *sync.Mutex, + operations map[string]context.CancelFunc, +) (*aop.NamespaceMux, error) { + mux := aop.NewNamespaceMux() + if err := mux.Register(&aop.ProtocolMessage{}, func(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + handleAgentCoreMessage(ctx, cc, envelope, message.(*aop.ProtocolMessage), send, operationsMu, operations) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&commandpb.ProtocolMessage{}, func(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + handleAgentCommandMessage(ctx, cc, envelope, message.(*commandpb.ProtocolMessage), send) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&toolpb.ProtocolMessage{}, func(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + handleAgentToolMessage(ctx, cc, envelope, message.(*toolpb.ProtocolMessage), send, operationsMu, operations) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&filepb.ProtocolMessage{}, func(_ context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + handleAgentFileMessage(cc, envelope, message.(*filepb.ProtocolMessage), send) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&execpb.ProtocolMessage{}, func(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + handleAgentExecMessage(ctx, cc, envelope, message.(*execpb.ProtocolMessage), send, operationsMu, operations) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&reloadpb.ProtocolMessage{}, func(_ context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + handleAgentReloadMessage(cc, envelope, message.(*reloadpb.ProtocolMessage), send) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&ptypb.ProtocolMessage{}, func(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + handleAgentPTYMessage(ctx, router, envelope, message.(*ptypb.ProtocolMessage), send) + return nil + }); err != nil { + return nil, err + } + return mux, nil +} + +func handleAgentCoreMessage( + ctx context.Context, + cc connectionConfig, + envelope *aop.Envelope, + value *aop.ProtocolMessage, + send func(string, protobuf.Message), + operationsMu *sync.Mutex, + operations map[string]context.CancelFunc, +) { + replyTo := envelope.GetId() + fail := func(message string) { send(replyTo, protocolFailure("OPERATION_FAILED", message)) } + switch payload := value.Message.(type) { + case *aop.ProtocolMessage_OpenSessionRequest: + if cc.Chat == nil { + fail("chat handler is unavailable") + return + } + send(replyTo, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionResponse{OpenSessionResponse: cc.Chat.OpenSession(ctx, payload.OpenSessionRequest)}}) + case *aop.ProtocolMessage_RunTurnRequest: + if cc.Chat == nil { + fail("chat handler is unavailable") + return + } + send(replyTo, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_RunTurnResponse{RunTurnResponse: cc.Chat.RunTurn(ctx, payload.RunTurnRequest)}}) + case *aop.ProtocolMessage_CancelTurnRequest: + if cc.Chat == nil { + fail("chat handler is unavailable") + return + } + send(replyTo, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CancelTurnResponse{CancelTurnResponse: cc.Chat.CancelTurn(payload.CancelTurnRequest)}}) + case *aop.ProtocolMessage_CloseSessionRequest: + if cc.Chat == nil { + fail("chat handler is unavailable") + return + } + send(replyTo, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CloseSessionResponse{CloseSessionResponse: cc.Chat.CloseSession(ctx, payload.CloseSessionRequest)}}) + case *aop.ProtocolMessage_CancelOperation: + operationsMu.Lock() + cancel := operations[payload.CancelOperation.GetTargetId()] + operationsMu.Unlock() + if cancel != nil { + cancel() + } + default: + fail("unsupported AOP core message") + } +} + +func handleAgentCommandMessage(ctx context.Context, cc connectionConfig, envelope *aop.Envelope, value *commandpb.ProtocolMessage, send func(string, protobuf.Message)) { + replyTo := envelope.GetId() + fail := func(message string) { send(replyTo, protocolFailure("OPERATION_FAILED", message)) } + request := value.GetRequest() + if request == nil { + fail("unsupported AIScan command message") + return + } + go func() { + if cc.Chat == nil { + fail("command handler is unavailable") + return + } + result, err := cc.Chat.Command(ctx, request) + if err != nil { + fail(err.Error()) + return + } + send(replyTo, &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Result{Result: result}}) + }() +} + +func handleAgentToolMessage(ctx context.Context, cc connectionConfig, envelope *aop.Envelope, value *toolpb.ProtocolMessage, send func(string, protobuf.Message), operationsMu *sync.Mutex, operations map[string]context.CancelFunc) { + replyTo := envelope.GetId() + fail := func(message string) { send(replyTo, protocolFailure("OPERATION_FAILED", message)) } + request := value.GetCall() + if request == nil || request.Call == nil { + fail("unsupported AOP tool message") + return + } + operationID := envelope.GetId() + taskCtx, taskCancel := context.WithCancel(ctx) + trackOperation(operationsMu, operations, operationID, taskCancel) + go func() { + defer finishOperation(operationsMu, operations, operationID, taskCancel) + event, err := executeToolRequest(taskCtx, operationID, request, cc.Registry, cc.DataBus) + if err != nil { + fail(err.Error()) + return + } + send(replyTo, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: event}}) + }() +} + +func handleAgentFileMessage(cc connectionConfig, envelope *aop.Envelope, value *filepb.ProtocolMessage, send func(string, protobuf.Message)) { + replyTo := envelope.GetId() + fail := func(message string) { send(replyTo, protocolFailure("OPERATION_FAILED", message)) } + switch payload := value.Message.(type) { + case *filepb.ProtocolMessage_ReadRequest: + go sendFileResult(replyTo, fileRead(payload.ReadRequest, workingDir(cc.Runtime)), send) + case *filepb.ProtocolMessage_WriteRequest: + go sendFileResult(replyTo, fileWrite(payload.WriteRequest, workingDir(cc.Runtime)), send) + case *filepb.ProtocolMessage_ListRequest: + if !cc.RunnerFileRPC { + fail("file list is unavailable") + return + } + go sendFileResult(replyTo, fileList(payload.ListRequest, workingDir(cc.Runtime)), send) + case *filepb.ProtocolMessage_MkdirRequest: + if !cc.RunnerFileRPC { + fail("file mkdir is unavailable") + return + } + go sendFileResult(replyTo, fileMkdir(payload.MkdirRequest, workingDir(cc.Runtime)), send) + case *filepb.ProtocolMessage_UploadRequest: + go func() { + if cc.Chat == nil { + fail("upload handler is unavailable") + return } - case *transport.ServerFrame_ReloadConfig: - if cc.Chat != nil { - result, statusValue := cc.Chat.ReloadConfig(cc.ServerURL) - if statusValue != nil { - send(&transport.AgentFrame{Payload: &transport.AgentFrame_Status{Status: statusValue}}) - } - send(&transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_ConfigReload{ConfigReload: result}}) + result, err := cc.Chat.Upload(payload.UploadRequest) + if err != nil { + fail(err.Error()) + return } - case *transport.ServerFrame_Terminal: - router.Handle(connectionCtx, terminalcodec.FromProto(payload.Terminal), func(out pty.Frame) { - send(&transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(out)}}) - }) - } + send(replyTo, &filepb.ProtocolMessage{Message: &filepb.ProtocolMessage_Result{Result: result}}) + }() + default: + fail("unsupported AOP file message") } } -func operationFailure(taskID, message string) *transport.AgentFrame { - return &transport.AgentFrame{CorrelationId: taskID, Payload: &transport.AgentFrame_OperationError{OperationError: &transport.OperationError{TaskId: taskID, Message: message}}} +func handleAgentExecMessage(ctx context.Context, cc connectionConfig, envelope *aop.Envelope, value *execpb.ProtocolMessage, send func(string, protobuf.Message), operationsMu *sync.Mutex, operations map[string]context.CancelFunc) { + replyTo := envelope.GetId() + fail := func(message string) { send(replyTo, protocolFailure("OPERATION_FAILED", message)) } + request := value.GetRequest() + if request == nil { + fail("unsupported AOP exec message") + return + } + operationID := envelope.GetId() + taskCtx, taskCancel := context.WithCancel(ctx) + trackOperation(operationsMu, operations, operationID, taskCancel) + go func() { + defer finishOperation(operationsMu, operations, operationID, taskCancel) + handleExecRequest(taskCtx, request, workingDir(cc.Runtime), replyTo, send) + }() } + +func handleAgentReloadMessage(cc connectionConfig, envelope *aop.Envelope, value *reloadpb.ProtocolMessage, send func(string, protobuf.Message)) { + replyTo := envelope.GetId() + fail := func(message string) { send(replyTo, protocolFailure("OPERATION_FAILED", message)) } + request := value.GetRequest() + if request == nil || request.Config == nil || cc.Chat == nil { + fail("config reload request is unavailable") + return + } + result, status := cc.Chat.ReloadConfig(request.Config) + if status != nil { + send("", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentStatus{AgentStatus: status}}) + } + send(replyTo, &reloadpb.ProtocolMessage{Message: &reloadpb.ProtocolMessage_Result{Result: result}}) +} + +func handleAgentPTYMessage(ctx context.Context, router *pty.Router, envelope *aop.Envelope, value *ptypb.ProtocolMessage, send func(string, protobuf.Message)) { + if router == nil { + send(envelope.GetId(), protocolFailure("OPERATION_FAILED", "PTY router is unavailable")) + return + } + router.Handle(ctx, terminalcodec.FromProto(value), func(out pty.Frame) { + send(envelope.GetId(), terminalcodec.ToProto(out)) + }) +} + +func workingDir(runtimeInfo *aop.AgentRuntimeInfo) string { + if runtimeInfo == nil { + return "" + } + return runtimeInfo.WorkingDir +} + +func protocolFailure(code, message string) *aop.ProtocolMessage { + return &aop.ProtocolMessage{Message: &aop.ProtocolMessage_ProtocolError{ProtocolError: &aop.ProtocolError{Code: code, Message: message}}} +} + func trackOperation(mu *sync.Mutex, operations map[string]context.CancelFunc, id string, cancel context.CancelFunc) { mu.Lock() operations[id] = cancel mu.Unlock() } + func finishOperation(mu *sync.Mutex, operations map[string]context.CancelFunc, id string, cancel context.CancelFunc) { cancel() mu.Lock() @@ -405,41 +544,44 @@ func finishOperation(mu *sync.Mutex, operations map[string]context.CancelFunc, i mu.Unlock() } -func executeToolRequest(ctx context.Context, request *transport.ToolCallRequest, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent]) (*aop.Event, error) { - if request == nil || request.Call == nil || request.TaskId == "" || request.Call.Id != request.TaskId { +func executeToolRequest(ctx context.Context, operationID string, request *toolpb.Call, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent]) (*aop.Event, error) { + if request == nil || request.Call == nil || operationID == "" { return nil, fmt.Errorf("tool call correlation is invalid") } call := request.Call + if call.Id == "" { + call.Id = operationID + } + if call.Id != operationID { + return nil, fmt.Errorf("tool call id must match envelope id") + } if strings.TrimSpace(call.Name) == "" { return nil, fmt.Errorf("tool name is required") } if call.WorkingDirectory != "" { ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: call.WorkingDirectory}) } - ctx = output.ContextWithCallID(ctx, request.TaskId) + ctx = output.ContextWithCallID(ctx, operationID) started := time.Now() - result, execErr := executeCall(ctx, executor, call, dataBus, request.TaskId) - text := result.Text() - if execErr != nil { - text = execErr.Error() - } - content := []*aop.Content{aop.Text(text)} - for _, block := range result.Content { - if block.Type != "image" { - continue - } - data, err := base64.StdEncoding.DecodeString(block.Base64Data) - if err == nil { - content = append(content, aop.Image(block.MimeType, data)) - } + result, execErr := executeCall(ctx, executor, call, dataBus, operationID) + if result == nil { + result = &aop.ToolResult{} } - detail, _ := aop.JSONValue(result.Details) - event := &aop.Event{Id: request.TaskId, EmittedAt: timestamppb.Now(), SessionId: request.SessionId, TurnId: request.TurnId, Emitter: "aiscan.agent", Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{CallId: call.Id, Name: call.Name, Output: content, Detail: detail, Terminate: result.Terminate, IsError: execErr != nil || result.IsError, DurationMs: uint64(time.Since(started).Milliseconds())}}} - return event, nil + if execErr != nil { + result.IsError = true + result.Output = []*aop.Content{aop.Text(execErr.Error())} + } + result.CallId = call.Id + result.Name = call.Name + result.DurationMs = uint64(time.Since(started).Milliseconds()) + return &aop.Event{ + Id: nextEnvelopeID("event"), EmittedAt: timestamppb.Now(), SessionId: request.SessionId, + TurnId: request.TurnId, Emitter: "aiscan.agent", Payload: &aop.Event_ToolResult{ToolResult: result}, + }, nil } type fileResultValue struct { - result *transport.FileResult + result *filepb.Result err error } @@ -450,21 +592,17 @@ func resolveFileRPCPath(baseDir, path string) string { return filepath.Clean(filepath.Join(baseDir, path)) } -func sendFileResult(correlation string, value fileResultValue, send func(*transport.AgentFrame)) { +func sendFileResult(replyTo string, value fileResultValue, send func(string, protobuf.Message)) { if value.err != nil { - taskID := "" - if value.result != nil { - taskID = value.result.TaskId - } - send(operationFailure(taskID, value.err.Error())) + send(replyTo, protocolFailure("FILE_OPERATION_FAILED", value.err.Error())) return } - send(&transport.AgentFrame{CorrelationId: correlation, Payload: &transport.AgentFrame_FileResult{FileResult: value.result}}) + send(replyTo, &filepb.ProtocolMessage{Message: &filepb.ProtocolMessage_Result{Result: value.result}}) } -func fileRead(req *transport.FileReadRequest, base string) fileResultValue { - result := &transport.FileResult{} + +func fileRead(req *filepb.ReadRequest, base string) fileResultValue { + result := &filepb.Result{} if req != nil { - result.TaskId = req.TaskId result.Path = req.Path } if req == nil || req.Path == "" { @@ -475,10 +613,10 @@ func fileRead(req *transport.FileReadRequest, base string) fileResultValue { result.Size = int64(len(data)) return fileResultValue{result: result, err: err} } -func fileWrite(req *transport.FileWriteRequest, base string) fileResultValue { - result := &transport.FileResult{} + +func fileWrite(req *filepb.WriteRequest, base string) fileResultValue { + result := &filepb.Result{} if req != nil { - result.TaskId = req.TaskId result.Path = req.Path result.Size = int64(len(req.Data)) } @@ -491,10 +629,10 @@ func fileWrite(req *transport.FileWriteRequest, base string) fileResultValue { } return fileResultValue{result: result, err: os.WriteFile(path, req.Data, 0o644)} } -func fileList(req *transport.FileListRequest, base string) fileResultValue { - result := &transport.FileResult{} + +func fileList(req *filepb.ListRequest, base string) fileResultValue { + result := &filepb.Result{} if req != nil { - result.TaskId = req.TaskId result.Path = req.Path } if result.Path == "" { @@ -509,14 +647,14 @@ func fileList(req *transport.FileListRequest, base string) fileResultValue { if err != nil { return fileResultValue{result: result, err: err} } - result.Entries = append(result.Entries, &transport.FileEntry{Name: entry.Name(), IsDirectory: entry.IsDir(), Size: info.Size()}) + result.Entries = append(result.Entries, &filepb.Entry{Name: entry.Name(), IsDirectory: entry.IsDir(), Size: info.Size()}) } return fileResultValue{result: result} } -func fileMkdir(req *transport.FileMkdirRequest, base string) fileResultValue { - result := &transport.FileResult{} + +func fileMkdir(req *filepb.MkdirRequest, base string) fileResultValue { + result := &filepb.Result{} if req != nil { - result.TaskId = req.TaskId result.Path = req.Path } if req == nil || req.Path == "" { @@ -525,9 +663,9 @@ func fileMkdir(req *transport.FileMkdirRequest, base string) fileResultValue { return fileResultValue{result: result, err: os.MkdirAll(resolveFileRPCPath(base, req.Path), 0o755)} } -func handleExecRequest(ctx context.Context, req *transport.ExecRequest, base string, send func(*transport.AgentFrame)) { +func handleExecRequest(ctx context.Context, req *execpb.Request, base, replyTo string, send func(string, protobuf.Message)) { if req == nil || strings.TrimSpace(req.Command) == "" { - send(operationFailure(req.GetTaskId(), "command is required")) + send(replyTo, protocolFailure("INVALID_ARGUMENT", "command is required")) return } runCtx := ctx @@ -556,12 +694,12 @@ func handleExecRequest(ctx context.Context, req *transport.ExecRequest, base str command.Stderr = &stderr err := command.Run() if stdout.Len() > 0 { - send(&transport.AgentFrame{CorrelationId: req.TaskId, Payload: &transport.AgentFrame_ExecOutput{ExecOutput: &transport.ExecOutput{TaskId: req.TaskId, Stream: transport.ExecStream_EXEC_STREAM_STDOUT, Data: stdout.Bytes()}}}) + send(replyTo, &execpb.ProtocolMessage{Message: &execpb.ProtocolMessage_Output{Output: &execpb.Output{Stream: execpb.Stream_STREAM_STDOUT, Data: stdout.Bytes()}}}) } if stderr.Len() > 0 { - send(&transport.AgentFrame{CorrelationId: req.TaskId, Payload: &transport.AgentFrame_ExecOutput{ExecOutput: &transport.ExecOutput{TaskId: req.TaskId, Stream: transport.ExecStream_EXEC_STREAM_STDERR, Data: stderr.Bytes()}}}) + send(replyTo, &execpb.ProtocolMessage{Message: &execpb.ProtocolMessage_Output{Output: &execpb.Output{Stream: execpb.Stream_STREAM_STDERR, Data: stderr.Bytes()}}}) } - result := &transport.ExecResult{TaskId: req.TaskId, State: "completed"} + result := &execpb.Result{State: "completed"} if err != nil { var exitErr *exec.ExitError switch { @@ -576,9 +714,9 @@ func handleExecRequest(ctx context.Context, req *transport.ExecRequest, base str case errors.As(err, &exitErr): result.ExitCode = int32(exitErr.ExitCode()) default: - send(operationFailure(req.TaskId, err.Error())) + send(replyTo, protocolFailure("EXEC_FAILED", err.Error())) return } } - send(&transport.AgentFrame{CorrelationId: req.TaskId, Payload: &transport.AgentFrame_ExecResult{ExecResult: result}}) + send(replyTo, &execpb.ProtocolMessage{Message: &execpb.ProtocolMessage_Result{Result: result}}) } diff --git a/pkg/web/agent/remote.go b/pkg/web/agent/remote.go deleted file mode 100644 index c712e4de..00000000 --- a/pkg/web/agent/remote.go +++ /dev/null @@ -1,102 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - cfg "github.com/chainreactors/aiscan/core/config" -) - -func fetchRemoteConfig(webURL string) (*cfg.Option, error) { - baseURL, accessKey := SplitAccessKey(webURL) - url := strings.TrimRight(baseURL, "/") + "/api/config/distribute" - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - if accessKey != "" { - req.Header.Set("Authorization", "Bearer "+accessKey) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("fetch remote config: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("remote config: HTTP %d", resp.StatusCode) - } - - var dc cfg.DistributeConfig - if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { - return nil, fmt.Errorf("decode remote config: %w", err) - } - cfg.MigrateLLMConfig(&dc.LLM, cfg.LLMProviderConfig{}) - return distributeToOption(&dc), nil -} - -func distributeToOption(d *cfg.DistributeConfig) *cfg.Option { - opt := &cfg.Option{ - LLMOptions: cfg.LLMOptions{ - ActiveProfile: d.LLM.ActiveProfile, - Providers: llmProviderEntries(d.LLM.Providers), - }, - ScannerOptions: cfg.ScannerOptions{ - CyberhubURL: d.Cyberhub.URL, - CyberhubKey: d.Cyberhub.Key, - CyberhubMode: d.Cyberhub.Mode, - Proxy: d.Cyberhub.Proxy, - }, - AgentOptions: cfg.AgentOptions{ - Tools: d.Agent.Tools, - Timeout: d.Agent.Timeout, - SaveSession: d.Agent.SaveSession, - }, - IOAOptions: cfg.IOAOptions{ - IOAURL: d.IOA.URL, - IOAToken: d.IOA.Token, - IOANodeName: d.IOA.NodeName, - Space: d.IOA.Space, - }, - ScanConfig: cfg.ScanConfigOptions{ - Verify: d.Scan.Verify, - }, - SearchConfig: cfg.SearchConfigOptions{ - TavilyKeys: d.Search.TavilyKeys, - }, - } - opt.FofaEmail = d.Recon.FofaEmail - opt.FofaKey = d.Recon.FofaKey - opt.HunterToken = d.Recon.HunterToken - opt.HunterAPIKey = d.Recon.HunterAPIKey - opt.ReconProxy = d.Recon.Proxy - opt.ReconLimit = d.Recon.Limit - if d.Search.TavilyKeys != "" { - opt.SearchConfig.TavilyKeys = cfg.ResolveString(opt.SearchConfig.TavilyKeys, d.Search.TavilyKeys) - } - return opt -} - -func llmProviderEntries(profiles []cfg.LLMProviderConfig) []cfg.LLMProviderEntry { - entries := make([]cfg.LLMProviderEntry, 0, len(profiles)) - for _, p := range profiles { - entries = append(entries, cfg.LLMProviderEntry{ - ID: p.ID, - Name: p.Name, - Provider: p.Provider, - BaseURL: p.BaseURL, - APIKey: p.APIKey, - Model: p.Model, - Proxy: p.Proxy, - MaxTokens: p.MaxTokens, - ContextWindow: p.ContextWindow, - }) - } - return entries -} diff --git a/pkg/web/agent/remote_test.go b/pkg/web/agent/remote_test.go deleted file mode 100644 index 8740f01d..00000000 --- a/pkg/web/agent/remote_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package agent - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - cfg "github.com/chainreactors/aiscan/core/config" -) - -func TestFetchRemoteConfigUsesBearerTokenFromURL(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/api/config/distribute", func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer reload-token" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - var value cfg.DistributeConfig - value.LLM.ActiveProfile = "p1" - value.LLM.Providers = []cfg.LLMProviderConfig{ - {ID: "p1", Provider: "openai", Model: "deepseek-chat", MaxTokens: 8192, ContextWindow: 128000}, - {ID: "p2", Provider: "openai", Model: "gpt-5"}, - } - _ = json.NewEncoder(w).Encode(value) - }) - server := httptest.NewServer(mux) - defer server.Close() - - authURL := strings.Replace(server.URL, "http://", "http://reload-token@", 1) - option, err := fetchRemoteConfig(authURL) - if err != nil { - t.Fatal(err) - } - if option.ActiveProfile != "p1" || len(option.Providers) != 2 { - t.Fatalf("unexpected remote option: %+v", option.LLMOptions) - } - primary := option.Providers[0] - if primary.Provider != "openai" || primary.Model != "deepseek-chat" { - t.Fatalf("unexpected primary profile: %+v", primary) - } - if primary.MaxTokens != 8192 || primary.ContextWindow != 128000 { - t.Fatalf("remote model limits were not propagated: %+v", primary) - } -} diff --git a/pkg/web/agent/stream.go b/pkg/web/agent/stream.go index 3f4ebddd..be1abaaa 100644 --- a/pkg/web/agent/stream.go +++ b/pkg/web/agent/stream.go @@ -4,14 +4,13 @@ import ( "sync" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "google.golang.org/protobuf/proto" ) // AgentStatsTracker tracks agent event statistics for the WebSocket connection. type AgentStatsTracker struct { mu sync.Mutex - stats transport.AgentStats + stats aop.AgentStats } // NewAgentStatsTracker creates a new stats tracker. @@ -20,19 +19,19 @@ func NewAgentStatsTracker() *AgentStatsTracker { } // Snapshot returns the current stats snapshot. -func (t *AgentStatsTracker) Snapshot() *transport.AgentStats { +func (t *AgentStatsTracker) Snapshot() *aop.AgentStats { if t == nil { - return &transport.AgentStats{} + return &aop.AgentStats{} } t.mu.Lock() defer t.mu.Unlock() - return proto.Clone(&t.stats).(*transport.AgentStats) + return proto.Clone(&t.stats).(*aop.AgentStats) } // Observe records an AOP event and returns updated stats if the stats changed. -func (t *AgentStatsTracker) Observe(e *aop.Event) (*transport.AgentStats, bool) { +func (t *AgentStatsTracker) Observe(e *aop.Event) (*aop.AgentStats, bool) { if t == nil { - return &transport.AgentStats{}, false + return &aop.AgentStats{}, false } t.mu.Lock() defer t.mu.Unlock() @@ -56,7 +55,7 @@ func (t *AgentStatsTracker) Observe(e *aop.Event) (*transport.AgentStats, bool) t.stats.RunningTools-- } default: - return proto.Clone(&t.stats).(*transport.AgentStats), false + return proto.Clone(&t.stats).(*aop.AgentStats), false } - return proto.Clone(&t.stats).(*transport.AgentStats), true + return proto.Clone(&t.stats).(*aop.AgentStats), true } diff --git a/pkg/web/agent/toolnode.go b/pkg/web/agent/toolnode.go index 2964b17c..ee80d99a 100644 --- a/pkg/web/agent/toolnode.go +++ b/pkg/web/agent/toolnode.go @@ -10,12 +10,15 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + scopb "github.com/chainreactors/aiscan/aop/sco" + toolpb "github.com/chainreactors/aiscan/aop/tool" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/telemetry" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/ioa/protocols" + protobuf "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -58,9 +61,8 @@ func RunToolNode(ctx context.Context, cfg ToolNodeConfig) error { logger = telemetry.NopLogger() } runnerRuntime := DefaultRuntime() - runnerRuntime.Capabilities = append(runnerRuntime.Capabilities, "file.read", "file.write", "file.list", "file.mkdir") home, _ := os.UserHomeDir() - runnerRuntime.Metadata, _ = aop.JSONValue(map[string]any{ + runnerRuntime.Metadata, _ = structpb.NewStruct(map[string]any{ "version": cfg.Version, "mode": "tool", "home": home, @@ -79,6 +81,7 @@ func RunToolNode(ctx context.Context, cfg ToolNodeConfig) error { Logger: logger, Node: protocols.NodeRef{ID: runnerID, Authority: authority}, Runtime: runnerRuntime, + Capabilities: []string{"pty", "file", "exec", "tool", "sco"}, RunnerFileRPC: true, }) } @@ -86,20 +89,26 @@ func RunToolNode(ctx context.Context, cfg ToolNodeConfig) error { // attachToolEvents forwards scanner telemetry (tool.data) and normalized SCO // nodes (tool.sco) onto the hub connection, correlated by call ID. Returns an // idempotent detach func, or nil when both sources are absent. -func attachToolEvents(dataBus *eventbus.Bus[output.ToolDataEvent], sco *output.SCOSidecar, send func(*transport.AgentFrame)) func() { +func attachToolEvents(dataBus *eventbus.Bus[output.ToolDataEvent], sco *output.SCOSidecar, send func(string, protobuf.Message)) func() { if dataBus == nil && sco == nil { return nil } var unsub func() if dataBus != nil { unsub = dataBus.Subscribe(func(event output.ToolDataEvent) { - data, _ := aop.JSONValue(event.Data) + if event.Kind != output.ToolDataProgress { + return + } + text, ok := event.Data.(string) + if !ok || text == "" { + return + } timestamp := event.Timestamp if timestamp.IsZero() { timestamp = time.Now() } - send(&transport.AgentFrame{CorrelationId: event.CallID, Payload: &transport.AgentFrame_ToolTelemetry{ToolTelemetry: &transport.ToolTelemetry{ - Tool: event.Tool, Kind: event.Kind, Target: event.Target, Data: data, CallId: event.CallID, Timestamp: timestamppb.New(timestamp), + send(event.CallID, &toolpb.ProtocolMessage{Message: &toolpb.ProtocolMessage_Progress{Progress: &toolpb.Progress{ + Tool: event.Tool, Target: event.Target, Text: text, Timestamp: timestamppb.New(timestamp), }}}) }) } @@ -109,7 +118,7 @@ func attachToolEvents(dataBus *eventbus.Bus[output.ToolDataEvent], sco *output.S for _, node := range nodes { encoded = append(encoded, append([]byte(nil), node...)) } - send(&transport.AgentFrame{CorrelationId: callID, Payload: &transport.AgentFrame_ScoNodes{ScoNodes: &transport.ScoNodes{CallId: callID, Nodes: encoded}}}) + send(callID, &scopb.ProtocolMessage{Message: &scopb.ProtocolMessage_Nodes{Nodes: &scopb.Nodes{Nodes: encoded, MediaType: aop.JSONMediaType}}}) } } var once bool diff --git a/pkg/web/agent/toolnode_test.go b/pkg/web/agent/toolnode_test.go index e2a94869..83db9237 100644 --- a/pkg/web/agent/toolnode_test.go +++ b/pkg/web/agent/toolnode_test.go @@ -10,46 +10,52 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + filepb "github.com/chainreactors/aiscan/aop/file" + toolpb "github.com/chainreactors/aiscan/aop/tool" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/gorilla/websocket" - "google.golang.org/protobuf/encoding/protojson" + protobuf "google.golang.org/protobuf/proto" ) var testUpgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} type hubScript struct { t *testing.T - registered chan *transport.AgentHello + registered chan *aop.AgentHello toolResult chan *aop.ToolResult progress chan string fileData chan []byte - toolData chan *transport.ToolTelemetry + toolData chan *toolpb.Progress } func newHubScript(t *testing.T) *hubScript { - return &hubScript{t: t, registered: make(chan *transport.AgentHello, 1), toolResult: make(chan *aop.ToolResult, 1), progress: make(chan string, 16), fileData: make(chan []byte, 1), toolData: make(chan *transport.ToolTelemetry, 4)} + return &hubScript{ + t: t, registered: make(chan *aop.AgentHello, 1), toolResult: make(chan *aop.ToolResult, 1), + progress: make(chan string, 16), fileData: make(chan []byte, 1), toolData: make(chan *toolpb.Progress, 4), + } } -func readAgentFrame(conn *websocket.Conn) (*transport.AgentFrame, error) { +func readAgentEnvelope(conn *websocket.Conn) (*aop.Envelope, protobuf.Message, error) { _, data, err := conn.ReadMessage() if err != nil { - return nil, err + return nil, nil, err } - frame := new(transport.AgentFrame) - if err := protojson.Unmarshal(data, frame); err != nil { - return nil, err + envelope := new(aop.Envelope) + if err := protobuf.Unmarshal(data, envelope); err != nil { + return nil, nil, err } - return frame, nil + message, err := aop.Unwrap(envelope) + return envelope, message, err } -func writeServerFrame(conn *websocket.Conn, frame *transport.ServerFrame) error { - data, err := protojson.Marshal(frame) + +func writeAgentEnvelope(conn *websocket.Conn, envelope *aop.Envelope) error { + data, err := protobuf.Marshal(envelope) if err != nil { return err } - return conn.WriteMessage(websocket.TextMessage, data) + return conn.WriteMessage(websocket.BinaryMessage, data) } func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { @@ -64,46 +70,50 @@ func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { return } defer conn.Close() - first, err := readAgentFrame(conn) - if err != nil || first.GetHello() == nil { - h.t.Errorf("expected hello: %v %v", first, err) + first, message, err := readAgentEnvelope(conn) + core, ok := message.(*aop.ProtocolMessage) + if err != nil || !ok || core.GetAgentHello() == nil { + h.t.Errorf("expected hello: %v %v", message, err) return } - h.registered <- first.GetHello() - if err := writeServerFrame(conn, &transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{AgentId: "runner-1"}}}); err != nil { + h.registered <- core.GetAgentHello() + if err := writeAgentEnvelope(conn, aop.MustWrap("accepted", first.Id, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentAccepted{AgentAccepted: &aop.AgentAccepted{AgentId: "runner-1"}}})); err != nil { return } go h.drive(conn) for { - frame, err := readAgentFrame(conn) + _, message, err := readAgentEnvelope(conn) if err != nil { return } - switch payload := frame.Payload.(type) { - case *transport.AgentFrame_Event: - if result := payload.Event.GetToolResult(); result != nil { + switch value := message.(type) { + case *aop.ProtocolMessage: + if result := value.GetEvent().GetToolResult(); result != nil { h.toolResult <- result } - case *transport.AgentFrame_ToolTelemetry: - telemetry := payload.ToolTelemetry - if telemetry.Kind == output.ToolDataProgress { - line, _ := aop.DecodeJSON[string](telemetry.Data) - h.progress <- line - } else { - h.toolData <- telemetry + case *toolpb.ProtocolMessage: + progress := value.GetProgress() + if progress == nil { + continue + } + h.progress <- progress.Text + case *filepb.ProtocolMessage: + if result := value.GetResult(); result != nil { + h.fileData <- result.Data } - case *transport.AgentFrame_FileResult: - h.fileData <- payload.FileResult.Data } } } func (h *hubScript) drive(conn *websocket.Conn) { arguments, _ := aop.JSONValue(map[string]any{"command": "echo hello"}) - _ = writeServerFrame(conn, &transport.ServerFrame{CorrelationId: "exec-1", Payload: &transport.ServerFrame_ToolCall{ToolCall: &transport.ToolCallRequest{TaskId: "exec-1", SessionId: "exec-1", TurnId: "exec-1", Call: &aop.ToolCall{Id: "exec-1", Name: "bash", Arguments: arguments}}}}) + _ = writeAgentEnvelope(conn, aop.MustWrap("exec-1", "", &toolpb.ProtocolMessage{Message: &toolpb.ProtocolMessage_Call{Call: &toolpb.Call{ + SessionId: "exec-1", TurnId: "exec-1", Call: &aop.ToolCall{Id: "exec-1", Name: "bash", Arguments: arguments}, + }}})) } + func (h *hubScript) driveFileRead(conn *websocket.Conn, path string) { - _ = writeServerFrame(conn, &transport.ServerFrame{CorrelationId: "read-1", Payload: &transport.ServerFrame_FileRead{FileRead: &transport.FileReadRequest{TaskId: "read-1", Path: path}}}) + _ = writeAgentEnvelope(conn, aop.MustWrap("read-1", "", &filepb.ProtocolMessage{Message: &filepb.ProtocolMessage_ReadRequest{ReadRequest: &filepb.ReadRequest{Path: path}}})) } func wait[T any](t *testing.T, ch <-chan T, what string) T { @@ -138,16 +148,16 @@ func TestRunToolNodeWireInterop(t *testing.T) { if hello.Runtime.Os == "" { t.Fatalf("runtime missing OS: %+v", hello.Runtime) } - metadata, err := aop.DecodeJSON[map[string]any](hello.Runtime.Metadata) - if err != nil || metadata["home"] == "" { - t.Fatalf("runtime metadata = %+v, err=%v", metadata, err) + metadata := hello.Runtime.Metadata.AsMap() + if metadata["home"] == "" { + t.Fatalf("runtime metadata = %+v", metadata) } capabilities := map[string]bool{} - for _, capability := range hello.Runtime.Capabilities { + for _, capability := range hello.Capabilities { capabilities[capability] = true } - if !capabilities["file.list"] || !capabilities["file.mkdir"] { - t.Fatalf("capabilities = %+v", hello.Runtime.Capabilities) + if !capabilities["file"] || !capabilities["tool"] || !capabilities["sco"] { + t.Fatalf("capabilities = %+v", hello.Capabilities) } if len(hello.Tools) != 1 || hello.Tools[0].Name != "bash" { t.Fatalf("tools = %+v", hello.Tools) @@ -159,10 +169,6 @@ func TestRunToolNodeWireInterop(t *testing.T) { if result.IsError || result.CallId != "exec-1" || result.Name != "bash" { t.Fatalf("tool result = %+v", result) } - dataBus.Emit(output.ToolDataEvent{Tool: "gogo", Kind: "service", CallID: "exec-1"}) - if telemetry := wait(t, hub.toolData, "tool telemetry"); telemetry.CallId != "exec-1" { - t.Fatalf("telemetry = %+v", telemetry) - } cancel() select { case <-errCh: @@ -185,22 +191,23 @@ func TestRunToolNodeFileRead(t *testing.T) { return } defer conn.Close() - first, err := readAgentFrame(conn) - if err != nil || first.GetHello() == nil { + first, message, err := readAgentEnvelope(conn) + core, ok := message.(*aop.ProtocolMessage) + if err != nil || !ok || core.GetAgentHello() == nil { return } - hub.registered <- first.GetHello() - if writeServerFrame(conn, &transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{AgentId: "runner-1"}}}) != nil { + hub.registered <- core.GetAgentHello() + if writeAgentEnvelope(conn, aop.MustWrap("accepted", first.Id, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentAccepted{AgentAccepted: &aop.AgentAccepted{AgentId: "runner-1"}}})) != nil { return } hub.driveFileRead(conn, path) for { - frame, err := readAgentFrame(conn) + _, message, err := readAgentEnvelope(conn) if err != nil { return } - if result := frame.GetFileResult(); result != nil { - hub.fileData <- result.Data + if value, ok := message.(*filepb.ProtocolMessage); ok && value.GetResult() != nil { + hub.fileData <- value.GetResult().Data return } } diff --git a/pkg/web/agent/upload_test.go b/pkg/web/agent/upload_test.go index 0b9b68cb..5726c263 100644 --- a/pkg/web/agent/upload_test.go +++ b/pkg/web/agent/upload_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + filepb "github.com/chainreactors/aiscan/aop/file" ) func TestUploadWritesAbsolutePath(t *testing.T) { @@ -13,7 +13,7 @@ func TestUploadWritesAbsolutePath(t *testing.T) { const body = "codex public proof\nkey=appImage/probe" dest := filepath.Join(os.TempDir(), "aiscan-uploads", filename) t.Cleanup(func() { _ = os.Remove(dest) }) - result, err := (&chatAgentHandler{}).Upload(&transport.FileUploadRequest{TaskId: "task-1", SessionId: "sess-1", Filename: filename, Data: []byte(body)}) + result, err := (&chatAgentHandler{}).Upload(&filepb.UploadRequest{SessionId: "sess-1", Filename: filename, Data: []byte(body)}) if err != nil { t.Fatal(err) } diff --git a/pkg/web/agent_connect.go b/pkg/web/agent_connect.go new file mode 100644 index 00000000..bbdc048f --- /dev/null +++ b/pkg/web/agent_connect.go @@ -0,0 +1,55 @@ +package web + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "github.com/chainreactors/aiscan/pkg/rpc/agent/agentconnect" + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" +) + +type connectAgentServer struct { + agentconnect.UnimplementedAgentServiceHandler + pool *AgentPool + local *LocalAgents +} + +func (s *connectAgentServer) ListLocalAgents(context.Context, *connect.Request[agentpb.ListLocalAgentsRequest]) (*connect.Response[agentpb.ListLocalAgentsResponse], error) { + response := &agentpb.ListLocalAgentsResponse{} + if s.local != nil { + response.Agents = s.local.List() + } + return connect.NewResponse(response), nil +} + +func (s *connectAgentServer) LaunchLocalAgent(ctx context.Context, _ *connect.Request[agentpb.LaunchLocalAgentRequest]) (*connect.Response[agentpb.LaunchLocalAgentResponse], error) { + if s.local == nil { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("local agent launcher is unavailable")) + } + agent, err := s.local.Launch(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + } + return connect.NewResponse(&agentpb.LaunchLocalAgentResponse{Agent: agent}), nil +} + +func (s *connectAgentServer) StopLocalAgent(_ context.Context, req *connect.Request[agentpb.StopLocalAgentRequest]) (*connect.Response[agentpb.StopLocalAgentResponse], error) { + if s.local == nil { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("local agent launcher is unavailable")) + } + if err := s.local.Stop(req.Msg.GetName()); err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return connect.NewResponse(&agentpb.StopLocalAgentResponse{}), nil +} + +func (s *connectAgentServer) ListAgents(context.Context, *connect.Request[agentpb.ListAgentsRequest]) (*connect.Response[agentpb.ListAgentsResponse], error) { + response := &agentpb.ListAgentsResponse{} + if s.pool != nil { + response.Agents = s.pool.List() + } + return connect.NewResponse(response), nil +} + +var _ agentconnect.AgentServiceHandler = (*connectAgentServer)(nil) diff --git a/pkg/web/agent_stream.go b/pkg/web/agent_stream.go index 1e7ddad5..8f028b21 100644 --- a/pkg/web/agent_stream.go +++ b/pkg/web/agent_stream.go @@ -3,79 +3,44 @@ package web import ( "context" "fmt" - "net/http" "sync" "time" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + aop "github.com/chainreactors/aiscan/aop" + reloadpb "github.com/chainreactors/aiscan/pkg/types/reload" "github.com/chainreactors/ioa/protocols" "github.com/gorilla/websocket" - "google.golang.org/protobuf/encoding/protojson" protobuf "google.golang.org/protobuf/proto" ) -type ServerAgentStream interface { - Context() context.Context - Recv() (*transport.AgentFrame, error) - Send(*transport.ServerFrame) error -} - -type agentTransportServer struct { - transport.UnimplementedAgentTransportServiceServer - pool *AgentPool -} - -func NewAgentTransportServer(pool *AgentPool) transport.AgentTransportServiceServer { - return &agentTransportServer{pool: pool} -} - -func (s *agentTransportServer) Connect(stream transport.AgentTransportService_ConnectServer) error { - if s.pool == nil { - return fmt.Errorf("agent pool is unavailable") - } - return s.pool.ServeAgentStream(stream) -} - -type webSocketAgentStream struct { - ctx context.Context +type webSocketEnvelopeStream struct { conn *websocket.Conn mu sync.Mutex } -func (s *webSocketAgentStream) Context() context.Context { return s.ctx } - -func (s *webSocketAgentStream) Recv() (*transport.AgentFrame, error) { +func (s *webSocketEnvelopeStream) Recv() (*aop.Envelope, error) { _, data, err := s.conn.ReadMessage() if err != nil { return nil, err } - frame := new(transport.AgentFrame) - if err := protojson.Unmarshal(data, frame); err != nil { - return nil, fmt.Errorf("decode agent frame: %w", err) + envelope := new(aop.Envelope) + if err := protobuf.Unmarshal(data, envelope); err != nil { + return nil, fmt.Errorf("decode AOP envelope: %w", err) } - return frame, nil + return envelope, nil } -func (s *webSocketAgentStream) Send(frame *transport.ServerFrame) error { - data, err := protojson.Marshal(frame) +func (s *webSocketEnvelopeStream) Send(envelope *aop.Envelope) error { + data, err := protobuf.Marshal(envelope) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() - return s.conn.WriteMessage(websocket.TextMessage, data) -} - -func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) { - conn, err := p.upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - _ = p.ServeAgentStream(&webSocketAgentStream{ctx: r.Context(), conn: conn}) + return s.conn.WriteMessage(websocket.BinaryMessage, data) } -func (p *AgentPool) ServeAgentStream(stream ServerAgentStream) error { +func (p *AgentPool) ServeAgentStream(ctx context.Context, stream aop.EnvelopeStream) error { if stream == nil { return fmt.Errorf("agent stream is required") } @@ -83,43 +48,49 @@ func (p *AgentPool) ServeAgentStream(stream ServerAgentStream) error { if err != nil { return err } - hello := first.GetHello() - if hello == nil { - return fmt.Errorf("first agent frame must contain hello") + return p.serveAgentStream(ctx, stream, first) +} + +func (p *AgentPool) serveAgentStream(parent context.Context, stream aop.EnvelopeStream, first *aop.Envelope) error { + message, err := aop.Unwrap(first) + if err != nil { + return err + } + core, ok := message.(*aop.ProtocolMessage) + if !ok || core.GetAgentHello() == nil { + return fmt.Errorf("first AOP envelope must contain agent_hello") } + hello := core.GetAgentHello() if hello.AgentId == "" || hello.Authority == "" { return fmt.Errorf("hello agent_id and authority are required") } node := protocols.NodeRef{ID: hello.AgentId, Authority: hello.Authority} - id := agentKey(hello.AgentId, hello.Authority) - if id == "" { + nodeURI := agentKey(hello.AgentId, hello.Authority) + if nodeURI == "" { return fmt.Errorf("agent identity is required") } name := hello.Name if name == "" { name = "agent" } - runtimeInfo := &transport.AgentRuntimeInfo{} + runtimeInfo := &aop.AgentRuntimeInfo{} if hello.Runtime != nil { - runtimeInfo = protobuf.Clone(hello.Runtime).(*transport.AgentRuntimeInfo) - } - statusValue := &transport.AgentStatus{} - if hello.Status != nil { - statusValue = protobuf.Clone(hello.Status).(*transport.AgentStatus) - } - statsValue := &transport.AgentStats{} - if hello.Stats != nil { - statsValue = protobuf.Clone(hello.Stats).(*transport.AgentStats) + runtimeInfo = protobuf.Clone(hello.Runtime).(*aop.AgentRuntimeInfo) } - ctx, cancel := context.WithCancel(stream.Context()) + ctx, cancel := context.WithCancel(parent) agent := &remoteAgent{ - id: id, name: name, commands: append([]string(nil), hello.Commands...), commandsMenu: cloneCommandSpecs(hello.CommandMenu), - close: cancel, sendCh: make(chan *transport.ServerFrame, 32), controlCh: make(chan *transport.ServerFrame, 32), - connectAt: time.Now(), node: node, runtime: runtimeInfo, status: statusValue, stats: statsValue, + nodeURI: nodeURI, name: name, capabilities: append([]string(nil), hello.Capabilities...), + close: cancel, sendCh: make(chan *aop.Envelope, 64), + connectAt: time.Now(), node: node, runtime: runtimeInfo, + status: &aop.AgentStatus{}, stats: &aop.AgentStats{}, tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: make(map[string]struct{}), childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), } + namespaceMux, err := p.newAgentNamespaceMux(agent) + if err != nil { + return fmt.Errorf("register agent namespaces: %w", err) + } p.register(agent) defer func() { cancel() @@ -127,54 +98,60 @@ func (p *AgentPool) ServeAgentStream(stream ServerAgentStream) error { close(agent.done) }() - if err := stream.Send(&transport.ServerFrame{Payload: &transport.ServerFrame_Accepted{Accepted: &transport.ConnectionAccepted{ - AgentId: agent.id, Name: agent.name, Capabilities: agent.runtime.GetCapabilities(), - }}}); err != nil { + accepted, err := aop.Wrap(generateID(), first.Id, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentAccepted{ + AgentAccepted: &aop.AgentAccepted{AgentId: hello.AgentId, Capabilities: append([]string(nil), hello.Capabilities...)}, + }}) + if err != nil { + return err + } + if err := stream.Send(accepted); err != nil { return err } + if p.config != nil { + if config, configErr := p.config(ctx); configErr == nil && config != nil { + reload, wrapErr := aop.Wrap(generateID(), "", &reloadpb.ProtocolMessage{Message: &reloadpb.ProtocolMessage_Request{Request: &reloadpb.Request{Config: config}}}) + if wrapErr != nil { + return wrapErr + } + if err := stream.Send(reload); err != nil { + return err + } + } + } writeErr := make(chan error, 1) go func() { for { - var frame *transport.ServerFrame select { - case frame = <-agent.controlCh: - default: - select { - case frame = <-agent.controlCh: - case frame = <-agent.sendCh: - case <-ctx.Done(): - return + case envelope := <-agent.sendCh: + if envelope == nil { + continue } - } - if frame == nil { - continue - } - if frame.GetReloadConfig() != nil { - agent.finishConfigReload() - } - if err := stream.Send(frame); err != nil { - select { - case writeErr <- err: - default: + if err := stream.Send(envelope); err != nil { + select { + case writeErr <- err: + default: + } + cancel() + return } - cancel() + case <-ctx.Done(): return } } }() - recvCh := make(chan *transport.AgentFrame) + recvCh := make(chan *aop.Envelope) recvErr := make(chan error, 1) go func() { for { - frame, err := stream.Recv() + envelope, err := stream.Recv() if err != nil { recvErr <- err return } select { - case recvCh <- frame: + case recvCh <- envelope: case <-ctx.Done(): return } @@ -183,8 +160,8 @@ func (p *AgentPool) ServeAgentStream(stream ServerAgentStream) error { for { select { - case frame := <-recvCh: - p.handleAgentFrame(agent, frame) + case envelope := <-recvCh: + p.dispatchAgentEnvelope(ctx, namespaceMux, envelope) case err := <-recvErr: return err case err := <-writeErr: diff --git a/pkg/web/agent_stream_handler.go b/pkg/web/agent_stream_handler.go index a0a659d2..089f9183 100644 --- a/pkg/web/agent_stream_handler.go +++ b/pkg/web/agent_stream_handler.go @@ -6,26 +6,103 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + execpb "github.com/chainreactors/aiscan/aop/exec" + filepb "github.com/chainreactors/aiscan/aop/file" + ptypb "github.com/chainreactors/aiscan/aop/pty" + scopb "github.com/chainreactors/aiscan/aop/sco" + toolpb "github.com/chainreactors/aiscan/aop/tool" "github.com/chainreactors/aiscan/core/output" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + reloadpb "github.com/chainreactors/aiscan/pkg/types/reload" terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" "github.com/chainreactors/utils/pty" protobuf "google.golang.org/protobuf/proto" ) -func (p *AgentPool) handleAgentFrame(agent *remoteAgent, frame *transport.AgentFrame) { - if agent == nil || frame == nil { +func (p *AgentPool) newAgentNamespaceMux(agent *remoteAgent) (*aop.NamespaceMux, error) { + mux := aop.NewNamespaceMux() + if err := mux.Register(&aop.ProtocolMessage{}, func(_ context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + p.handleAgentCoreMessage(agent, envelope, message.(*aop.ProtocolMessage)) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&commandpb.ProtocolMessage{}, func(_ context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + p.handleAgentCommandMessage(agent, envelope, message.(*commandpb.ProtocolMessage)) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&filepb.ProtocolMessage{}, func(_ context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + p.handleAgentFileMessage(agent, envelope, message.(*filepb.ProtocolMessage)) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&execpb.ProtocolMessage{}, func(_ context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + p.handleAgentExecMessage(agent, envelope, message.(*execpb.ProtocolMessage)) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&reloadpb.ProtocolMessage{}, func(_ context.Context, _ *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + p.handleAgentReloadMessage(agent, message.(*reloadpb.ProtocolMessage)) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&ptypb.ProtocolMessage{}, func(_ context.Context, _ *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + p.forwardPTYFrame(terminalcodec.FromProto(message.(*ptypb.ProtocolMessage))) + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&toolpb.ProtocolMessage{}, func(_ context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + if progress := message.(*toolpb.ProtocolMessage).GetProgress(); progress != nil { + p.handleToolProgress(envelope.ReplyTo, progress) + } + return nil + }); err != nil { + return nil, err + } + if err := mux.Register(&scopb.ProtocolMessage{}, func(ctx context.Context, envelope *aop.Envelope, message protobuf.Message, _ aop.SendFunc) error { + p.handleAgentSCOMessage(ctx, envelope, message.(*scopb.ProtocolMessage)) + return nil + }); err != nil { + return nil, err + } + return mux, nil +} + +func (p *AgentPool) handleAgentEnvelope(agent *remoteAgent, envelope *aop.Envelope) { + mux, err := p.newAgentNamespaceMux(agent) + if err != nil { return } - switch payload := frame.Payload.(type) { - case *transport.AgentFrame_Status: - status := payload.Status + p.dispatchAgentEnvelope(context.Background(), mux, envelope) +} + +func (p *AgentPool) dispatchAgentEnvelope(ctx context.Context, mux *aop.NamespaceMux, envelope *aop.Envelope) { + if mux == nil || envelope == nil { + return + } + _, _ = mux.Dispatch(ctx, envelope, func(*aop.Envelope) error { return nil }) +} + +func (p *AgentPool) handleAgentCoreMessage(agent *remoteAgent, envelope *aop.Envelope, value *aop.ProtocolMessage) { + if agent == nil || envelope == nil || value == nil { + return + } + correlationID := envelope.ReplyTo + switch payload := value.Message.(type) { + case *aop.ProtocolMessage_AgentStatus: + status := payload.AgentStatus if status == nil { return } agent.mu.Lock() if agent.status == nil { - agent.status = &transport.AgentStatus{} + agent.status = &aop.AgentStatus{} } if status.Provider != "" { agent.status.Provider = status.Provider @@ -40,119 +117,133 @@ func (p *AgentPool) handleAgentFrame(agent *remoteAgent, frame *transport.AgentF } agent.mu.Unlock() - case *transport.AgentFrame_Stats: + case *aop.ProtocolMessage_AgentStats: agent.mu.Lock() - if payload.Stats == nil { - agent.stats = &transport.AgentStats{} + if payload.AgentStats == nil { + agent.stats = &aop.AgentStats{} } else { - agent.stats = protobuf.Clone(payload.Stats).(*transport.AgentStats) + agent.stats = protobuf.Clone(payload.AgentStats).(*aop.AgentStats) } agent.mu.Unlock() - case *transport.AgentFrame_OpenSession: - if accepted := payload.OpenSession.GetAccepted(); accepted != nil { + case *aop.ProtocolMessage_OpenSessionResponse: + response := payload.OpenSessionResponse + if accepted := response.GetAccepted(); accepted != nil { agent.mu.Lock() agent.openSessions[accepted.Id] = struct{}{} agent.mu.Unlock() } result := taskResult{} - if rejected := payload.OpenSession.GetRejected(); rejected != nil { + if rejected := response.GetRejected(); rejected != nil { result.Err = rejected.Message } - p.finishAgentTask(agent, frame.CorrelationId, result) + p.finishAgentTask(agent, correlationID, result) - case *transport.AgentFrame_CloseSession: - if accepted := payload.CloseSession.GetAccepted(); accepted != nil { + case *aop.ProtocolMessage_CloseSessionResponse: + response := payload.CloseSessionResponse + if accepted := response.GetAccepted(); accepted != nil { agent.mu.Lock() delete(agent.openSessions, accepted.Id) agent.mu.Unlock() } result := taskResult{} - if rejected := payload.CloseSession.GetRejected(); rejected != nil { + if rejected := response.GetRejected(); rejected != nil { result.Err = rejected.Message } - p.finishAgentTask(agent, frame.CorrelationId, result) + p.finishAgentTask(agent, correlationID, result) - case *transport.AgentFrame_RunTurn: - if rejected := payload.RunTurn.GetRejected(); rejected != nil { - p.finishAgentTask(agent, frame.CorrelationId, taskResult{Err: rejected.Message}) + case *aop.ProtocolMessage_RunTurnResponse: + if rejected := payload.RunTurnResponse.GetRejected(); rejected != nil { + p.finishAgentTask(agent, correlationID, taskResult{Err: rejected.Message}) } - case *transport.AgentFrame_CancelTurn: - // Cancellation is acknowledged by the response; the local waiter was - // already closed when the cancel request was queued. - - case *transport.AgentFrame_Event: - p.forwardAOPFrame(agent, frame.CorrelationId, payload.Event) + case *aop.ProtocolMessage_CancelTurnResponse: + // The local waiter is closed when cancellation is enqueued. - case *transport.AgentFrame_CommandResult: - result := payload.CommandResult - if result != nil { - p.finishAgentTask(agent, result.TaskId, taskResult{Result: append(json.RawMessage(nil), result.Result...)}) - } + case *aop.ProtocolMessage_Event: + p.forwardAOPFrame(agent, correlationID, payload.Event) - case *transport.AgentFrame_FileResult: - result := payload.FileResult - if result != nil { - p.finishAgentTask(agent, result.TaskId, taskResult{File: protobuf.Clone(result).(*transport.FileResult)}) - } - - case *transport.AgentFrame_ExecOutput: - // Exec output is streaming telemetry. Callers that need it consume the - // terminal result; no second output envelope is maintained. - - case *transport.AgentFrame_ExecResult: - result := payload.ExecResult - if result != nil { - encoded, _ := json.Marshal(result) - p.finishAgentTask(agent, result.TaskId, taskResult{Result: encoded}) - } - - case *transport.AgentFrame_OperationError: - failure := payload.OperationError - if failure != nil { - p.finishAgentTask(agent, failure.TaskId, taskResult{Err: failure.Message}) + case *aop.ProtocolMessage_ProtocolError: + if payload.ProtocolError != nil { + p.finishAgentTask(agent, correlationID, taskResult{Err: payload.ProtocolError.Message}) } + } +} - case *transport.AgentFrame_ConfigReload: - result := payload.ConfigReload - if result == nil { - return - } +func (p *AgentPool) handleAgentCommandMessage(agent *remoteAgent, envelope *aop.Envelope, value *commandpb.ProtocolMessage) { + if agent == nil || envelope == nil || value == nil { + return + } + if catalog := value.GetCatalog(); catalog != nil { agent.mu.Lock() - if result.Ok { - agent.status.Provider = result.Provider - agent.status.Model = result.Model - agent.status.ConfigError = "" - } else { - agent.status.ConfigError = result.Error - } + agent.commandsMenu = cloneCommandSpecs(catalog.Commands) agent.mu.Unlock() + return + } + if result := value.GetResult(); result != nil { + p.finishAgentTask(agent, envelope.ReplyTo, taskResult{Result: append(json.RawMessage(nil), result.Data...)}) + } +} - case *transport.AgentFrame_Terminal: - if payload.Terminal != nil { - p.forwardPTYFrame(terminalcodec.FromProto(payload.Terminal)) - } +func (p *AgentPool) handleAgentFileMessage(agent *remoteAgent, envelope *aop.Envelope, value *filepb.ProtocolMessage) { + if agent == nil || envelope == nil || value == nil { + return + } + if result := value.GetResult(); result != nil { + p.finishAgentTask(agent, envelope.ReplyTo, taskResult{File: protobuf.Clone(result).(*filepb.Result)}) + } +} - case *transport.AgentFrame_ToolTelemetry: - p.handleToolTelemetry(agent, payload.ToolTelemetry) +func (p *AgentPool) handleAgentExecMessage(agent *remoteAgent, envelope *aop.Envelope, value *execpb.ProtocolMessage) { + if agent == nil || envelope == nil || value == nil { + return + } + if result := value.GetResult(); result != nil { + encoded, _ := json.Marshal(result) + p.finishAgentTask(agent, envelope.ReplyTo, taskResult{Result: encoded}) + } + // Output is intentionally streaming-only and does not complete the task. +} - case *transport.AgentFrame_ScoNodes: - if p.sco != nil && payload.ScoNodes != nil && len(payload.ScoNodes.Nodes) > 0 { - nodes := make([]json.RawMessage, 0, len(payload.ScoNodes.Nodes)) - for _, node := range payload.ScoNodes.Nodes { - nodes = append(nodes, append(json.RawMessage(nil), node...)) - } - scanID := payload.ScoNodes.CallId - if scanID == "" { - scanID = frame.CorrelationId - } - if scanID == "" { - scanID = "standalone" - } - _ = p.sco.UpsertSCONodes(context.Background(), scanID, nodes) - } +func (p *AgentPool) handleAgentReloadMessage(agent *remoteAgent, value *reloadpb.ProtocolMessage) { + if agent == nil || value == nil { + return } + result := value.GetResult() + if result == nil { + return + } + agent.mu.Lock() + if agent.status == nil { + agent.status = &aop.AgentStatus{} + } + if result.Ok { + agent.status.Provider = result.Provider + agent.status.Model = result.Model + agent.status.ConfigError = "" + } else { + agent.status.ConfigError = result.Error + } + agent.mu.Unlock() +} + +func (p *AgentPool) handleAgentSCOMessage(ctx context.Context, envelope *aop.Envelope, value *scopb.ProtocolMessage) { + if envelope == nil || value == nil { + return + } + nodes := value.GetNodes() + if p.sco == nil || nodes == nil || len(nodes.Nodes) == 0 { + return + } + values := make([]json.RawMessage, 0, len(nodes.Nodes)) + for _, node := range nodes.Nodes { + values = append(values, append(json.RawMessage(nil), node...)) + } + operationID := envelope.ReplyTo + if operationID == "" { + operationID = envelope.Id + } + _ = p.sco.UpsertSCONodes(ctx, operationID, values) } func (p *AgentPool) finishAgentTask(agent *remoteAgent, taskID string, result taskResult) { @@ -186,15 +277,24 @@ func (p *AgentPool) forwardAOPFrame(agent *remoteAgent, correlationID string, ev } sessionID, ok := p.sessions.TaskSession(lookup) if !ok { - // Session lifecycle frames are not part of a turn and therefore may - // legitimately arrive without a task correlation. Other uncorrelated - // AOP frames can belong to standalone scans and must not leak into the - // chat transcript merely because their scan ID occupies session_id. switch event.Payload.(type) { case *aop.Event_SessionStarted, *aop.Event_SessionEnded: sessionID = event.SessionId default: - sessionID = "" + // Session commands emit durable AOP messages without a turn ID: + // they belong to the Runtime session, not to an LLM turn. The + // agent connection also sends those events without reply_to, so + // task correlation cannot resolve them. Accept the event only + // when this exact agent has the Runtime session open; this keeps + // standalone scan telemetry from leaking into chat history. + agent.mu.Lock() + _, opened := agent.openSessions[event.SessionId] + agent.mu.Unlock() + if opened { + sessionID = event.SessionId + } else { + sessionID = "" + } } } if sessionID != "" { @@ -209,17 +309,11 @@ func (p *AgentPool) forwardAOPFrame(agent *remoteAgent, correlationID string, ev } } -func (p *AgentPool) handleToolTelemetry(agent *remoteAgent, value *transport.ToolTelemetry) { +func (p *AgentPool) handleToolProgress(operationID string, value *toolpb.Progress) { if value == nil { return } - var data any - if value.Data != nil { - data, _ = aop.DecodeJSON[any](value.Data) - } - event := output.ToolDataEvent{ - Tool: value.Tool, Kind: value.Kind, Target: value.Target, Data: data, CallID: value.CallId, - } + event := output.ToolDataEvent{Tool: value.Tool, Kind: output.ToolDataProgress, Target: value.Target, Data: value.Text, CallID: operationID} if value.Timestamp != nil { event.Timestamp = value.Timestamp.AsTime() } else { @@ -233,10 +327,9 @@ func (p *AgentPool) handleToolTelemetry(agent *remoteAgent, value *transport.Too return } line = output.StripANSI(line) - if line == "" { - return + if line != "" { + p.hub.BroadcastScan(scanProgressEvent(event.CallID, line), false) } - p.hub.BroadcastScan(scanProgressEvent(event.CallID, line), false) } func (p *AgentPool) forwardPTYFrame(frame pty.Frame) { diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 3555d144..5a1dd94d 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -11,8 +11,12 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" - "github.com/chainreactors/aiscan/core/output" + filepb "github.com/chainreactors/aiscan/aop/file" + toolpb "github.com/chainreactors/aiscan/aop/tool" + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + configpb "github.com/chainreactors/aiscan/pkg/types/config" + reloadpb "github.com/chainreactors/aiscan/pkg/types/reload" terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" @@ -21,120 +25,39 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -// AgentInfo is the public view of a connected agent. -type AgentInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Commands []string `json:"commands,omitempty"` - CommandsMenu []*transport.CommandSpec `json:"commands_menu,omitempty"` - Busy bool `json:"busy"` - ConnectAt time.Time `json:"connected_at"` - Node protocols.NodeRef `json:"node"` - Runtime AgentRuntimeView `json:"runtime,omitempty"` - Status AgentStatusView `json:"status,omitempty"` - Stats AgentStatsView `json:"stats,omitempty"` -} - -type AgentRuntimeView struct { - Hostname string `json:"hostname,omitempty"` - Username string `json:"username,omitempty"` - WorkingDir string `json:"working_dir,omitempty"` - OS string `json:"os,omitempty"` - Arch string `json:"arch,omitempty"` - PID int32 `json:"pid,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - Meta map[string]any `json:"meta,omitempty"` -} - -type AgentStatusView struct { - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Space string `json:"space,omitempty"` - Bound bool `json:"bound"` - ConfigError string `json:"config_error,omitempty"` -} - -type AgentStatsView struct { - Turns uint64 `json:"turns,omitempty"` - ToolCalls uint64 `json:"tool_calls,omitempty"` - RunningTools uint64 `json:"running_tools,omitempty"` - PromptTokens uint64 `json:"prompt_tokens,omitempty"` - CompletionTokens uint64 `json:"completion_tokens,omitempty"` - TotalTokens uint64 `json:"total_tokens,omitempty"` - CacheReadTokens uint64 `json:"cache_read_tokens,omitempty"` - CacheWriteTokens uint64 `json:"cache_write_tokens,omitempty"` - Assets uint64 `json:"assets,omitempty"` - Loots uint64 `json:"loots,omitempty"` - LastEvent string `json:"last_event,omitempty"` -} - -func cloneCommandSpecs(values []*transport.CommandSpec) []*transport.CommandSpec { +func cloneCommandSpecs(values []*commandpb.Spec) []*commandpb.Spec { if len(values) == 0 { return nil } - out := make([]*transport.CommandSpec, 0, len(values)) + out := make([]*commandpb.Spec, 0, len(values)) for _, value := range values { if value != nil { - out = append(out, protobuf.Clone(value).(*transport.CommandSpec)) + out = append(out, protobuf.Clone(value).(*commandpb.Spec)) } } return out } -func runtimeView(value *transport.AgentRuntimeInfo) AgentRuntimeView { - if value == nil { - return AgentRuntimeView{} - } - view := AgentRuntimeView{ - Hostname: value.Hostname, Username: value.Username, WorkingDir: value.WorkingDir, - OS: value.Os, Arch: value.Arch, PID: value.Pid, Capabilities: append([]string(nil), value.Capabilities...), - } - if value.Metadata != nil { - view.Meta, _ = aop.DecodeJSON[map[string]any](value.Metadata) - } - return view -} - -func statusView(value *transport.AgentStatus) AgentStatusView { - if value == nil { - return AgentStatusView{} - } - return AgentStatusView{Provider: value.Provider, Model: value.Model, Space: value.Space, Bound: value.Bound, ConfigError: value.ConfigError} -} - -func statsView(value *transport.AgentStats) AgentStatsView { - if value == nil { - return AgentStatsView{} - } - return AgentStatsView{ - Turns: value.Turns, ToolCalls: value.ToolCalls, RunningTools: value.RunningTools, - PromptTokens: value.InputTokens, CompletionTokens: value.OutputTokens, TotalTokens: value.TotalTokens, - CacheReadTokens: value.CacheReadTokens, CacheWriteTokens: value.CacheWriteTokens, - Assets: value.Assets, Loots: value.Loots, LastEvent: value.LastEvent, - } -} - type taskResult struct { Output string Result json.RawMessage - File *transport.FileResult + File *filepb.Result Err string Turn int } type remoteAgent struct { - id string + nodeURI string name string - commands []string - commandsMenu []*transport.CommandSpec + capabilities []string + commandsMenu []*commandpb.Spec close func() - sendCh chan *transport.ServerFrame - controlCh chan *transport.ServerFrame + sendCh chan *aop.Envelope connectAt time.Time node protocols.NodeRef - runtime *transport.AgentRuntimeInfo - status *transport.AgentStatus - stats *transport.AgentStats + runtime *aop.AgentRuntimeInfo + status *aop.AgentStatus + stats *aop.AgentStats mu sync.Mutex tasks map[string]chan taskResult @@ -148,31 +71,35 @@ type remoteAgent struct { // session.start's parent_session_id. Only a ROOT session.end converges the // task; child ends are lifecycle noise. childSessions map[string]map[string]struct{} - reloadPending bool done chan struct{} } -func (a *remoteAgent) info() AgentInfo { +func (a *remoteAgent) view() *agentpb.View { a.mu.Lock() defer a.mu.Unlock() - return AgentInfo{ - ID: a.id, + hello := &aop.AgentHello{ + AgentId: a.node.ID, Name: a.name, - Commands: a.commands, - CommandsMenu: cloneCommandSpecs(a.commandsMenu), - Busy: len(a.tasks) > 0, - ConnectAt: a.connectAt, - Node: a.node, - Runtime: runtimeView(a.runtime), - Status: statusView(a.status), - Stats: statsView(a.stats), + Authority: a.node.Authority, + Capabilities: append([]string(nil), a.capabilities...), + } + if a.runtime != nil { + hello.Runtime = protobuf.Clone(a.runtime).(*aop.AgentRuntimeInfo) + } + view := &agentpb.View{Hello: hello, NodeUri: a.node.URI(), ConnectedAt: timestamppb.New(a.connectAt), Commands: cloneCommandSpecs(a.commandsMenu), Busy: len(a.tasks) > 0} + if a.status != nil { + view.Status = protobuf.Clone(a.status).(*aop.AgentStatus) } + if a.stats != nil { + view.Stats = protobuf.Clone(a.stats).(*aop.AgentStats) + } + return view } // commandSpecs returns the agent's reported "/verb" catalog (its agent-scope // menu commands plus one per loaded skill). Immutable after register, so it // needs no lock. The hub merges it with its hub-scope commands in SessionMenu. -func (a *remoteAgent) commandSpecs() []*transport.CommandSpec { +func (a *remoteAgent) commandSpecs() []*commandpb.Spec { if a == nil { return nil } @@ -185,15 +112,10 @@ type SessionLookup interface { BroadcastAOPEvent(sessionID string, event *aop.Event) } -// RecordStore is the subset of Store needed for record persistence. -type RecordStore interface { - InsertRecord(ctx context.Context, rec *output.Record) error - InsertRecords(ctx context.Context, recs []*output.Record) error -} - -// SCOStore persists libcstx nodes emitted by a connected agent process. +// SCOStore persists libcstx nodes and records which AOP operation observed +// them. Node identity is global; operation membership is many-to-many. type SCOStore interface { - UpsertSCONodes(ctx context.Context, scanID string, nodes []json.RawMessage) error + UpsertSCONodes(ctx context.Context, operationID string, nodes []json.RawMessage) error } // AgentPool manages connected remote aiscan agents via WebSocket. @@ -202,8 +124,8 @@ type AgentPool struct { agents map[string]*remoteAgent hub *Hub sessions SessionLookup - records RecordStore sco SCOStore + config func(context.Context) (*configpb.DistributeConfig, error) ptyMu sync.RWMutex ptySubs map[string]chan pty.Frame ptyAgents map[string]string @@ -227,10 +149,6 @@ func (p *AgentPool) SetSessionLookup(sl SessionLookup) { p.sessions = sl } -func (p *AgentPool) SetRecordStore(rs RecordStore) { - p.records = rs -} - func (p *AgentPool) SetSCOStore(store SCOStore) { p.sco = store } @@ -238,8 +156,8 @@ func (p *AgentPool) SetSCOStore(store SCOStore) { // agentKey is the pool key for a registering agent: its canonical Web identity, so // a reconnecting agent (WS flap, hub restart, config-driven bounce) re-registers // under the SAME key. The hub used to mint a throwaway id per connection, which -// dangled every chat session bound to it — the session freezes the agent id at -// creation, so on reconnect the stored id resolved to nothing and the chat +// dangled every chat session bound to it — the session freezes the node URI at +// creation, so on reconnect the stored URI resolved to nothing and the chat // rejected every message as "not connected" even with the agent right back. func agentKey(agentID, authority string) string { return (protocols.NodeRef{ID: agentID, Authority: authority}).URI() @@ -247,8 +165,8 @@ func agentKey(agentID, authority string) string { func (p *AgentPool) register(a *remoteAgent) { p.mu.Lock() - old := p.agents[a.id] - p.agents[a.id] = a + old := p.agents[a.nodeURI] + p.agents[a.nodeURI] = a p.mu.Unlock() // The pool is keyed by stable identity (see agentKey), so a reconnecting agent // — or a second agent sharing the same node name — lands on an occupied slot. @@ -267,13 +185,13 @@ func (p *AgentPool) unregister(a *remoteAgent) { // Only vacate the slot if it still holds THIS instance. After a reconnect the // slot was already reassigned to the replacement under the same key; the old // instance tearing down must not evict its successor. - removed := p.agents[a.id] == a + removed := p.agents[a.nodeURI] == a if removed { - delete(p.agents, a.id) + delete(p.agents, a.nodeURI) } p.mu.Unlock() if removed { - p.notifyPTY(a.id, pty.Frame{Type: pty.FrameDetached}) + p.notifyPTY(a.nodeURI, pty.Frame{Type: pty.FrameDetached}) } a.mu.Lock() for _, ch := range a.tasks { @@ -285,18 +203,18 @@ func (p *AgentPool) unregister(a *remoteAgent) { a.mu.Unlock() } -func (p *AgentPool) get(id string) *remoteAgent { +func (p *AgentPool) get(nodeURI string) *remoteAgent { p.mu.RLock() defer p.mu.RUnlock() - return p.agents[id] + return p.agents[nodeURI] } -func (p *AgentPool) List() []AgentInfo { +func (p *AgentPool) List() []*agentpb.View { p.mu.RLock() defer p.mu.RUnlock() - out := make([]AgentInfo, 0, len(p.agents)) + out := make([]*agentpb.View, 0, len(p.agents)) for _, a := range p.agents { - out = append(out, a.info()) + out = append(out, a.view()) } return out } @@ -352,10 +270,10 @@ func (p *AgentPool) PickChat() *remoteAgent { // DispatchToolCall sends a canonical AOP tool.call to a tool-capable node. // The task completes only on the matching AOP tool.result. -func (p *AgentPool) DispatchToolCall(agentID, taskID string, call *aop.ToolCall) (<-chan taskResult, error) { - a := p.get(agentID) +func (p *AgentPool) DispatchToolCall(nodeURI, taskID string, call *aop.ToolCall) (<-chan taskResult, error) { + a := p.get(nodeURI) if a == nil { - return nil, fmt.Errorf("agent %s not connected", agentID) + return nil, fmt.Errorf("node %s not connected", nodeURI) } call.Id = taskID sessionID := taskID @@ -366,7 +284,7 @@ func (p *AgentPool) DispatchToolCall(agentID, taskID string, call *aop.ToolCall) } agentName := a.name if agentName == "" { - agentName = a.id + agentName = a.nodeURI } event := &aop.Event{ Id: generateID(), EmittedAt: timestamppb.Now(), SessionId: sessionID, TurnId: taskID, Emitter: agentName, @@ -378,12 +296,9 @@ func (p *AgentPool) DispatchToolCall(agentID, taskID string, call *aop.ToolCall) } a.toolCalls[taskID] = struct{}{} a.mu.Unlock() - ch, err := p.dispatchFrame(agentID, taskID, &transport.ServerFrame{ - CorrelationId: taskID, - Payload: &transport.ServerFrame_ToolCall{ToolCall: &transport.ToolCallRequest{ - TaskId: taskID, SessionId: sessionID, TurnId: taskID, Call: call, - }}, - }) + ch, err := p.dispatchMessage(nodeURI, taskID, &toolpb.ProtocolMessage{Message: &toolpb.ProtocolMessage_Call{Call: &toolpb.Call{ + SessionId: sessionID, TurnId: taskID, Call: call, + }}}) if err != nil { a.mu.Lock() delete(a.toolCalls, taskID) @@ -397,25 +312,22 @@ func (p *AgentPool) DispatchToolCall(agentID, taskID string, call *aop.ToolCall) } // DispatchChat sends a natural-language prompt to an LLM-capable agent. -func (p *AgentPool) DispatchChat(agentID, taskID, prompt string) (<-chan taskResult, error) { - return p.DispatchRun(agentID, &aop.RunTurnRequest{ - RequestId: taskID, TurnId: taskID, - Input: &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: prompt}}}}}, +func (p *AgentPool) DispatchChat(nodeURI, taskID, prompt string) (<-chan taskResult, error) { + return p.DispatchRun(nodeURI, &aop.RunTurnRequest{ + TurnId: taskID, + Input: &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: prompt}}}}}, }) } -func (p *AgentPool) DispatchOpenSession(agentID string, request *aop.OpenSessionRequest) (<-chan taskResult, error) { - if request == nil || strings.TrimSpace(request.RequestId) == "" || strings.TrimSpace(request.SessionId) == "" { - return nil, fmt.Errorf("open session request_id and session_id are required") +func (p *AgentPool) DispatchOpenSession(nodeURI, requestID string, request *aop.OpenSessionRequest) (<-chan taskResult, error) { + if request == nil || strings.TrimSpace(requestID) == "" || strings.TrimSpace(request.SessionId) == "" { + return nil, fmt.Errorf("open session envelope id and session_id are required") } - return p.dispatchFrame(agentID, request.RequestId, &transport.ServerFrame{ - CorrelationId: request.RequestId, - Payload: &transport.ServerFrame_OpenSession{OpenSession: request}, - }) + return p.dispatchMessage(nodeURI, requestID, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionRequest{OpenSessionRequest: request}}) } -func (p *AgentPool) SessionOpen(agentID, sessionID string) bool { - agent := p.get(agentID) +func (p *AgentPool) SessionOpen(nodeURI, sessionID string) bool { + agent := p.get(nodeURI) if agent == nil { return false } @@ -425,20 +337,17 @@ func (p *AgentPool) SessionOpen(agentID, sessionID string) bool { return ok } -func (p *AgentPool) DispatchCloseSession(agentID string, request *aop.CloseSessionRequest) (<-chan taskResult, error) { - if request == nil || strings.TrimSpace(request.RequestId) == "" || strings.TrimSpace(request.SessionId) == "" { - return nil, fmt.Errorf("close session request_id and session_id are required") +func (p *AgentPool) DispatchCloseSession(nodeURI, requestID string, request *aop.CloseSessionRequest) (<-chan taskResult, error) { + if request == nil || strings.TrimSpace(requestID) == "" || strings.TrimSpace(request.SessionId) == "" { + return nil, fmt.Errorf("close session envelope id and session_id are required") } - return p.dispatchFrame(agentID, request.RequestId, &transport.ServerFrame{ - CorrelationId: request.RequestId, - Payload: &transport.ServerFrame_CloseSession{CloseSession: request}, - }) + return p.dispatchMessage(nodeURI, requestID, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CloseSessionRequest{CloseSessionRequest: request}}) } -func (p *AgentPool) DispatchRun(agentID string, request *aop.RunTurnRequest) (<-chan taskResult, error) { - a := p.get(agentID) +func (p *AgentPool) DispatchRun(nodeURI string, request *aop.RunTurnRequest) (<-chan taskResult, error) { + a := p.get(nodeURI) if a == nil { - return nil, fmt.Errorf("agent %s not connected", agentID) + return nil, fmt.Errorf("node %s not connected", nodeURI) } if request == nil || request.Input == nil || request.TurnId == "" { return nil, fmt.Errorf("run request with input and turn_id is required") @@ -451,31 +360,27 @@ func (p *AgentPool) DispatchRun(agentID string, request *aop.RunTurnRequest) (<- } a.mu.Unlock() if !opened { - select { - case a.sendCh <- &transport.ServerFrame{CorrelationId: "open:" + request.SessionId, Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ - RequestId: "open:" + request.SessionId, SessionId: request.SessionId, Participant: agentID, - }}}: - default: + requestID := "open:" + request.SessionId + if err := p.sendAgentMessage(nodeURI, requestID, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionRequest{OpenSessionRequest: &aop.OpenSessionRequest{ + SessionId: request.SessionId, NodeUri: nodeURI, + }}}); err != nil { a.mu.Lock() delete(a.openSessions, request.SessionId) a.mu.Unlock() - return nil, fmt.Errorf("agent %s send channel full", agentID) + return nil, err } } } - return p.dispatchFrame(agentID, request.TurnId, &transport.ServerFrame{ - CorrelationId: request.TurnId, Payload: &transport.ServerFrame_RunTurn{RunTurn: request}, - }) + return p.dispatchMessage(nodeURI, request.TurnId, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_RunTurnRequest{RunTurnRequest: request}}) } -func (p *AgentPool) DispatchCommand(agentID string, command *transport.CommandRequest) (<-chan taskResult, error) { - if command == nil || command.TaskId == "" { - return nil, fmt.Errorf("command task_id is required") +func (p *AgentPool) DispatchCommand(nodeURI, taskID string, command *commandpb.Request) (<-chan taskResult, error) { + if command == nil || taskID == "" { + return nil, fmt.Errorf("command and operation id are required") } - taskID := command.TaskId - a := p.get(agentID) + a := p.get(nodeURI) if a == nil { - return nil, fmt.Errorf("agent %s not connected", agentID) + return nil, fmt.Errorf("node %s not connected", nodeURI) } if command.SessionId != "" { a.mu.Lock() @@ -485,54 +390,56 @@ func (p *AgentPool) DispatchCommand(agentID string, command *transport.CommandRe } a.mu.Unlock() if !opened { - select { - case a.sendCh <- &transport.ServerFrame{CorrelationId: "open:" + command.SessionId, Payload: &transport.ServerFrame_OpenSession{OpenSession: &aop.OpenSessionRequest{ - RequestId: "open:" + command.SessionId, SessionId: command.SessionId, Participant: agentID, - }}}: - default: + requestID := "open:" + command.SessionId + if err := p.sendAgentMessage(nodeURI, requestID, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionRequest{OpenSessionRequest: &aop.OpenSessionRequest{ + SessionId: command.SessionId, NodeUri: nodeURI, + }}}); err != nil { a.mu.Lock() delete(a.openSessions, command.SessionId) a.mu.Unlock() - return nil, fmt.Errorf("agent %s send channel full", agentID) + return nil, err } } } - return p.dispatchFrame(agentID, taskID, &transport.ServerFrame{ - CorrelationId: taskID, - Payload: &transport.ServerFrame_Command{Command: protobuf.Clone(command).(*transport.CommandRequest)}, - }) + return p.dispatchMessage(nodeURI, taskID, &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Request{Request: protobuf.Clone(command).(*commandpb.Request)}}) } -func (p *AgentPool) dispatchFrame(agentID, taskID string, frame *transport.ServerFrame) (<-chan taskResult, error) { - a := p.get(agentID) +func (p *AgentPool) dispatchMessage(nodeURI, taskID string, message protobuf.Message) (<-chan taskResult, error) { + a := p.get(nodeURI) if a == nil { - return nil, fmt.Errorf("agent %s not connected", agentID) + return nil, fmt.Errorf("node %s not connected", nodeURI) } ch := make(chan taskResult, 1) a.mu.Lock() a.tasks[taskID] = ch a.turns[taskID] = 0 a.mu.Unlock() - - select { - case a.sendCh <- frame: - default: + envelope, err := aop.Wrap(taskID, "", message) + if err != nil { a.mu.Lock() delete(a.tasks, taskID) delete(a.turns, taskID) a.mu.Unlock() close(ch) - return nil, fmt.Errorf("agent %s send channel full", agentID) + return nil, err + } + if err := a.enqueue(envelope); err != nil { + a.mu.Lock() + delete(a.tasks, taskID) + delete(a.turns, taskID) + a.mu.Unlock() + close(ch) + return nil, err } return ch, nil } -// BroadcastConfigReload notifies every connected agent that the hub config -// changed so each re-fetches and hot-swaps its LLM provider without a restart. -// Config notifications use the control channel so task output cannot starve a -// provider change. Repeated reloads are coalesced while one is queued or waiting -// for control-channel capacity; the agent always fetches the latest config. -func (p *AgentPool) BroadcastConfigReload() int { +// BroadcastConfigReload sends the committed protobuf config on the same FIFO as +// every other application message. Agents never fetch a second REST DTO. +func (p *AgentPool) BroadcastConfigReload(config *configpb.DistributeConfig) int { + if config == nil { + return 0 + } p.mu.RLock() agents := make([]*remoteAgent, 0, len(p.agents)) for _, a := range p.agents { @@ -541,69 +448,41 @@ func (p *AgentPool) BroadcastConfigReload() int { p.mu.RUnlock() n := 0 for _, a := range agents { - if a.queueConfigReload() { + message := &reloadpb.ProtocolMessage{Message: &reloadpb.ProtocolMessage_Request{Request: &reloadpb.Request{Config: protobuf.Clone(config).(*configpb.DistributeConfig)}}} + envelope, err := aop.Wrap(generateID(), "", message) + if err == nil && a.enqueue(envelope) == nil { n++ } } return n } -func (a *remoteAgent) queueConfigReload() bool { - if a == nil || a.controlCh == nil { - return false - } - a.mu.Lock() - if a.reloadPending { - a.mu.Unlock() - return true +func (p *AgentPool) sendAgentMessage(nodeURI, id, replyTo string, message protobuf.Message) error { + a := p.get(nodeURI) + if a == nil { + return fmt.Errorf("node %s not connected", nodeURI) } - a.reloadPending = true - a.mu.Unlock() - - frame := &transport.ServerFrame{Payload: &transport.ServerFrame_ReloadConfig{ReloadConfig: &transport.ReloadConfig{}}} - select { - case a.controlCh <- frame: - return true - default: + envelope, err := aop.Wrap(id, replyTo, message) + if err != nil { + return err } - - go func() { - if a.done == nil { - a.controlCh <- frame - return - } - select { - case a.controlCh <- frame: - case <-a.done: - a.mu.Lock() - a.reloadPending = false - a.mu.Unlock() - } - }() - return true + return a.enqueue(envelope) } -func (a *remoteAgent) finishConfigReload() { - a.mu.Lock() - a.reloadPending = false - a.mu.Unlock() -} - -func (p *AgentPool) sendAgentFrame(agentID string, frame *transport.ServerFrame) error { - a := p.get(agentID) - if a == nil { - return fmt.Errorf("agent %s not connected", agentID) +func (a *remoteAgent) enqueue(envelope *aop.Envelope) error { + if a == nil || a.sendCh == nil { + return fmt.Errorf("agent connection is unavailable") } select { - case a.sendCh <- frame: + case a.sendCh <- envelope: return nil - default: - return fmt.Errorf("agent %s send channel full", agentID) + case <-a.done: + return fmt.Errorf("agent disconnected") } } -func (p *AgentPool) CancelTask(agentID, taskID string, sessionID ...string) error { - a := p.get(agentID) +func (p *AgentPool) CancelTask(nodeURI, taskID string, sessionID ...string) error { + a := p.get(nodeURI) if a == nil { return nil } @@ -624,123 +503,28 @@ func (p *AgentPool) CancelTask(agentID, taskID string, sessionID ...string) erro if len(sessionID) > 0 { chatSessionID = sessionID[0] } - cancelFrame := &transport.ServerFrame{CorrelationId: taskID, Payload: &transport.ServerFrame_CancelTurn{CancelTurn: &aop.CancelTurnRequest{ - RequestId: taskID, SessionId: chatSessionID, TurnId: taskID, - }}} + requestID := generateID() + cancelMessage := protobuf.Message(&aop.ProtocolMessage{Message: &aop.ProtocolMessage_CancelTurnRequest{CancelTurnRequest: &aop.CancelTurnRequest{ + SessionId: chatSessionID, TurnId: taskID, + }}}) if isToolCall { - cancelFrame = &transport.ServerFrame{CorrelationId: taskID, Payload: &transport.ServerFrame_CancelOperation{CancelOperation: &transport.CancelOperation{TaskId: taskID}}} + cancelMessage = &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CancelOperation{CancelOperation: &aop.CancelOperation{TargetId: taskID}}} } if resultCh != nil { close(resultCh) } - a.enqueueControl(cancelFrame) - return nil -} - -// enqueueControl never drops a control frame because task traffic temporarily -// fills the channel. The pending send is bounded by the agent connection's -// lifetime and the writer always drains controlCh before sendCh. -func (a *remoteAgent) enqueueControl(frame *transport.ServerFrame) { - if a == nil || a.controlCh == nil { - return - } - select { - case a.controlCh <- frame: - return - default: - } - go func() { - if a.done == nil { - a.controlCh <- frame - return - } - select { - case a.controlCh <- frame: - case <-a.done: - } - }() -} - -// HandleTerminalWS bridges one browser terminal WebSocket to one remote agent. -// The browser sends transport-neutral PTY frames; the pool assigns a stream_id, -// wraps them for the mixed agent connection, and unwraps matching responses. -func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *http.Request) { - conn, err := p.upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - terminalID := generateID() - events, online, unsubscribe := p.subscribePTY(agentID, terminalID) - defer unsubscribe() - defer p.CloseTerminal(agentID, terminalID) - - done := make(chan struct{}) - defer close(done) - - var writeMu sync.Mutex - write := func(frame pty.Frame) error { - writeMu.Lock() - defer writeMu.Unlock() - data, err := terminalcodec.Marshal(frame) - if err != nil { - return err - } - return conn.WriteMessage(websocket.TextMessage, data) - } - - go func() { - for { - select { - case msg, ok := <-events: - if !ok { - return - } - if err := write(msg); err != nil { - _ = conn.Close() - return - } - case <-done: - return - } - } - }() - if !online { - _ = write(pty.Frame{Type: pty.FrameDetached, StreamID: terminalID}) - } - - for { - _, data, err := conn.ReadMessage() - if err != nil { - return - } - frame, err := terminalcodec.Unmarshal(data) - if err != nil { - _ = write(pty.Frame{Type: pty.FrameError, StreamID: terminalID, Error: "invalid terminal protobuf JSON: " + err.Error()}) - continue - } - if frame.Type == "" { - _ = write(pty.Frame{Type: pty.FrameError, StreamID: terminalID, Error: "PTY frame type is required"}) - continue - } - frame.StreamID = terminalID - if err := p.sendAgentFrame(agentID, &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}); err != nil { - _ = write(pty.Frame{Type: pty.FrameError, StreamID: terminalID, Error: err.Error()}) - continue - } - } + return p.sendAgentMessage(nodeURI, requestID, "", cancelMessage) } -func (p *AgentPool) CancelPTY(agentID, terminalID string) { - _ = p.sendAgentFrame(agentID, &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(pty.Frame{Type: pty.FrameKill, StreamID: terminalID})}}) +func (p *AgentPool) CancelPTY(nodeURI, terminalID string) { + _ = p.sendAgentMessage(nodeURI, generateID(), "", terminalcodec.ToProto(pty.Frame{Type: pty.FrameKill, StreamID: terminalID})) } -func (p *AgentPool) CloseTerminal(agentID, terminalID string) { - _ = p.sendAgentFrame(agentID, &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(pty.Frame{Type: pty.FrameDetach, StreamID: terminalID})}}) +func (p *AgentPool) CloseTerminal(nodeURI, terminalID string) { + _ = p.sendAgentMessage(nodeURI, generateID(), "", terminalcodec.ToProto(pty.Frame{Type: pty.FrameDetach, StreamID: terminalID})) } -func (p *AgentPool) subscribePTY(agentID, terminalID string) (<-chan pty.Frame, bool, func()) { +func (p *AgentPool) subscribePTY(nodeURI, terminalID string) (<-chan pty.Frame, bool, func()) { ch := make(chan pty.Frame, 256) // Snapshot connectivity while registering the subscription under the pool // lock. An unregister cannot otherwise be distinguished from an initially @@ -748,8 +532,8 @@ func (p *AgentPool) subscribePTY(agentID, terminalID string) (<-chan pty.Frame, p.mu.RLock() p.ptyMu.Lock() p.ptySubs[terminalID] = ch - p.ptyAgents[terminalID] = agentID - online := p.agents[agentID] != nil + p.ptyAgents[terminalID] = nodeURI + online := p.agents[nodeURI] != nil p.ptyMu.Unlock() p.mu.RUnlock() return ch, online, func() { @@ -763,11 +547,11 @@ func (p *AgentPool) subscribePTY(agentID, terminalID string) (<-chan pty.Frame, } } -func (p *AgentPool) notifyPTY(agentID string, frame pty.Frame) { +func (p *AgentPool) notifyPTY(nodeURI string, frame pty.Frame) { p.ptyMu.RLock() defer p.ptyMu.RUnlock() - for terminalID, boundAgentID := range p.ptyAgents { - if boundAgentID != agentID { + for terminalID, boundNodeURI := range p.ptyAgents { + if boundNodeURI != nodeURI { continue } out := frame @@ -788,8 +572,8 @@ func (p *AgentPool) rebindPTY(agent *remoteAgent) { } p.ptyMu.RLock() terminalIDs := make([]string, 0) - for terminalID, agentID := range p.ptyAgents { - if agentID == agent.id { + for terminalID, nodeURI := range p.ptyAgents { + if nodeURI == agent.nodeURI { terminalIDs = append(terminalIDs, terminalID) } } @@ -797,10 +581,7 @@ func (p *AgentPool) rebindPTY(agent *remoteAgent) { for _, terminalID := range terminalIDs { terminalID := terminalID go func() { - select { - case agent.sendCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_Terminal{Terminal: terminalcodec.ToProto(pty.Frame{Type: pty.FrameList, StreamID: terminalID})}}: - case <-agent.done: - } + _ = agent.enqueue(aop.MustWrap(generateID(), "", terminalcodec.ToProto(pty.Frame{Type: pty.FrameList, StreamID: terminalID}))) }() } } @@ -824,31 +605,9 @@ func buildUpgrader(origins []string) websocket.Upgrader { } } -func (p *AgentPool) recordScanResultStats(a *remoteAgent, payload json.RawMessage) { - if a == nil || len(payload) == 0 { - return - } - var result output.Result - if err := json.Unmarshal(payload, &result); err != nil { - return - } - a.mu.Lock() - if a.stats == nil { - a.stats = &transport.AgentStats{} - } - a.stats.Assets += uint64(len(result.Assets)) - if result.Summary.Loots > 0 { - a.stats.Loots += uint64(result.Summary.Loots) - } else { - a.stats.Loots += uint64(len(result.Loots)) - } - a.mu.Unlock() -} - // convergeTaskOnToolResult closes a tool.call task on its terminal -// tool.result: text content becomes the task output, structured Details the -// scan result, and an is_error content the task error. tool.result events of -// chat tasks (LLM tool use) are not terminals and pass through. +// tool.result. SCO facts travel independently through the SCO namespace; +// tool.result carries only operation completion and human-readable output. func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev *aop.Event) { a.mu.Lock() if _, isToolCall := a.toolCalls[taskID]; !isToolCall { @@ -873,15 +632,8 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev * res.Err = res.Output res.Output = "" } - var details json.RawMessage - if d.Detail != nil { - details = append(details, d.Detail.Data...) - res.Result = details - } ch <- res close(ch) - p.recordScanResultStats(a, details) - p.persistResultRecords(a, taskID, details) } // convergeTaskOnSessionEnd closes a chat task when the ROOT agent session @@ -921,72 +673,7 @@ func aopToolResultText(content []*aop.Content) string { for _, item := range content { if text := item.GetText().GetText(); text != "" { parts = append(parts, text) - } else if opaque := item.GetOpaque(); opaque != nil { - parts = append(parts, string(opaque.Value.GetData())) } } return strings.Join(parts, "\n") } - -func (p *AgentPool) persistResultRecords(a *remoteAgent, taskID string, payload json.RawMessage) { - if p.records == nil || len(payload) == 0 { - return - } - var result output.Result - if err := json.Unmarshal(payload, &result); err != nil { - return - } - recs := resultToRecords(taskID, a.id, &result) - if len(recs) > 0 { - _ = p.records.InsertRecords(context.Background(), recs) - } -} - -func resultToRecords(scanID, agentID string, result *output.Result) []*output.Record { - if result == nil { - return nil - } - var recs []*output.Record - now := time.Now() - for _, loot := range result.Loots { - rec := &output.Record{ - Timestamp: now, - Loot: true, - ID: generateID(), - ScanID: scanID, - AgentID: agentID, - Source: loot.Kind, - Target: loot.Target, - Priority: loot.Priority, - Summary: loot.Description, - Tags: loot.Tags, - } - switch loot.Kind { - case output.LootVuln: - rec.Type = output.TypeNeutron - case output.LootWeakpass: - rec.Type = output.TypeZombie - case output.LootFingerprint: - rec.Type = output.TypeGogo - default: - rec.Type = output.RecordType(loot.Kind) - } - data, _ := json.Marshal(loot) - rec.Data = data - recs = append(recs, rec) - } - for _, e := range result.Errors { - data, _ := json.Marshal(e) - recs = append(recs, &output.Record{ - Type: output.TypeError, - Timestamp: now, - Data: data, - ID: generateID(), - ScanID: scanID, - AgentID: agentID, - Source: e.Source, - Summary: e.Message, - }) - } - return recs -} diff --git a/pkg/web/agents_session_end_test.go b/pkg/web/agents_session_end_test.go index f3b1e31a..6635a039 100644 --- a/pkg/web/agents_session_end_test.go +++ b/pkg/web/agents_session_end_test.go @@ -6,7 +6,6 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" ) func sessionEvent(t *testing.T, sessionID string, event *aop.Event) *aop.Event { @@ -163,16 +162,21 @@ func TestDisconnectedAcceptedTurnEmitsOneTerminalEvent(t *testing.T) { pool := NewAgentPool(service.Hub()) service.SetAgentPool(pool) remote := &remoteAgent{ - id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 2), controlCh: make(chan *transport.ServerFrame, 2), + id: "agent-1", name: "agent-1", sendCh: make(chan *aop.Envelope, 2), tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: make(map[string]struct{}), childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), } pool.agents[remote.id] = remote - if _, err := store.db.Exec(`UPDATE chat_sessions SET agent_id = ? WHERE id = ?`, remote.id, "session-1"); err != nil { - t.Fatal(err) + session, _ := store.GetSession(context.Background(), "session-1") + if session != nil { + if session.Session == nil { + session.Session = &aop.Session{} + } + session.Session.Participant = remote.id + _ = store.UpdateSession(context.Background(), session) } service.handleAgentRun("session-1", &aop.RunTurnRequest{ - RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", + SessionId: "session-1", TurnId: "turn-1", Input: &aop.Message{Role: "user", Content: []*aop.Content{aop.Text("hello")}}, }) pool.unregister(remote) diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index d6aad8b5..b512624e 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -6,7 +6,6 @@ import ( "io/fs" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "strings" @@ -16,41 +15,78 @@ import ( webstatic "github.com/chainreactors/aiscan/web" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" - "github.com/chainreactors/aiscan/core/output" + filepb "github.com/chainreactors/aiscan/aop/file" + ptypb "github.com/chainreactors/aiscan/aop/pty" + scopb "github.com/chainreactors/aiscan/aop/sco" + toolpb "github.com/chainreactors/aiscan/aop/tool" + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/gorilla/websocket" - "google.golang.org/protobuf/encoding/protojson" + protobuf "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/timestamppb" ) -func writeAgentFrame(t *testing.T, conn *websocket.Conn, frame *transport.AgentFrame) { +func wrapMessage(t *testing.T, id, replyTo string, message protobuf.Message) *aop.Envelope { t.Helper() - raw, err := protojson.Marshal(frame) + envelope, err := aop.Wrap(id, replyTo, message) if err != nil { t.Fatal(err) } - if err := conn.WriteMessage(websocket.TextMessage, raw); err != nil { + return envelope +} + +func unwrapEnvelope(t *testing.T, envelope *aop.Envelope) protobuf.Message { + t.Helper() + message, err := aop.Unwrap(envelope) + if err != nil { t.Fatal(err) } + return message } -func readServerFrame(t *testing.T, conn *websocket.Conn) *transport.ServerFrame { +func writeAgentEnvelope(t *testing.T, conn *websocket.Conn, envelope *aop.Envelope) { + t.Helper() + raw, err := protobuf.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + if err := conn.WriteMessage(websocket.BinaryMessage, raw); err != nil { + t.Fatal(err) + } +} + +func readHubEnvelope(t *testing.T, conn *websocket.Conn) *aop.Envelope { t.Helper() _, raw, err := conn.ReadMessage() if err != nil { t.Fatal(err) } - frame := new(transport.ServerFrame) - if err := protojson.Unmarshal(raw, frame); err != nil { + envelope := new(aop.Envelope) + if err := protobuf.Unmarshal(raw, envelope); err != nil { t.Fatal(err) } - return frame + return envelope +} + +// ptyFrameFromEnvelope extracts a PTY frame from an AOP envelope; envelopes +// carrying any other namespace decode to the zero frame. +func ptyFrameFromEnvelope(envelope *aop.Envelope) pty.Frame { + message, err := aop.Unwrap(envelope) + if err != nil { + return pty.Frame{} + } + ptyMessage, ok := message.(*ptypb.ProtocolMessage) + if !ok { + return pty.Frame{} + } + return terminalcodec.FromProto(ptyMessage) } type recordingSCOStore struct { @@ -69,9 +105,9 @@ func TestAgentPoolPersistsToolSCO(t *testing.T) { pool := NewAgentPool(NewHub()) pool.SetSCOStore(store) node := json.RawMessage(`{"cstx_id":"ip:127.0.0.1","cstx_type":"ip","value":"127.0.0.1"}`) - pool.handleAgentFrame(&remoteAgent{}, &transport.AgentFrame{Payload: &transport.AgentFrame_ScoNodes{ScoNodes: &transport.ScoNodes{ - CallId: "call-gogo-1", Nodes: [][]byte{node}, - }}}) + pool.handleAgentEnvelope(&remoteAgent{}, wrapMessage(t, generateID(), "call-gogo-1", &scopb.ProtocolMessage{Message: &scopb.ProtocolMessage_Nodes{Nodes: &scopb.Nodes{ + Nodes: [][]byte{node}, + }}})) if store.scanID != "call-gogo-1" { t.Fatalf("scan id = %q, want tool call id", store.scanID) @@ -81,18 +117,33 @@ func TestAgentPoolPersistsToolSCO(t *testing.T) { } } +// dialAOPWebSocket opens the unified application WebSocket both peers +// (agents and browsers) use. +func dialAOPWebSocket(t *testing.T, srv *httptest.Server) *websocket.Conn { + t.Helper() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/aop/ws" + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + t.Fatalf("dial: %v", err) + } + return conn +} + func dialAgent(t *testing.T, srv *httptest.Server, name string, commands []string) *websocket.Conn { - return dialAgentWithIdentity(t, srv, name, commands, "node-"+name, transport.AgentStatus{Space: "case-test"}) + return dialAgentWithIdentity(t, srv, name, commands, "node-"+name, aop.AgentStatus{Space: "case-test"}) } func writeAgentPTY(t *testing.T, conn *websocket.Conn, frame pty.Frame) { t.Helper() - writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}) + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", terminalcodec.ToProto(frame))) } func readAgentPTY(t *testing.T, conn *websocket.Conn, want pty.FrameType) pty.Frame { t.Helper() - frame := terminalcodec.FromProto(readServerFrame(t, conn).GetTerminal()) + frame := ptyFrameFromEnvelope(readHubEnvelope(t, conn)) if frame.Type != want { t.Fatalf("agent expected PTY %s, got %s", want, frame.Type) } @@ -101,61 +152,61 @@ func readAgentPTY(t *testing.T, conn *websocket.Conn, want pty.FrameType) pty.Fr func writeBrowserPTY(t *testing.T, conn *websocket.Conn, frame pty.Frame) { t.Helper() - raw, err := terminalcodec.Marshal(frame) - if err != nil { - t.Fatalf("marshal browser PTY %s: %v", frame.Type, err) - } - if err := conn.WriteMessage(websocket.TextMessage, raw); err != nil { - t.Fatalf("browser write PTY %s: %v", frame.Type, err) - } + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", terminalcodec.ToProto(frame))) +} + +// writeBrowserPTYOpen sends a browser pty.open; unlike every later frame it +// must nominate the target agent, so it is built as a proto message rather +// than through the transport-neutral pty.Frame codec. +func writeBrowserPTYOpen(t *testing.T, conn *websocket.Conn, open *ptypb.Open) { + t.Helper() + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &ptypb.ProtocolMessage{Message: &ptypb.ProtocolMessage_Open{Open: open}})) +} + +func writeBrowserPTYList(t *testing.T, conn *websocket.Conn, list *ptypb.List) { + t.Helper() + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &ptypb.ProtocolMessage{Message: &ptypb.ProtocolMessage_List{List: list}})) } func readBrowserPTY(t *testing.T, conn *websocket.Conn, want pty.FrameType) pty.Frame { t.Helper() - _, raw, err := conn.ReadMessage() - if err != nil { - t.Fatalf("browser read PTY %s: %v", want, err) - } - frame, err := terminalcodec.Unmarshal(raw) - if err != nil { - t.Fatalf("decode browser PTY %s: %v", want, err) - } + frame := ptyFrameFromEnvelope(readHubEnvelope(t, conn)) if frame.Type != want { t.Fatalf("browser expected PTY %s, got %s", want, frame.Type) } return frame } -func dialAgentWithIdentity(t *testing.T, srv *httptest.Server, name string, commands []string, nodeID string, status transport.AgentStatus) *websocket.Conn { +func dialAgentWithIdentity(t *testing.T, srv *httptest.Server, name string, commands []string, nodeID string, status aop.AgentStatus) *websocket.Conn { t.Helper() - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agent/ws" - conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("dial: %v", err) - } - writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Hello{Hello: &transport.AgentHello{ - AgentId: nodeID, Name: name, Authority: srv.URL, Commands: commands, - Status: &transport.AgentStatus{Space: status.Space, Provider: status.Provider, Model: status.Model, Bound: status.Bound, ConfigError: status.ConfigError}, - Stats: &transport.AgentStats{TotalTokens: 42}, - }}}) - ack := readServerFrame(t, conn) - if ack.GetAccepted() == nil { + conn := dialAOPWebSocket(t, srv) + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentHello{AgentHello: &aop.AgentHello{ + AgentId: nodeID, Name: name, Authority: srv.URL, + }}})) + ack := unwrapEnvelope(t, readHubEnvelope(t, conn)) + if accepted, ok := ack.(*aop.ProtocolMessage); !ok || accepted.GetAgentAccepted() == nil { t.Fatalf("expected accepted, got %+v", ack) } + commandSpecs := make([]*commandpb.Spec, 0, len(commands)) + for _, command := range commands { + commandSpecs = append(commandSpecs, &commandpb.Spec{Name: command}) + } + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Catalog{Catalog: &commandpb.Catalog{Commands: commandSpecs}}})) + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentStatus{AgentStatus: &aop.AgentStatus{ + Space: status.Space, Provider: status.Provider, Model: status.Model, Bound: status.Bound, ConfigError: status.ConfigError, + }}})) + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentStats{AgentStats: &aop.AgentStats{TotalTokens: 42}}})) return conn } func setupTestServer(t *testing.T) (*httptest.Server, *AgentPool) { t.Helper() - hub := NewHub() - pool := NewAgentPool(hub) + svc := NewService(ServiceConfig{}) + pool := NewAgentPool(svc.Hub()) + svc.SetAgentPool(pool) mux := http.NewServeMux() - mux.HandleFunc("/api/agent/ws", pool.HandleWS) - mux.HandleFunc("GET /api/agents/{id}/terminal/ws", func(w http.ResponseWriter, r *http.Request) { - pool.HandleTerminalWS(r.PathValue("id"), w, r) + mux.HandleFunc("/api/aop/ws", func(w http.ResponseWriter, r *http.Request) { + HandleAOPWebSocket(svc, pool, w, r) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) @@ -169,13 +220,13 @@ func TestWSRegisterAndList(t *testing.T) { time.Sleep(50 * time.Millisecond) agents := pool.List() - if len(agents) != 1 || agents[0].Name != "test-agent" { + if len(agents) != 1 || agents[0].GetHello().GetName() != "test-agent" { t.Fatalf("expected 1 agent named test-agent, got %+v", agents) } - if agents[0].Node.ID != "node-test-agent" || agents[0].Status.Space != "case-test" { + if !strings.Contains(agents[0].NodeUri, "node-test-agent") || agents[0].GetStatus().GetSpace() != "case-test" { t.Fatalf("agent descriptor not retained: %+v", agents[0]) } - if agents[0].Stats.TotalTokens != 42 { + if agents[0].GetStats().GetTotalTokens() != 42 { t.Fatalf("agent stats not retained: %+v", agents[0].Stats) } } @@ -207,7 +258,7 @@ func TestReconnectKeepsStableID(t *testing.T) { conn1 := dialAgent(t, srv, "stable-agent", []string{"scan"}) waitAgents(t, pool, 1) - id1 := pool.List()[0].ID + id1 := pool.List()[0].NodeUri // Drop the connection and let the hub observe the disconnect. conn1.Close() @@ -217,7 +268,7 @@ func TestReconnectKeepsStableID(t *testing.T) { conn2 := dialAgent(t, srv, "stable-agent", []string{"scan"}) defer conn2.Close() waitAgents(t, pool, 1) - id2 := pool.List()[0].ID + id2 := pool.List()[0].NodeUri if id1 != id2 { t.Fatalf("agent id changed across reconnect: %q -> %q (session binding would dangle)", id1, id2) @@ -233,7 +284,7 @@ func TestWSDispatchAndComplete(t *testing.T) { defer conn.Close() time.Sleep(50 * time.Millisecond) - agentID := pool.List()[0].ID + agentID := pool.List()[0].NodeUri progressCh, _, unsub := pool.hub.SubscribeScan("task-1") defer unsub() @@ -246,20 +297,24 @@ func TestWSDispatchAndComplete(t *testing.T) { t.Fatal(err) } - cmd := readServerFrame(t, conn) - if cmd.GetToolCall().GetTaskId() != "task-1" { - t.Fatalf("unexpected: %+v", cmd) + cmdEnvelope := readHubEnvelope(t, conn) + if cmdEnvelope.GetId() != "task-1" { + t.Fatalf("unexpected: %+v", cmdEnvelope) + } + cmd := unwrapEnvelope(t, cmdEnvelope) + toolCall, ok := cmd.(*toolpb.ProtocolMessage) + if !ok || toolCall.GetCall() == nil { + t.Fatalf("unexpected dispatch: %+v", cmd) } - call := cmd.GetToolCall().GetCall() + call := toolCall.GetCall().GetCall() args, _ := aop.DecodeJSON[map[string]any](call.Arguments) if call.Name != "bash" || args["command"] != "scan -i 1.2.3.4" { t.Fatalf("unexpected tool.call data: %+v", call) } - progress, _ := aop.JSONValue("port 80 open") - writeAgentFrame(t, conn, &transport.AgentFrame{CorrelationId: "task-1", Payload: &transport.AgentFrame_ToolTelemetry{ToolTelemetry: &transport.ToolTelemetry{ - Tool: "bash", Kind: output.ToolDataProgress, CallId: "task-1", Data: progress, - }}}) + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "task-1", &toolpb.ProtocolMessage{Message: &toolpb.ProtocolMessage_Progress{Progress: &toolpb.Progress{ + Tool: "bash", Text: "port 80 open", + }}})) select { case evt := <-progressCh: if !strings.Contains(evt.GetProgress().GetData(), "port 80 open") { @@ -269,21 +324,17 @@ func TestWSDispatchAndComplete(t *testing.T) { t.Fatal("timeout") } - detail, _ := aop.JSONValue(map[string]int{"ports": 3}) - writeAgentFrame(t, conn, &transport.AgentFrame{CorrelationId: "task-1", Payload: &transport.AgentFrame_Event{Event: &aop.Event{ + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "task-1", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: &aop.Event{ Id: "result-1", EmittedAt: timestamppb.Now(), SessionId: "task-1", TurnId: "task-1", Emitter: "worker", Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{ - CallId: "task-1", Name: "bash", Output: []*aop.Content{aop.Text("done")}, Detail: detail, + CallId: "task-1", Name: "bash", Output: []*aop.Content{aop.Text("done")}, }}, - }}}) + }}})) select { case res := <-resultCh: if res.Err != "" || res.Output != "done" { t.Fatalf("unexpected result: %+v", res) } - if !strings.Contains(string(res.Result), `"ports":3`) { - t.Fatalf("result details not propagated: %s", res.Result) - } case <-time.After(time.Second): t.Fatal("timeout") } @@ -292,7 +343,7 @@ func TestWSDispatchAndComplete(t *testing.T) { func TestWSDispatchChatUsesAOPMessage(t *testing.T) { srv, pool := setupTestServer(t) conn := dialAgentWithIdentity(t, srv, "chat-worker", []string{"scan"}, "node-chat-worker", - transport.AgentStatus{Space: "case-test", Provider: "openai", Model: "test-model"}) + aop.AgentStatus{Space: "case-test", Provider: "openai", Model: "test-model"}) defer conn.Close() time.Sleep(50 * time.Millisecond) @@ -306,16 +357,17 @@ func TestWSDispatchChatUsesAOPMessage(t *testing.T) { t.Fatal(err) } - cmd := readServerFrame(t, conn) - if cmd.GetRunTurn().GetTurnId() != "task-chat" { + cmd := unwrapEnvelope(t, readHubEnvelope(t, conn)) + core, ok := cmd.(*aop.ProtocolMessage) + if !ok || core.GetRunTurnRequest().GetTurnId() != "task-chat" { t.Fatalf("unexpected: %+v", cmd) } - run := cmd.GetRunTurn() + run := core.GetRunTurnRequest() if len(run.Input.Content) != 1 || run.Input.Content[0].GetText().GetText() != "hello" { t.Fatalf("unexpected run input: %+v", run) } - writeAgentFrame(t, conn, turnEndMessage("task-chat", "sess-chat", "completed")) + writeAgentEnvelope(t, conn, turnEndEnvelope(t, "task-chat", "sess-chat", "completed")) select { case res := <-resultCh: if res.Err != "" { @@ -326,13 +378,14 @@ func TestWSDispatchChatUsesAOPMessage(t *testing.T) { } } -// turnEndMessage builds the agent→hub AOP turn.end frame that converges +// turnEndEnvelope builds the agent→hub AOP turn.end envelope that converges // a chat task. -func turnEndMessage(turnID, sessionID, stop string) *transport.AgentFrame { - return &transport.AgentFrame{CorrelationId: turnID, Payload: &transport.AgentFrame_Event{Event: &aop.Event{ +func turnEndEnvelope(t *testing.T, turnID, sessionID, stop string) *aop.Envelope { + t.Helper() + return wrapMessage(t, generateID(), turnID, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: &aop.Event{ Id: "end-" + turnID, EmittedAt: timestamppb.Now(), SessionId: sessionID, TurnId: turnID, Emitter: "agent", Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: stop}}, - }}} + }}}) } // TestDispatchRunCarriesGoalOptions guards the Goal-mode wiring: the @@ -343,7 +396,7 @@ func turnEndMessage(turnID, sessionID, stop string) *transport.AgentFrame { func TestDispatchRunCarriesGoalOptions(t *testing.T) { srv, pool := setupTestServer(t) conn := dialAgentWithIdentity(t, srv, "goal-worker", []string{"scan"}, "node-goal-worker", - transport.AgentStatus{Provider: "openai", Model: "test-model"}) + aop.AgentStatus{Provider: "openai", Model: "test-model"}) defer conn.Close() time.Sleep(50 * time.Millisecond) @@ -352,33 +405,40 @@ func TestDispatchRunCarriesGoalOptions(t *testing.T) { t.Fatal("expected chat-capable agent") } - options, _ := aop.ProtoJSONValue(&transport.RunOptions{EvalCriteria: "find at least one SQLi", EvalMaxRounds: 5}) + options, err := anypb.New(&agentpb.RunOptions{EvalCriteria: "find at least one SQLi", EvalMaxRounds: 5}) + if err != nil { + t.Fatal(err) + } resultCh, err := pool.DispatchRun(agent.id, &aop.RunTurnRequest{ - RequestId: "task-goal", SessionId: "sess-1", TurnId: "task-goal", + SessionId: "sess-1", TurnId: "task-goal", Input: &aop.Message{Id: "input-task-goal", Role: "user", Content: []*aop.Content{aop.Text("audit target")}}, - Extensions: []*aop.Extension{{Namespace: "io.chainreactors.aiscan.run", Value: options}}, + Extensions: []*anypb.Any{options}, }) if err != nil { t.Fatal(err) } - opened := readServerFrame(t, conn) - if opened.GetOpenSession() == nil { + opened := unwrapEnvelope(t, readHubEnvelope(t, conn)) + if openedCore, ok := opened.(*aop.ProtocolMessage); !ok || openedCore.GetOpenSessionRequest() == nil { t.Fatalf("first frame = %+v, want session.open", opened) } - cmd := readServerFrame(t, conn) - inbound := cmd.GetRunTurn() + cmd := unwrapEnvelope(t, readHubEnvelope(t, conn)) + cmdCore, ok := cmd.(*aop.ProtocolMessage) + if !ok { + t.Fatalf("dispatch did not carry a Run: %+v", cmd) + } + inbound := cmdCore.GetRunTurnRequest() if inbound == nil { t.Fatalf("dispatch did not carry a Run: %+v", cmd) } if inbound.SessionId != "sess-1" || len(inbound.Input.Content) != 1 || inbound.Input.Content[0].GetText().GetText() != "audit target" { t.Errorf("run = %+v", inbound) } - var gotOptions transport.RunOptions - if err := aop.DecodeProtoJSON(inbound.Extensions[0].Value, &gotOptions); err != nil || gotOptions.EvalCriteria != "find at least one SQLi" || gotOptions.EvalMaxRounds != 5 { + var gotOptions agentpb.RunOptions + if err := inbound.Extensions[0].UnmarshalTo(&gotOptions); err != nil || gotOptions.EvalCriteria != "find at least one SQLi" || gotOptions.EvalMaxRounds != 5 { t.Errorf("goal options = %+v, err=%v", gotOptions, err) } - writeAgentFrame(t, conn, turnEndMessage("task-goal", "sess-1", "completed")) + writeAgentEnvelope(t, conn, turnEndEnvelope(t, "task-goal", "sess-1", "completed")) select { case <-resultCh: case <-time.After(time.Second): @@ -401,7 +461,7 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { defer srv.Close() conn := dialAgentWithIdentity(t, srv, "upload-agent", []string{"scan"}, "node-upload-agent", - transport.AgentStatus{Provider: "openai", Model: "test-model"}) + aop.AgentStatus{Provider: "openai", Model: "test-model"}) defer conn.Close() time.Sleep(50 * time.Millisecond) @@ -411,7 +471,7 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { } ctx := context.Background() - session, err := svc.CreateSession(ctx, agents[0].ID, "") + session, err := svc.CreateSession(ctx, agents[0].NodeUri, "") if err != nil { t.Fatal(err) } @@ -419,18 +479,39 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - msg := readServerFrame(t, conn) - upload := msg.GetFileUpload() - if upload == nil || upload.TaskId == "" || len(upload.Data) == 0 { + msg := readHubEnvelope(t, conn) + if msg.GetId() == "" { + t.Errorf("upload envelope missing correlation id: %+v", msg) + return + } + payload, err := aop.Unwrap(msg) + if err != nil { + t.Errorf("unwrap upload: %v", err) + return + } + fileMessage, ok := payload.(*filepb.ProtocolMessage) + if !ok || fileMessage.GetUploadRequest() == nil { t.Errorf("unexpected upload message: %+v", msg) return } - writeAgentFrame(t, conn, &transport.AgentFrame{CorrelationId: msg.CorrelationId, Payload: &transport.AgentFrame_FileResult{FileResult: &transport.FileResult{ - TaskId: upload.TaskId, Filename: upload.Filename, Path: `C:\tmp\note.txt`, Size: int64(len(upload.Data)), - }}}) + upload := fileMessage.GetUploadRequest() + if len(upload.Data) == 0 { + t.Errorf("unexpected upload message: %+v", msg) + return + } + raw, err := protobuf.Marshal(aop.MustWrap(generateID(), msg.GetId(), &filepb.ProtocolMessage{Message: &filepb.ProtocolMessage_Result{Result: &filepb.Result{ + Filename: upload.Filename, Path: `C:\tmp\note.txt`, Size: int64(len(upload.Data)), + }}})) + if err != nil { + t.Errorf("marshal upload result: %v", err) + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, raw); err != nil { + t.Errorf("write upload result: %v", err) + } }() - result, err := svc.HandleFileUpload(ctx, session.ID, "note.txt", []byte("hello")) + result, err := svc.HandleFileUpload(ctx, session.GetSession().GetId(), "note.txt", []byte("hello")) if err != nil { t.Fatal(err) } @@ -444,7 +525,7 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { t.Fatal("timeout waiting for agent upload reply") } - events, err := store.ListAOPEvents(ctx, session.ID, 10) + events, err := store.ListAOPEvents(ctx, session.GetSession().GetId(), 10) if err != nil { t.Fatal(err) } @@ -456,20 +537,15 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) { t.Fatalf("unexpected persisted upload event: %+v", events[0]) } // The English Content is only a fallback; the localizable contract lives in - // Metadata as {code, params} so the message stays translatable after reload. - var meta struct { - Code string `json:"code"` - Params map[string]string `json:"params"` - } + // Typed metadata carries {code, params} so the message stays translatable + // after reload without a second JSON DTO. webExtension, ok, err := ext.GetWebMessage(events[0]) if err != nil || !ok { t.Fatalf("web extension = %+v, ok = %v, err = %v", webExtension, ok, err) } - if err := json.Unmarshal(webExtension.Metadata, &meta); err != nil { - t.Fatalf("decode system message metadata: %v", err) - } - if meta.Code != SysFileUploaded || meta.Params["filename"] != "note.txt" || meta.Params["path"] != result.Path { - t.Fatalf("unexpected system message metadata: %+v", meta) + params := webExtension.GetParams().AsMap() + if webExtension.GetCode() != SysFileUploaded || params["filename"] != "note.txt" || params["path"] != result.Path { + t.Fatalf("unexpected system message metadata: %+v", webExtension) } } @@ -501,9 +577,9 @@ func TestWSUnrecognizedExtensionIsNotProjected(t *testing.T) { progressCh, _, unsub := pool.hub.SubscribeScan("task-2") defer unsub() - writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_OperationError{OperationError: &transport.OperationError{ - TaskId: "unknown-task", Code: "IGNORED", Message: "not progress telemetry", - }}}) + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "unknown-task", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_ProtocolError{ProtocolError: &aop.ProtocolError{ + Code: "IGNORED", Message: "not progress telemetry", + }}})) select { case evt := <-progressCh: @@ -518,35 +594,28 @@ func TestWSTerminalRelay(t *testing.T) { defer agentConn.Close() time.Sleep(50 * time.Millisecond) - agentID := pool.List()[0].ID - terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" - browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("terminal dial: %v", err) - } + agentID := pool.List()[0].NodeUri + browserConn := dialAOPWebSocket(t, srv) defer browserConn.Close() - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameOpen}) + writeBrowserPTYOpen(t, browserConn, &ptypb.Open{StreamId: "term-1", AgentId: agentID}) open := readAgentPTY(t, agentConn, pty.FrameOpen) - if open.StreamID == "" { + if open.StreamID != "term-1" { t.Fatalf("unexpected pty.open: %+v", open) } - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: open.StreamID, SessionID: "session-1"}) + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: open.StreamID, Session: &pty.Info{ID: "session-1"}}) opened := readBrowserPTY(t, browserConn, pty.FrameOpened) - if opened.StreamID != open.StreamID || opened.SessionID != "session-1" { + if opened.StreamID != open.StreamID || opened.Session == nil || opened.Session.ID != "session-1" { t.Fatalf("unexpected pty.opened: %+v", opened) } - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameInput, SessionID: "session-1", Data: []byte("echo pty-ok\n")}) + writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameInput, StreamID: open.StreamID, Data: []byte("echo pty-ok\n")}) input := readAgentPTY(t, agentConn, pty.FrameInput) - if input.StreamID != open.StreamID || input.SessionID != "session-1" || string(input.Data) != "echo pty-ok\n" { + if input.StreamID != open.StreamID || string(input.Data) != "echo pty-ok\n" { t.Fatalf("unexpected pty.input: %+v", input) } @@ -564,30 +633,23 @@ func TestWSTerminalSessionLifecycle(t *testing.T) { defer agentConn.Close() time.Sleep(50 * time.Millisecond) - agentID := pool.List()[0].ID - terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" - browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("dial: %v", err) - } + agentID := pool.List()[0].NodeUri + browserConn := dialAOPWebSocket(t, srv) defer browserConn.Close() // open - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameOpen, Kind: "shell", Name: "test-shell", Cols: 80, Rows: 24}) + writeBrowserPTYOpen(t, browserConn, &ptypb.Open{StreamId: "term-1", AgentId: agentID, Kind: "shell", Name: "test-shell", Cols: 80, Rows: 24}) open := readAgentPTY(t, agentConn, pty.FrameOpen) streamID := open.StreamID - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: streamID, SessionID: "sess-1", Kind: "shell"}) + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: streamID, Session: &pty.Info{ID: "sess-1", Kind: "shell"}}) opened := readBrowserPTY(t, browserConn, pty.FrameOpened) - if opened.SessionID != "sess-1" { + if opened.Session == nil || opened.Session.ID != "sess-1" { t.Fatalf("opened missing session_id: %+v", opened) } // input → output - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameInput, Data: []byte("ls\n")}) + writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameInput, StreamID: streamID, Data: []byte("ls\n")}) inp := readAgentPTY(t, agentConn, pty.FrameInput) if string(inp.Data) != "ls\n" { t.Fatalf("input data lost: %q", inp.Data) @@ -599,14 +661,14 @@ func TestWSTerminalSessionLifecycle(t *testing.T) { } // resize - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameResize, Cols: 120, Rows: 40}) + writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameResize, StreamID: streamID, Cols: 120, Rows: 40}) resize := readAgentPTY(t, agentConn, pty.FrameResize) if resize.Cols != 120 || resize.Rows != 40 { t.Fatalf("resize lost: %+v", resize) } - // list - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameList}) + // list (on the already-routed stream) + writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameList, StreamID: streamID}) list := readAgentPTY(t, agentConn, pty.FrameList) writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: list.StreamID, Sessions: []pty.Info{{ID: "sess-1", Kind: "shell", State: pty.StateRunning}}}) @@ -615,23 +677,23 @@ func TestWSTerminalSessionLifecycle(t *testing.T) { t.Fatalf("sessions missing: %+v", sessions) } - // detach - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameDetach}) - det := readAgentPTY(t, agentConn, pty.FrameDetach) - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameDetached, StreamID: det.StreamID, SessionID: "sess-1"}) - readBrowserPTY(t, browserConn, pty.FrameDetached) + // detach closes the browser route; the agent still receives the frame. + writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameDetach, StreamID: streamID}) + readAgentPTY(t, agentConn, pty.FrameDetach) - // attach - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameAttach, SessionID: "sess-1"}) + // attach rides a fresh stream routed via its list open. + writeBrowserPTYList(t, browserConn, &ptypb.List{StreamId: "term-2", AgentId: agentID}) + readAgentPTY(t, agentConn, pty.FrameList) + writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameAttach, StreamID: "term-2", SessionID: "sess-1"}) att := readAgentPTY(t, agentConn, pty.FrameAttach) - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameAttached, StreamID: att.StreamID, SessionID: "sess-1"}) + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameAttached, StreamID: att.StreamID, Session: &pty.Info{ID: "sess-1"}}) readBrowserPTY(t, browserConn, pty.FrameAttached) // closed - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameClosed, StreamID: streamID, - SessionID: "sess-1", State: pty.StateCompleted}) + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameClosed, StreamID: "term-2", + Session: &pty.Info{ID: "sess-1", State: pty.StateCompleted}}) closed := readBrowserPTY(t, browserConn, pty.FrameClosed) - if closed.State != pty.StateCompleted { + if closed.Session == nil || closed.Session.State != pty.StateCompleted { t.Fatalf("closed state lost: %+v", closed) } } @@ -642,18 +704,11 @@ func TestWSTerminalSingleton(t *testing.T) { defer agentConn.Close() time.Sleep(50 * time.Millisecond) - agentID := pool.List()[0].ID - terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" - browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("dial: %v", err) - } + agentID := pool.List()[0].NodeUri + browserConn := dialAOPWebSocket(t, srv) defer browserConn.Close() - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameOpen, + writeBrowserPTYOpen(t, browserConn, &ptypb.Open{StreamId: "term-1", AgentId: agentID, Kind: "shell", Name: "singleton-shell", Singleton: true, Cols: 80, Rows: 24}) open := readAgentPTY(t, agentConn, pty.FrameOpen) @@ -667,30 +722,27 @@ func TestWSTerminalRebindsAfterAgentReconnect(t *testing.T) { agentConn := dialAgent(t, srv, "generation-agent", []string{"tmux"}) waitAgents(t, pool, 1) - agentID := pool.List()[0].ID - terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" - browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("dial terminal: %v", err) - } + agentID := pool.List()[0].NodeUri + browserConn := dialAOPWebSocket(t, srv) defer browserConn.Close() + writeBrowserPTYOpen(t, browserConn, &ptypb.Open{StreamId: "term-1", AgentId: agentID}) + open := readAgentPTY(t, agentConn, pty.FrameOpen) + streamID := open.StreamID + if err := agentConn.Close(); err != nil { t.Fatalf("close agent: %v", err) } detached := readBrowserPTY(t, browserConn, pty.FrameDetached) - if detached.StreamID == "" { - t.Fatalf("disconnect notification missing stream id: %+v", detached) + if detached.StreamID != streamID { + t.Fatalf("disconnect notification = %+v, want stream %s", detached, streamID) } reconnected := dialAgent(t, srv, "generation-agent", []string{"tmux"}) defer reconnected.Close() list := readAgentPTY(t, reconnected, pty.FrameList) - if list.StreamID != detached.StreamID { - t.Fatalf("rebound stream = %s, want %s", list.StreamID, detached.StreamID) + if list.StreamID != streamID { + t.Fatalf("rebound stream = %s, want %s", list.StreamID, streamID) } writeAgentPTY(t, reconnected, pty.Frame{Type: pty.FrameSessions, StreamID: list.StreamID, Sessions: []pty.Info{{ID: "resident-repl", Kind: "repl", Name: "main-repl", State: pty.StateRunning}}}) @@ -700,28 +752,19 @@ func TestWSTerminalRebindsAfterAgentReconnect(t *testing.T) { } } -func TestWSTerminalCanWaitForOfflineAgent(t *testing.T) { +// TestWSTerminalOfflineAgentDetached pins the contract for opening a terminal +// against an offline agent: the browser immediately learns the agent is +// detached instead of the open hanging until a reconnect. +func TestWSTerminalOfflineAgentDetached(t *testing.T) { srv, _ := setupTestServer(t) agentID := protocols.NodeRef{ID: "node-offline-agent", Authority: srv.URL}.URI() - terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" - browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("dial offline terminal: %v", err) - } + browserConn := dialAOPWebSocket(t, srv) defer browserConn.Close() - readBrowserPTY(t, browserConn, pty.FrameDetached) - agentConn := dialAgent(t, srv, "offline-agent", []string{"tmux"}) - defer agentConn.Close() - list := readAgentPTY(t, agentConn, pty.FrameList) - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: list.StreamID, - Sessions: []pty.Info{{ID: "resident-repl", Kind: "repl", Name: "main-repl", State: pty.StateRunning}}}) - sessions := readBrowserPTY(t, browserConn, pty.FrameSessions) - if len(sessions.Sessions) != 1 || sessions.Sessions[0].ID != "resident-repl" { - t.Fatalf("offline subscription did not rebind: %+v", sessions) + writeBrowserPTYOpen(t, browserConn, &ptypb.Open{StreamId: "term-1", AgentId: agentID}) + detached := readBrowserPTY(t, browserConn, pty.FrameDetached) + if detached.StreamID != "term-1" { + t.Fatalf("offline detached = %+v", detached) } } @@ -731,21 +774,14 @@ func TestWSTerminalBufferPressure(t *testing.T) { defer agentConn.Close() time.Sleep(50 * time.Millisecond) - agentID := pool.List()[0].ID - terminalURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agents/" + url.PathEscape(agentID) + "/terminal/ws" - browserConn, resp, err := websocket.DefaultDialer.Dial(terminalURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("dial: %v", err) - } + agentID := pool.List()[0].NodeUri + browserConn := dialAOPWebSocket(t, srv) defer browserConn.Close() - writeBrowserPTY(t, browserConn, pty.Frame{Type: pty.FrameOpen}) + writeBrowserPTYOpen(t, browserConn, &ptypb.Open{StreamId: "term-1", AgentId: agentID}) open := readAgentPTY(t, agentConn, pty.FrameOpen) streamID := open.StreamID - writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: streamID, SessionID: "sess-1"}) + writeAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameOpened, StreamID: streamID, Session: &pty.Info{ID: "sess-1"}}) readBrowserPTY(t, browserConn, pty.FrameOpened) // Flood: agent sends 100 output messages without browser reading @@ -758,11 +794,15 @@ func TestWSTerminalBufferPressure(t *testing.T) { browserConn.SetReadDeadline(time.Now().Add(time.Second)) received := 0 for { - var m pty.Frame - if err := browserConn.ReadJSON(&m); err != nil { + _, raw, err := browserConn.ReadMessage() + if err != nil { + break + } + envelope := new(aop.Envelope) + if err := protobuf.Unmarshal(raw, envelope); err != nil { break } - if m.Type == pty.FrameOutput { + if ptyFrameFromEnvelope(envelope).Type == pty.FrameOutput { received++ } } @@ -819,29 +859,23 @@ func setupE2EServer(t *testing.T) (*httptest.Server, *AgentPool) { //nolint:unus type mockBrowserAgent struct { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag conn *websocket.Conn - messages chan *transport.ServerFrame + messages chan *aop.Envelope errors chan error } func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *mockBrowserAgent { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/agent/ws" - conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - if resp != nil && resp.Body != nil { - defer resp.Body.Close() - } - if err != nil { - t.Fatalf("dial agent: %v", err) - } - writeAgentFrame(t, conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Hello{Hello: &transport.AgentHello{ - AgentId: "node-" + name, Name: name, Authority: srv.URL, Commands: []string{"tmux"}, - }}}) - ack := readServerFrame(t, conn) - if ack.GetAccepted() == nil { + conn := dialAOPWebSocket(t, srv) + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentHello{AgentHello: &aop.AgentHello{ + AgentId: "node-" + name, Name: name, Authority: srv.URL, + }}})) + ack := unwrapEnvelope(t, readHubEnvelope(t, conn)) + if accepted, ok := ack.(*aop.ProtocolMessage); !ok || accepted.GetAgentAccepted() == nil { t.Fatalf("expected accepted, got %+v", ack) } + writeAgentEnvelope(t, conn, wrapMessage(t, generateID(), "", &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Catalog{Catalog: &commandpb.Catalog{Commands: []*commandpb.Spec{{Name: "tmux"}}}}})) agent := &mockBrowserAgent{ - conn: conn, messages: make(chan *transport.ServerFrame, 64), errors: make(chan error, 1), + conn: conn, messages: make(chan *aop.Envelope, 64), errors: make(chan error, 1), } go func() { defer close(agent.messages) @@ -851,12 +885,12 @@ func dialMockAgent(t *testing.T, srv *httptest.Server, name string) *mockBrowser agent.errors <- err return } - msg := new(transport.ServerFrame) - if err := protojson.Unmarshal(raw, msg); err != nil { + envelope := new(aop.Envelope) + if err := protobuf.Unmarshal(raw, envelope); err != nil { agent.errors <- err return } - agent.messages <- msg + agent.messages <- envelope } }() return agent @@ -883,8 +917,8 @@ func launchBrowser(t *testing.T) *rod.Browser { //nolint:unused // referenced by return browser } -func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []*transport.ServerFrame { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag - var msgs []*transport.ServerFrame +func drainAgentMessages(agent *mockBrowserAgent, timeout time.Duration) []*aop.Envelope { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag + var msgs []*aop.Envelope timer := time.NewTimer(timeout) defer timer.Stop() for { @@ -910,7 +944,7 @@ func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) if !ok { t.Fatalf("agent connection closed while waiting for %s", want) } - frame := terminalcodec.FromProto(msg.GetTerminal()) + frame := ptyFrameFromEnvelope(msg) if frame.Type == want { return frame } @@ -924,7 +958,7 @@ func readMockAgentPTY(t *testing.T, agent *mockBrowserAgent, want pty.FrameType) func writeMockAgentPTY(t *testing.T, agent *mockBrowserAgent, frame pty.Frame) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag t.Helper() - writeAgentFrame(t, agent.conn, &transport.AgentFrame{Payload: &transport.AgentFrame_Terminal{Terminal: terminalcodec.ToProto(frame)}}) + writeAgentEnvelope(t, agent.conn, wrapMessage(t, generateID(), "", terminalcodec.ToProto(frame))) } func openFirstAgentTerminal(t *testing.T, page *rod.Page) { //nolint:unused // referenced by agents_e2e_test.go with the e2e build tag @@ -968,7 +1002,7 @@ func runE2ETerminalOpenAndType(t *testing.T) { //nolint:unused // referenced by attach := readMockAgentPTY(t, agentConn, pty.FrameAttach) replStreamID := attach.StreamID writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameAttached, StreamID: attach.StreamID, - SessionID: "e2e-sess-1", Kind: "repl"}) + Session: &pty.Info{ID: "e2e-sess-1", Kind: "repl"}}) time.Sleep(300 * time.Millisecond) @@ -988,7 +1022,7 @@ func runE2ETerminalOpenAndType(t *testing.T) { //nolint:unused // referenced by inputs := drainAgentMessages(agentConn, time.Second) gotInput := false for _, m := range inputs { - frame := terminalcodec.FromProto(m.GetTerminal()) + frame := ptyFrameFromEnvelope(m) if frame.Type == pty.FrameInput && frame.StreamID == replStreamID { gotInput = true break @@ -1005,7 +1039,7 @@ func runE2ETerminalOpenAndType(t *testing.T) { //nolint:unused // referenced by // Agent sends pty.closed writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameClosed, StreamID: replStreamID, - SessionID: "e2e-sess-1", State: pty.StateCompleted}) + Session: &pty.Info{ID: "e2e-sess-1", State: pty.StateCompleted}}) refresh := readMockAgentPTY(t, agentConn, pty.FrameList) writeMockAgentPTY(t, agentConn, pty.Frame{Type: pty.FrameSessions, StreamID: refresh.StreamID}) if _, err := page.Timeout(5 * time.Second).Element(`[title='Console'], [title='控制台']`); err != nil { @@ -1038,7 +1072,7 @@ func runE2ETerminalResize(t *testing.T) { //nolint:unused // referenced by agent }) attach := readMockAgentPTY(t, agentConn, pty.FrameAttach) writeMockAgentPTY(t, agentConn, pty.Frame{ - Type: pty.FrameAttached, StreamID: attach.StreamID, SessionID: "resize-sess", Kind: "repl", + Type: pty.FrameAttached, StreamID: attach.StreamID, Session: &pty.Info{ID: "resize-sess", Kind: "repl"}, }) _ = drainAgentMessages(agentConn, 200*time.Millisecond) @@ -1049,7 +1083,7 @@ func runE2ETerminalResize(t *testing.T) { //nolint:unused // referenced by agent msgs := drainAgentMessages(agentConn, time.Second) resizeReceived := false for _, m := range msgs { - frame := terminalcodec.FromProto(m.GetTerminal()) + frame := ptyFrameFromEnvelope(m) if frame.Type == pty.FrameResize { resizeReceived = true t.Logf("resize received: %+v", frame) @@ -1066,21 +1100,26 @@ func TestCancelTaskConvergesPendingTaskImmediately(t *testing.T) { resultCh := make(chan taskResult, 1) remote := &remoteAgent{ id: "agent-1", - sendCh: make(chan *transport.ServerFrame, 1), - controlCh: make(chan *transport.ServerFrame, 1), + sendCh: make(chan *aop.Envelope, 1), tasks: map[string]chan taskResult{"task-1": resultCh}, turns: map[string]int{"task-1": 1}, toolCalls: make(map[string]struct{}), childSessions: make(map[string]map[string]struct{}), + done: make(chan struct{}), } pool.agents[remote.id] = remote pool.CancelTask(remote.id, "task-1", "session-1") select { - case frame := <-remote.controlCh: - if frame.GetCancelTurn().GetSessionId() != "session-1" || frame.GetCancelTurn().GetTurnId() != "task-1" { - t.Fatalf("cancel frame = %+v", frame) + case envelope := <-remote.sendCh: + message, err := aop.Unwrap(envelope) + if err != nil { + t.Fatal(err) + } + core, ok := message.(*aop.ProtocolMessage) + if !ok || core.GetCancelTurnRequest().GetSessionId() != "session-1" || core.GetCancelTurnRequest().GetTurnId() != "task-1" { + t.Fatalf("cancel envelope = %+v", message) } default: t.Fatal("cancel frame was not sent") diff --git a/pkg/web/aop_chat.go b/pkg/web/aop_chat.go new file mode 100644 index 00000000..45db2dac --- /dev/null +++ b/pkg/web/aop_chat.go @@ -0,0 +1,490 @@ +package web + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + aop "github.com/chainreactors/aiscan/aop" + chatpb "github.com/chainreactors/aiscan/pkg/types/chat" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" + "google.golang.org/protobuf/proto" +) + +type aopChatServer struct { + service *Service + mu sync.Mutex +} + +const agentControlTimeout = 10 * time.Second + +func NewAOPChatServer(service *Service) *aopChatServer { + return &aopChatServer{service: service} +} + +func (s *aopChatServer) OpenSession(ctx context.Context, requestID string, req *aop.OpenSessionRequest) (*aop.OpenSessionResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, fmt.Errorf("chat service is unavailable") + } + if req == nil || strings.TrimSpace(requestID) == "" { + return rejectedOpen("INVALID_ARGUMENT", "envelope id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.OpenSessionResponse) + hash, found, conflict, err := s.beginRequest(ctx, "OpenSession", requestID, req, replayed) + if err != nil { + return nil, fmt.Errorf("load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedOpen("ALREADY_EXISTS", "envelope id conflicts with another request"), nil + } + finish := func(response *aop.OpenSessionResponse) (*aop.OpenSessionResponse, error) { + if err := s.finishRequest(ctx, "OpenSession", requestID, hash, response); err != nil { + return nil, fmt.Errorf("save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.NodeUri) == "" { + return finish(rejectedOpen("INVALID_ARGUMENT", "node_uri is required")) + } + scanID, err := openSessionScanID(req) + if err != nil { + return finish(rejectedOpen("INVALID_ARGUMENT", err.Error())) + } + if s.service.agents == nil || s.service.agents.get(req.NodeUri) == nil { + return finish(rejectedOpen("UNAVAILABLE", "node is not connected")) + } + + id := strings.TrimSpace(req.SessionId) + if id == "" { + id = generateID() + } + createdNew := false + var created *chatpb.SessionRecord + if existing, err := s.service.store.GetSession(ctx, id); err == nil { + if existing.GetSession().GetNodeUri() != req.NodeUri { + return finish(rejectedOpen("ALREADY_EXISTS", "session is bound to another node")) + } + created = existing + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get session: %v", err) + } else { + now := nowProto() + created = &chatpb.SessionRecord{ + Session: &aop.Session{Id: id, State: SessionStateOpen, NodeUri: req.NodeUri, Title: req.Title}, + CreatedAt: now, UpdatedAt: now, + } + if agent := s.service.agents.get(req.NodeUri); agent != nil { + created.AgentName = agent.name + } + if err := s.service.store.CreateSession(ctx, created); err != nil { + return nil, fmt.Errorf("create session: %v", err) + } + createdNew = true + } + if scanID != "" { + if _, err := s.service.store.Get(ctx, scanID); err != nil { + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return finish(rejectedOpen("NOT_FOUND", "scan not found")) + } + if err := s.service.store.LinkScanToSession(ctx, id, scanID); err != nil { + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return nil, fmt.Errorf("link scan to session: %v", err) + } + } + if !s.service.agents.SessionOpen(req.NodeUri, id) { + forward := proto.Clone(req).(*aop.OpenSessionRequest) + forward.SessionId = id + resultCh, err := s.service.agents.DispatchOpenSession(req.NodeUri, requestID, forward) + if err != nil { + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return finish(rejectedOpen("UNAVAILABLE", err.Error())) + } + timer := time.NewTimer(agentControlTimeout) + defer timer.Stop() + select { + case result, ok := <-resultCh: + if !ok || result.Err != "" { + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + message := result.Err + if message == "" { + message = "node disconnected while opening session" + } + return finish(rejectedOpen("FAILED_PRECONDITION", message)) + } + case <-ctx.Done(): + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return nil, ctx.Err() + case <-timer.C: + if createdNew { + _ = s.service.store.DeleteSession(context.Background(), id) + } + return finish(rejectedOpen("UNAVAILABLE", "node timed out while opening session")) + } + } + return finish(&aop.OpenSessionResponse{Outcome: &aop.OpenSessionResponse_Accepted{Accepted: created.GetSession()}}) +} + +func (s *aopChatServer) RunTurn(ctx context.Context, requestID string, req *aop.RunTurnRequest) (*aop.RunTurnResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, fmt.Errorf("chat service is unavailable") + } + if req == nil || strings.TrimSpace(requestID) == "" { + return rejectedRun("INVALID_ARGUMENT", "envelope id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.RunTurnResponse) + hash, found, conflict, err := s.beginRequest(ctx, "RunTurn", requestID, req, replayed) + if err != nil { + return nil, fmt.Errorf("load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedRun("ALREADY_EXISTS", "envelope id conflicts with another request"), nil + } + finish := func(response *aop.RunTurnResponse) (*aop.RunTurnResponse, error) { + if err := s.finishRequest(ctx, "RunTurn", requestID, hash, response); err != nil { + return nil, fmt.Errorf("save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.SessionId) == "" || (!req.ContinueSession && (req.Input == nil || len(req.Input.Content) == 0)) { + return finish(rejectedRun("INVALID_ARGUMENT", "session_id and input.content are required unless continue_session is true")) + } + session, err := s.service.store.GetSession(ctx, req.SessionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedRun("NOT_FOUND", "session not found")) + } + return nil, fmt.Errorf("get session: %v", err) + } + if s.service.sessionAgent(req.SessionId) == nil { + return finish(rejectedRun("UNAVAILABLE", "node is not connected")) + } + turnID := strings.TrimSpace(req.TurnId) + if turnID == "" { + turnID = generateID() + } + session.UpdatedAt = nowProto() + if session.GetSession().GetTitle() == "" { + if session.Session == nil { + session.Session = &aop.Session{} + } + session.Session.Title = contentText(req.Input.Content, 60) + } + _ = s.service.store.UpdateSession(ctx, session) + + forward := *req + if forward.Input == nil { + forward.Input = &aop.Message{Role: "user"} + } + forward.TurnId = turnID + response := &aop.RunTurnResponse{Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ + SessionId: req.SessionId, TurnId: turnID, State: "running", + }}} + if _, err := finish(response); err != nil { + return nil, err + } + if !req.ContinueSession { + s.service.broadcastUserMessage(req.SessionId, turnID, forward.Input) + } + s.service.handleAgentRun(req.SessionId, &forward) + return response, nil +} + +func (s *aopChatServer) CancelTurn(ctx context.Context, requestID string, req *aop.CancelTurnRequest) (*aop.CancelTurnResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, fmt.Errorf("chat service is unavailable") + } + if req == nil || strings.TrimSpace(requestID) == "" { + return rejectedCancel("INVALID_ARGUMENT", "envelope id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.CancelTurnResponse) + hash, found, conflict, err := s.beginRequest(ctx, "CancelTurn", requestID, req, replayed) + if err != nil { + return nil, fmt.Errorf("load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedCancel("ALREADY_EXISTS", "envelope id conflicts with another request"), nil + } + finish := func(response *aop.CancelTurnResponse) (*aop.CancelTurnResponse, error) { + if err := s.finishRequest(ctx, "CancelTurn", requestID, hash, response); err != nil { + return nil, fmt.Errorf("save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.SessionId) == "" || strings.TrimSpace(req.TurnId) == "" { + return finish(rejectedCancel("INVALID_ARGUMENT", "session_id and turn_id are required")) + } + if err := s.service.CancelTurn(ctx, req.SessionId, req.TurnId); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedCancel("NOT_FOUND", "session not found")) + } + if errors.Is(err, ErrTurnNotFound) { + return finish(rejectedCancel("NOT_FOUND", "turn not found")) + } + return nil, fmt.Errorf("cancel turn: %v", err) + } + return finish(&aop.CancelTurnResponse{Outcome: &aop.CancelTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ + SessionId: req.SessionId, TurnId: req.TurnId, State: "canceled", + }}}) +} + +func (s *aopChatServer) CloseSession(ctx context.Context, requestID string, req *aop.CloseSessionRequest) (*aop.CloseSessionResponse, error) { + if s.service == nil || s.service.store == nil { + return nil, fmt.Errorf("chat service is unavailable") + } + if req == nil || strings.TrimSpace(requestID) == "" { + return rejectedClose("INVALID_ARGUMENT", "envelope id is required"), nil + } + s.mu.Lock() + defer s.mu.Unlock() + replayed := new(aop.CloseSessionResponse) + hash, found, conflict, err := s.beginRequest(ctx, "CloseSession", requestID, req, replayed) + if err != nil { + return nil, fmt.Errorf("load request journal: %v", err) + } + if found { + return replayed, nil + } + if conflict { + return rejectedClose("ALREADY_EXISTS", "envelope id conflicts with another request"), nil + } + finish := func(response *aop.CloseSessionResponse) (*aop.CloseSessionResponse, error) { + if err := s.finishRequest(ctx, "CloseSession", requestID, hash, response); err != nil { + return nil, fmt.Errorf("save request journal: %v", err) + } + return response, nil + } + if strings.TrimSpace(req.SessionId) == "" { + return finish(rejectedClose("INVALID_ARGUMENT", "session_id is required")) + } + session, err := s.service.store.GetSession(ctx, req.SessionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return finish(rejectedClose("NOT_FOUND", "session not found")) + } + return nil, fmt.Errorf("get session: %v", err) + } + agentConnected := s.service.agents != nil && s.service.agents.get(session.GetSession().GetNodeUri()) != nil + if agentConnected { + resultCh, dispatchErr := s.service.agents.DispatchCloseSession(session.GetSession().GetNodeUri(), requestID, proto.Clone(req).(*aop.CloseSessionRequest)) + if dispatchErr != nil { + return finish(rejectedClose("UNAVAILABLE", dispatchErr.Error())) + } + timer := time.NewTimer(agentControlTimeout) + defer timer.Stop() + select { + case result, ok := <-resultCh: + if !ok || result.Err != "" { + message := result.Err + if message == "" { + message = "node disconnected while closing session" + } + return finish(rejectedClose("FAILED_PRECONDITION", message)) + } + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + return finish(rejectedClose("UNAVAILABLE", "node timed out while closing session")) + } + } + if session.Session == nil { + session.Session = &aop.Session{} + } + session.Session.State = SessionStateClosed + session.UpdatedAt = nowProto() + if err := s.service.store.UpdateSession(ctx, session); err != nil { + return nil, fmt.Errorf("close session: %v", err) + } + if !agentConnected { + s.service.BroadcastAOPEvent(req.SessionId, &aop.Event{ + SessionId: req.SessionId, Emitter: "aiscan.web", + Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: req.Reason}}, + }) + } + return finish(&aop.CloseSessionResponse{Outcome: &aop.CloseSessionResponse_Accepted{Accepted: session.GetSession()}}) +} + +func (s *aopChatServer) ListEvents(ctx context.Context, req *aop.ListEventsRequest) (*aop.ListEventsResponse, error) { + if s.service == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { + return nil, fmt.Errorf("session_id is required") + } + after, err := parseAOPCursor(req.AfterCursor) + if err != nil { + return nil, err + } + stored, err := s.service.store.ListAOPEventsAfter(ctx, req.SessionId, after, int(req.Limit)) + if err != nil { + return nil, fmt.Errorf("list events: %v", err) + } + response := &aop.ListEventsResponse{Events: make([]*aop.EventDelivery, 0, len(stored))} + for _, item := range stored { + response.Events = append(response.Events, delivery(item.Cursor, item.Event)) + response.NextCursor = strconv.FormatInt(item.Cursor, 10) + } + return response, nil +} + +func (s *aopChatServer) watchEvents(req *aop.WatchEventsRequest, ctx context.Context, send func(*aop.EventDelivery) error) error { + if s.service == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { + return fmt.Errorf("session_id is required") + } + if send == nil { + return fmt.Errorf("event sender is unavailable") + } + after, err := parseAOPCursor(req.AfterCursor) + if err != nil { + return err + } + live, unsubscribe := s.service.hub.SubscribeAOP(req.SessionId) + defer unsubscribe() + replayed, err := s.service.store.ListAOPEventsAfter(ctx, req.SessionId, after, 0) + if err != nil { + return fmt.Errorf("replay events: %v", err) + } + for _, item := range replayed { + if err := send(delivery(item.Cursor, item.Event)); err != nil { + return err + } + if item.Cursor > after { + after = item.Cursor + } + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case item, ok := <-live: + if !ok { + return nil + } + if item.Event == nil || (item.Cursor > 0 && item.Cursor <= after) { + continue + } + if err := send(delivery(item.Cursor, item.Event)); err != nil { + return err + } + if item.Cursor > after { + after = item.Cursor + } + } + } +} + +func (s *aopChatServer) beginRequest(ctx context.Context, method, requestID string, request, response proto.Message) (hash []byte, found, conflict bool, err error) { + raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) + if err != nil { + return nil, false, false, err + } + digest := sha256.Sum256(raw) + found, conflict, err = s.service.store.LoadAOPRequest(ctx, requestID, method, digest[:], response) + return digest[:], found, conflict, err +} + +func (s *aopChatServer) finishRequest(ctx context.Context, method, requestID string, hash []byte, response proto.Message) error { + return s.service.store.SaveAOPRequest(ctx, requestID, method, hash, response) +} + +func delivery(cursor int64, event *aop.Event) *aop.EventDelivery { + value := "" + if cursor > 0 { + value = strconv.FormatInt(cursor, 10) + } + return &aop.EventDelivery{Cursor: value, Event: event} +} + +func parseAOPCursor(value string) (int64, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + cursor, err := strconv.ParseInt(value, 10, 64) + if err != nil || cursor < 0 { + return 0, fmt.Errorf("invalid cursor %q", value) + } + return cursor, nil +} + +func openSessionScanID(request *aop.OpenSessionRequest) (string, error) { + if request == nil { + return "", nil + } + for _, extension := range request.Extensions { + link := new(scanpb.SessionBinding) + if extension == nil || !extension.MessageIs(link) { + continue + } + if err := extension.UnmarshalTo(link); err != nil { + return "", fmt.Errorf("decode scan extension: %w", err) + } + return strings.TrimSpace(link.ScanId), nil + } + return "", nil +} + +func contentText(content []*aop.Content, limit int) string { + var text strings.Builder + for _, part := range content { + value := part.GetText().GetText() + if value == "" { + continue + } + if text.Len() > 0 { + text.WriteByte(' ') + } + text.WriteString(value) + } + value := strings.TrimSpace(text.String()) + if limit > 0 && len(value) > limit { + return value[:limit] + "..." + } + return value +} + +func rejection(code, message string) *aop.Rejection { + return &aop.Rejection{Code: code, Message: message} +} + +func rejectedOpen(code, message string) *aop.OpenSessionResponse { + return &aop.OpenSessionResponse{Outcome: &aop.OpenSessionResponse_Rejected{Rejected: rejection(code, message)}} +} + +func rejectedRun(code, message string) *aop.RunTurnResponse { + return &aop.RunTurnResponse{Outcome: &aop.RunTurnResponse_Rejected{Rejected: rejection(code, message)}} +} + +func rejectedCancel(code, message string) *aop.CancelTurnResponse { + return &aop.CancelTurnResponse{Outcome: &aop.CancelTurnResponse_Rejected{Rejected: rejection(code, message)}} +} + +func rejectedClose(code, message string) *aop.CloseSessionResponse { + return &aop.CloseSessionResponse{Outcome: &aop.CloseSessionResponse_Rejected{Rejected: rejection(code, message)}} +} diff --git a/pkg/web/aop_endpoint.go b/pkg/web/aop_endpoint.go new file mode 100644 index 00000000..5510c06c --- /dev/null +++ b/pkg/web/aop_endpoint.go @@ -0,0 +1,37 @@ +package web + +import ( + "net/http" + + aop "github.com/chainreactors/aiscan/aop" +) + +// HandleAOPWebSocket is the only application WebSocket entrypoint. The first +// protobuf Envelope selects the concrete peer role: AgentHello enters the +// runner loop; every other supported request enters the browser application +// loop. Both roles use the same envelope and namespace messages. +func HandleAOPWebSocket(service *Service, agents *AgentPool, w http.ResponseWriter, r *http.Request) { + if service == nil || agents == nil { + http.Error(w, "AOP WebSocket is unavailable", http.StatusServiceUnavailable) + return + } + conn, err := agents.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + stream := &webSocketEnvelopeStream{conn: conn} + first, err := stream.Recv() + if err != nil { + return + } + message, err := aop.Unwrap(first) + if err != nil { + return + } + if core, ok := message.(*aop.ProtocolMessage); ok && core.GetAgentHello() != nil { + _ = agents.serveAgentStream(r.Context(), stream, first) + return + } + _ = service.serveBrowserAOP(r.Context(), stream, first) +} diff --git a/pkg/web/aop_grpc.go b/pkg/web/aop_grpc.go deleted file mode 100644 index 8cea8971..00000000 --- a/pkg/web/aop_grpc.go +++ /dev/null @@ -1,503 +0,0 @@ -package web - -import ( - "context" - "crypto/sha256" - "database/sql" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - aop "github.com/chainreactors/aiscan/aop" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" -) - -type aopChatServer struct { - aop.UnimplementedChatServiceServer - service *Service - mu sync.Mutex -} - -const agentControlTimeout = 10 * time.Second - -func NewAOPChatServer(service *Service) aop.ChatServiceServer { - return &aopChatServer{service: service} -} - -func (s *aopChatServer) OpenSession(ctx context.Context, req *aop.OpenSessionRequest) (*aop.OpenSessionResponse, error) { - if s.service == nil || s.service.store == nil { - return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") - } - if req == nil || strings.TrimSpace(req.RequestId) == "" { - return rejectedOpen(req, codes.InvalidArgument, "request_id is required"), nil - } - s.mu.Lock() - defer s.mu.Unlock() - replayed := new(aop.OpenSessionResponse) - hash, found, conflict, err := s.beginRequest(ctx, "OpenSession", req.RequestId, req, replayed) - if err != nil { - return nil, status.Errorf(codes.Internal, "load request journal: %v", err) - } - if found { - return replayed, nil - } - if conflict { - return rejectedOpen(req, codes.AlreadyExists, "request_id conflicts with another request"), nil - } - finish := func(response *aop.OpenSessionResponse) (*aop.OpenSessionResponse, error) { - if err := s.finishRequest(ctx, "OpenSession", req.RequestId, hash, response); err != nil { - return nil, status.Errorf(codes.Internal, "save request journal: %v", err) - } - return response, nil - } - if strings.TrimSpace(req.Participant) == "" { - return finish(rejectedOpen(req, codes.InvalidArgument, "participant is required")) - } - if s.service.agents == nil || s.service.agents.get(req.Participant) == nil { - return finish(rejectedOpen(req, codes.Unavailable, "participant is not connected")) - } - - id := strings.TrimSpace(req.SessionId) - if id == "" { - id = generateID() - } - createdNew := false - var created *ChatSession - if existing, err := s.service.store.GetSession(ctx, id); err == nil { - if existing.AgentID != req.Participant { - return finish(rejectedOpen(req, codes.AlreadyExists, "session is bound to another participant")) - } - created = existing - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, status.Errorf(codes.Internal, "get session: %v", err) - } else { - now := time.Now() - created = &ChatSession{ - ID: id, AgentID: req.Participant, Title: req.Title, Status: SessionActive, - CreatedAt: now, UpdatedAt: now, - } - if agent := s.service.agents.get(req.Participant); agent != nil { - created.AgentName = agent.name - } - if err := s.service.store.CreateSession(ctx, created); err != nil { - return nil, status.Errorf(codes.Internal, "create session: %v", err) - } - createdNew = true - } - if !s.service.agents.SessionOpen(req.Participant, id) { - forward := proto.Clone(req).(*aop.OpenSessionRequest) - forward.SessionId = id - resultCh, err := s.service.agents.DispatchOpenSession(req.Participant, forward) - if err != nil { - if createdNew { - _ = s.service.store.DeleteSession(context.Background(), id) - } - return finish(rejectedOpen(req, codes.Unavailable, err.Error())) - } - timer := time.NewTimer(agentControlTimeout) - defer timer.Stop() - select { - case result, ok := <-resultCh: - if !ok || result.Err != "" { - if createdNew { - _ = s.service.store.DeleteSession(context.Background(), id) - } - message := result.Err - if message == "" { - message = "participant disconnected while opening session" - } - return finish(rejectedOpen(req, codes.FailedPrecondition, message)) - } - case <-ctx.Done(): - if createdNew { - _ = s.service.store.DeleteSession(context.Background(), id) - } - return nil, status.FromContextError(ctx.Err()).Err() - case <-timer.C: - if createdNew { - _ = s.service.store.DeleteSession(context.Background(), id) - } - return finish(rejectedOpen(req, codes.Unavailable, "participant timed out while opening session")) - } - } - return finish(&aop.OpenSessionResponse{RequestId: req.RequestId, Outcome: &aop.OpenSessionResponse_Accepted{Accepted: sessionToAOP(created)}}) -} - -func (s *aopChatServer) RunTurn(ctx context.Context, req *aop.RunTurnRequest) (*aop.RunTurnResponse, error) { - if s.service == nil || s.service.store == nil { - return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") - } - if req == nil || strings.TrimSpace(req.RequestId) == "" { - return rejectedRun(req, codes.InvalidArgument, "request_id is required"), nil - } - s.mu.Lock() - defer s.mu.Unlock() - replayed := new(aop.RunTurnResponse) - hash, found, conflict, err := s.beginRequest(ctx, "RunTurn", req.RequestId, req, replayed) - if err != nil { - return nil, status.Errorf(codes.Internal, "load request journal: %v", err) - } - if found { - return replayed, nil - } - if conflict { - return rejectedRun(req, codes.AlreadyExists, "request_id conflicts with another request"), nil - } - finish := func(response *aop.RunTurnResponse) (*aop.RunTurnResponse, error) { - if err := s.finishRequest(ctx, "RunTurn", req.RequestId, hash, response); err != nil { - return nil, status.Errorf(codes.Internal, "save request journal: %v", err) - } - return response, nil - } - if strings.TrimSpace(req.SessionId) == "" || (!req.ContinueSession && (req.Input == nil || len(req.Input.Content) == 0)) { - return finish(rejectedRun(req, codes.InvalidArgument, "session_id and input.content are required unless continue_session is true")) - } - session, err := s.service.store.GetSession(ctx, req.SessionId) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return finish(rejectedRun(req, codes.NotFound, "session not found")) - } - return nil, status.Errorf(codes.Internal, "get session: %v", err) - } - if s.service.sessionAgent(req.SessionId) == nil { - return finish(rejectedRun(req, codes.Unavailable, "participant is not connected")) - } - turnID := strings.TrimSpace(req.TurnId) - if turnID == "" { - turnID = generateID() - } - session.UpdatedAt = time.Now() - if session.Title == "" { - session.Title = contentText(req.Input.Content, 60) - } - _ = s.service.store.UpdateSession(ctx, session) - - forward := *req - if forward.Input == nil { - forward.Input = &aop.Message{Role: "user"} - } - forward.TurnId = turnID - response := &aop.RunTurnResponse{RequestId: req.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ - SessionId: req.SessionId, TurnId: turnID, State: "running", - }}} - if _, err := finish(response); err != nil { - return nil, err - } - s.service.handleAgentRun(req.SessionId, &forward) - return response, nil -} - -func (s *aopChatServer) CancelTurn(ctx context.Context, req *aop.CancelTurnRequest) (*aop.CancelTurnResponse, error) { - if s.service == nil || s.service.store == nil { - return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") - } - if req == nil || strings.TrimSpace(req.RequestId) == "" { - return rejectedCancel(req, codes.InvalidArgument, "request_id is required"), nil - } - s.mu.Lock() - defer s.mu.Unlock() - replayed := new(aop.CancelTurnResponse) - hash, found, conflict, err := s.beginRequest(ctx, "CancelTurn", req.RequestId, req, replayed) - if err != nil { - return nil, status.Errorf(codes.Internal, "load request journal: %v", err) - } - if found { - return replayed, nil - } - if conflict { - return rejectedCancel(req, codes.AlreadyExists, "request_id conflicts with another request"), nil - } - finish := func(response *aop.CancelTurnResponse) (*aop.CancelTurnResponse, error) { - if err := s.finishRequest(ctx, "CancelTurn", req.RequestId, hash, response); err != nil { - return nil, status.Errorf(codes.Internal, "save request journal: %v", err) - } - return response, nil - } - if strings.TrimSpace(req.SessionId) == "" || strings.TrimSpace(req.TurnId) == "" { - return finish(rejectedCancel(req, codes.InvalidArgument, "session_id and turn_id are required")) - } - if err := s.service.CancelTurn(ctx, req.SessionId, req.TurnId); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return finish(rejectedCancel(req, codes.NotFound, "session not found")) - } - if errors.Is(err, ErrTurnNotFound) { - return finish(rejectedCancel(req, codes.NotFound, "turn not found")) - } - return nil, status.Errorf(codes.Internal, "cancel turn: %v", err) - } - return finish(&aop.CancelTurnResponse{RequestId: req.RequestId, Outcome: &aop.CancelTurnResponse_Accepted{Accepted: &aop.TurnReceipt{ - SessionId: req.SessionId, TurnId: req.TurnId, State: "canceled", - }}}) -} - -func (s *aopChatServer) CloseSession(ctx context.Context, req *aop.CloseSessionRequest) (*aop.CloseSessionResponse, error) { - if s.service == nil || s.service.store == nil { - return nil, status.Error(codes.FailedPrecondition, "chat service is unavailable") - } - if req == nil || strings.TrimSpace(req.RequestId) == "" { - return rejectedClose(req, codes.InvalidArgument, "request_id is required"), nil - } - s.mu.Lock() - defer s.mu.Unlock() - replayed := new(aop.CloseSessionResponse) - hash, found, conflict, err := s.beginRequest(ctx, "CloseSession", req.RequestId, req, replayed) - if err != nil { - return nil, status.Errorf(codes.Internal, "load request journal: %v", err) - } - if found { - return replayed, nil - } - if conflict { - return rejectedClose(req, codes.AlreadyExists, "request_id conflicts with another request"), nil - } - finish := func(response *aop.CloseSessionResponse) (*aop.CloseSessionResponse, error) { - if err := s.finishRequest(ctx, "CloseSession", req.RequestId, hash, response); err != nil { - return nil, status.Errorf(codes.Internal, "save request journal: %v", err) - } - return response, nil - } - if strings.TrimSpace(req.SessionId) == "" { - return finish(rejectedClose(req, codes.InvalidArgument, "session_id is required")) - } - session, err := s.service.store.GetSession(ctx, req.SessionId) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return finish(rejectedClose(req, codes.NotFound, "session not found")) - } - return nil, status.Errorf(codes.Internal, "get session: %v", err) - } - agentConnected := s.service.agents != nil && s.service.agents.get(session.AgentID) != nil - if agentConnected { - resultCh, dispatchErr := s.service.agents.DispatchCloseSession(session.AgentID, proto.Clone(req).(*aop.CloseSessionRequest)) - if dispatchErr != nil { - return finish(rejectedClose(req, codes.Unavailable, dispatchErr.Error())) - } - timer := time.NewTimer(agentControlTimeout) - defer timer.Stop() - select { - case result, ok := <-resultCh: - if !ok || result.Err != "" { - message := result.Err - if message == "" { - message = "participant disconnected while closing session" - } - return finish(rejectedClose(req, codes.FailedPrecondition, message)) - } - case <-ctx.Done(): - return nil, status.FromContextError(ctx.Err()).Err() - case <-timer.C: - return finish(rejectedClose(req, codes.Unavailable, "participant timed out while closing session")) - } - } - session.Status = SessionArchived - session.UpdatedAt = time.Now() - if err := s.service.store.UpdateSession(ctx, session); err != nil { - return nil, status.Errorf(codes.Internal, "close session: %v", err) - } - if !agentConnected { - s.service.BroadcastAOPEvent(req.SessionId, &aop.Event{ - SessionId: req.SessionId, Emitter: "aiscan.web", - Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: req.Reason}}, - }) - } - return finish(&aop.CloseSessionResponse{RequestId: req.RequestId, Outcome: &aop.CloseSessionResponse_Accepted{Accepted: sessionToAOP(session)}}) -} - -func (s *aopChatServer) ListEvents(ctx context.Context, req *aop.ListEventsRequest) (*aop.ListEventsResponse, error) { - if s.service == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { - return nil, status.Error(codes.InvalidArgument, "session_id is required") - } - after, err := parseAOPCursor(req.AfterCursor) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - stored, err := s.service.store.ListAOPEventsAfter(ctx, req.SessionId, after, int(req.Limit)) - if err != nil { - return nil, status.Errorf(codes.Internal, "list events: %v", err) - } - response := &aop.ListEventsResponse{Events: make([]*aop.EventDelivery, 0, len(stored))} - for _, item := range stored { - response.Events = append(response.Events, delivery(item.Cursor, item.Event)) - response.NextCursor = strconv.FormatInt(item.Cursor, 10) - } - return response, nil -} - -func (s *aopChatServer) WatchEvents(req *aop.WatchEventsRequest, stream aop.ChatService_WatchEventsServer) error { - return s.watchEvents(req, stream.Context(), func(response *aop.WatchEventsResponse) error { - return stream.Send(response) - }) -} - -func (s *aopChatServer) watchEvents(req *aop.WatchEventsRequest, ctx context.Context, send func(*aop.WatchEventsResponse) error) error { - if s.service == nil || req == nil || strings.TrimSpace(req.SessionId) == "" { - return status.Error(codes.InvalidArgument, "session_id is required") - } - if send == nil { - return status.Error(codes.Internal, "event sender is unavailable") - } - after, err := parseAOPCursor(req.AfterCursor) - if err != nil { - return status.Error(codes.InvalidArgument, err.Error()) - } - live, unsubscribe := s.service.hub.SubscribeAOP(req.SessionId) - defer unsubscribe() - replayed, err := s.service.store.ListAOPEventsAfter(ctx, req.SessionId, after, 0) - if err != nil { - return status.Errorf(codes.Internal, "replay events: %v", err) - } - for _, item := range replayed { - if err := send(&aop.WatchEventsResponse{Delivery: delivery(item.Cursor, item.Event)}); err != nil { - return err - } - if item.Cursor > after { - after = item.Cursor - } - } - for { - select { - case <-ctx.Done(): - return ctx.Err() - case item, ok := <-live: - if !ok { - return nil - } - if item.Event == nil || (item.Cursor > 0 && item.Cursor <= after) { - continue - } - if err := send(&aop.WatchEventsResponse{Delivery: delivery(item.Cursor, item.Event)}); err != nil { - return err - } - if item.Cursor > after { - after = item.Cursor - } - } - } -} - -func (s *aopChatServer) beginRequest(ctx context.Context, method, requestID string, request, response proto.Message) (hash []byte, found, conflict bool, err error) { - raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(request) - if err != nil { - return nil, false, false, err - } - digest := sha256.Sum256(raw) - found, conflict, err = s.service.store.LoadAOPRequest(ctx, requestID, method, digest[:], response) - return digest[:], found, conflict, err -} - -func (s *aopChatServer) finishRequest(ctx context.Context, method, requestID string, hash []byte, response proto.Message) error { - return s.service.store.SaveAOPRequest(ctx, requestID, method, hash, response) -} - -func sessionToAOP(session *ChatSession) *aop.Session { - if session == nil { - return nil - } - state := "open" - if session.Status != SessionActive { - state = "closed" - } - return &aop.Session{Id: session.ID, State: state, Participant: session.AgentID, Title: session.Title} -} - -func delivery(cursor int64, event *aop.Event) *aop.EventDelivery { - value := "" - if cursor > 0 { - value = strconv.FormatInt(cursor, 10) - } - return &aop.EventDelivery{Cursor: value, Event: event} -} - -func parseAOPCursor(value string) (int64, error) { - if strings.TrimSpace(value) == "" { - return 0, nil - } - cursor, err := strconv.ParseInt(value, 10, 64) - if err != nil || cursor < 0 { - return 0, fmt.Errorf("invalid cursor %q", value) - } - return cursor, nil -} - -func contentText(content []*aop.Content, limit int) string { - var text strings.Builder - for _, part := range content { - value := part.GetText().GetText() - if value == "" { - continue - } - if text.Len() > 0 { - text.WriteByte(' ') - } - text.WriteString(value) - } - value := strings.TrimSpace(text.String()) - if limit > 0 && len(value) > limit { - return value[:limit] + "..." - } - return value -} - -func rejection(code codes.Code, message string) *aop.Rejection { - return &aop.Rejection{Code: canonicalCode(code), Message: message} -} - -func canonicalCode(code codes.Code) string { - switch code { - case codes.InvalidArgument: - return "INVALID_ARGUMENT" - case codes.NotFound: - return "NOT_FOUND" - case codes.AlreadyExists: - return "ALREADY_EXISTS" - case codes.FailedPrecondition: - return "FAILED_PRECONDITION" - case codes.Unavailable: - return "UNAVAILABLE" - case codes.ResourceExhausted: - return "RESOURCE_EXHAUSTED" - case codes.Unauthenticated: - return "UNAUTHENTICATED" - case codes.Internal: - return "INTERNAL" - default: - return strings.ToUpper(strings.ReplaceAll(code.String(), " ", "_")) - } -} - -func rejectedOpen(req *aop.OpenSessionRequest, code codes.Code, message string) *aop.OpenSessionResponse { - response := &aop.OpenSessionResponse{Outcome: &aop.OpenSessionResponse_Rejected{Rejected: rejection(code, message)}} - if req != nil { - response.RequestId = req.RequestId - } - return response -} - -func rejectedRun(req *aop.RunTurnRequest, code codes.Code, message string) *aop.RunTurnResponse { - response := &aop.RunTurnResponse{Outcome: &aop.RunTurnResponse_Rejected{Rejected: rejection(code, message)}} - if req != nil { - response.RequestId = req.RequestId - } - return response -} - -func rejectedCancel(req *aop.CancelTurnRequest, code codes.Code, message string) *aop.CancelTurnResponse { - response := &aop.CancelTurnResponse{Outcome: &aop.CancelTurnResponse_Rejected{Rejected: rejection(code, message)}} - if req != nil { - response.RequestId = req.RequestId - } - return response -} - -func rejectedClose(req *aop.CloseSessionRequest, code codes.Code, message string) *aop.CloseSessionResponse { - response := &aop.CloseSessionResponse{Outcome: &aop.CloseSessionResponse_Rejected{Rejected: rejection(code, message)}} - if req != nil { - response.RequestId = req.RequestId - } - return response -} diff --git a/pkg/web/aop_transport_test.go b/pkg/web/aop_transport_test.go index c326dbe9..4eafc39b 100644 --- a/pkg/web/aop_transport_test.go +++ b/pkg/web/aop_transport_test.go @@ -2,36 +2,29 @@ package web import ( "context" - "net" "path/filepath" "testing" - "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" - "google.golang.org/grpc" - "google.golang.org/grpc/test/bufconn" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/anypb" ) -func TestAgentFrameBinaryAndJSONAreEquivalent(t *testing.T) { - original := &transport.AgentFrame{ - FrameId: "frame-1", CorrelationId: "turn-1", - Payload: &transport.AgentFrame_Event{Event: &aop.Event{ - Id: "event-1", SessionId: "session-1", TurnId: "turn-1", Emitter: "agent-1", Seq: 7, - Payload: &aop.Event_Message{Message: &aop.Message{Id: "message-1", Role: "assistant", Content: []*aop.Content{ - {Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}, - {Value: &aop.Content_Media{Media: &aop.MediaContent{Kind: "image", Resource: &aop.Resource{Source: &aop.Resource_Data{Data: []byte{0, 1, 2, 255}}, MediaType: "image/png"}}}}, - }}}, - }}, - } +func TestAOPEnvelopeBinaryAndJSONAreEquivalent(t *testing.T) { + original := aop.MustWrap("frame-1", "turn-1", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: &aop.Event{ + Id: "event-1", SessionId: "session-1", TurnId: "turn-1", Emitter: "agent-1", Seq: 7, + Payload: &aop.Event_Message{Message: &aop.Message{Id: "message-1", Role: "assistant", Content: []*aop.Content{ + {Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}, + {Value: &aop.Content_Media{Media: &aop.MediaContent{Kind: "image", Resource: &aop.Resource{Source: &aop.Resource_Data{Data: []byte{0, 1, 2, 255}}, MediaType: "image/png"}}}}, + }}}, + }}}) binary, err := proto.Marshal(original) if err != nil { t.Fatal(err) } - fromBinary := new(transport.AgentFrame) + fromBinary := new(aop.Envelope) if err := proto.Unmarshal(binary, fromBinary); err != nil { t.Fatal(err) } @@ -39,7 +32,7 @@ func TestAgentFrameBinaryAndJSONAreEquivalent(t *testing.T) { if err != nil { t.Fatal(err) } - fromJSON := new(transport.AgentFrame) + fromJSON := new(aop.Envelope) if err := protojson.Unmarshal(jsonValue, fromJSON); err != nil { t.Fatal(err) } @@ -48,142 +41,90 @@ func TestAgentFrameBinaryAndJSONAreEquivalent(t *testing.T) { } } -func TestAOPChatServiceGRPCPersistsAgentGeneratedEvents(t *testing.T) { +func TestAOPRequestIDReplayDoesNotDispatchTwice(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "chat.db")) if err != nil { t.Fatal(err) } defer store.Close() service := NewService(ServiceConfig{Store: store}) - defer service.Close() pool := NewAgentPool(service.Hub()) service.SetAgentPool(pool) fake := &remoteAgent{ - id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 8), - controlCh: make(chan *transport.ServerFrame, 8), tasks: make(map[string]chan taskResult), - turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, + id: "agent-1", name: "agent-1", sendCh: make(chan *aop.Envelope, 8), + tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), } pool.agents[fake.id] = fake - - listener := bufconn.Listen(1 << 20) - server := NewGRPCServer("", service, pool) - go func() { _ = server.Serve(listener) }() - defer server.Stop() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { - return listener.Dial() - }), grpc.WithInsecure()) - if err != nil { - t.Fatal(err) + server := NewAOPChatServer(service) + ctx := context.Background() + if opened, err := server.OpenSession(ctx, "open-1", &aop.OpenSessionRequest{SessionId: "session-1", Participant: "agent-1"}); err != nil || opened.GetAccepted() == nil { + t.Fatalf("open = %v, %v", opened, err) } - defer conn.Close() - client := aop.NewChatServiceClient(conn) - - opened, err := client.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1", Participant: "agent-1", Title: "integration"}) - if err != nil { - t.Fatal(err) + request := &aop.RunTurnRequest{ + SessionId: "session-1", TurnId: "turn-1", + Input: &aop.Message{Id: "input-1", Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}}, } - if opened.GetAccepted().GetId() != "session-1" { - t.Fatalf("unexpected open response: %v", opened) + first, err := server.RunTurn(ctx, "run-1", request) + if err != nil || first.GetAccepted() == nil { + t.Fatalf("first run = %v, %v", first, err) } - - runInput := &aop.Message{Id: "message-1", Role: "user", Content: []*aop.Content{ - {Value: &aop.Content_Text{Text: &aop.TextContent{Text: "inspect this"}}}, - {Value: &aop.Content_Media{Media: &aop.MediaContent{Kind: "image", Resource: &aop.Resource{Source: &aop.Resource_Data{Data: []byte{1, 2, 3}}, MediaType: "image/png"}}}}, - }} - run, err := client.RunTurn(ctx, &aop.RunTurnRequest{ - RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", - Input: runInput, - }) - if err != nil { - t.Fatal(err) + second, err := server.RunTurn(ctx, "run-1", proto.Clone(request).(*aop.RunTurnRequest)) + if err != nil || !proto.Equal(first, second) { + t.Fatalf("replay = %v, %v; want %v", second, err, first) } - if run.GetAccepted().GetTurnId() != "turn-1" { - t.Fatalf("unexpected run response: %v", run) + if got := len(fake.sendCh); got != 1 { + t.Fatalf("agent frames = %d, want one run", got) } - - before, err := client.ListEvents(ctx, &aop.ListEventsRequest{SessionId: "session-1", Limit: 100}) + events, err := store.ListAOPEvents(ctx, "session-1", 10) if err != nil { t.Fatal(err) } - if len(before.Events) != 0 { - t.Fatalf("RunTurn synthesized AOP events before the agent emitted them: %v", before.Events) - } - - pool.handleAgentFrame(fake, &transport.AgentFrame{ - CorrelationId: "turn-1", - Payload: &transport.AgentFrame_Event{Event: &aop.Event{ - Id: "event-1", EmittedAt: timestamppb.Now(), SessionId: "session-1", - TurnId: "turn-1", Emitter: "agent-1", Seq: 1, - Payload: &aop.Event_Message{Message: proto.Clone(runInput).(*aop.Message)}, - }}, - }) - - listed, err := client.ListEvents(ctx, &aop.ListEventsRequest{SessionId: "session-1", Limit: 100}) - if err != nil { - t.Fatal(err) + if len(events) != 1 || events[0].GetMessage().GetId() != "input-1" || events[0].GetEmitter() != "aiscan.web" { + t.Fatalf("canonical user history = %+v", events) } - var found *aop.Event - for _, item := range listed.Events { - if item.Event.GetMessage().GetId() == "message-1" { - found = item.Event - break - } - } - if found == nil { - t.Fatalf("input message event was not persisted: %v", listed.Events) - } - if len(found.GetMessage().Content) != 2 || !proto.Equal(found.GetMessage(), &aop.Message{Id: "message-1", Role: "user", Content: []*aop.Content{ - {Value: &aop.Content_Text{Text: &aop.TextContent{Text: "inspect this"}}}, - {Value: &aop.Content_Media{Media: &aop.MediaContent{Kind: "image", Resource: &aop.Resource{Source: &aop.Resource_Data{Data: []byte{1, 2, 3}}, MediaType: "image/png"}}}}, - }}) { - t.Fatalf("stored message lost content: %v", found.GetMessage()) + conflicting := proto.Clone(request).(*aop.RunTurnRequest) + conflicting.Input.Content[0] = &aop.Content{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "different"}}} + response, err := server.RunTurn(ctx, "run-1", conflicting) + if err != nil || response.GetRejected().GetCode() != "ALREADY_EXISTS" { + t.Fatalf("conflict = %v, %v", response, err) } } -func TestAOPRequestIDReplayDoesNotDispatchTwice(t *testing.T) { +func TestOpenSessionLinksTypedScanExtension(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "chat.db")) if err != nil { t.Fatal(err) } defer store.Close() + if err := store.Create(context.Background(), &scanpb.Scan{ + Id: "scan-1", Target: "127.0.0.1", Mode: "quick", CreatedAt: nowProto(), UpdatedAt: nowProto(), + }); err != nil { + t.Fatal(err) + } service := NewService(ServiceConfig{Store: store}) pool := NewAgentPool(service.Hub()) service.SetAgentPool(pool) fake := &remoteAgent{ - id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 8), controlCh: make(chan *transport.ServerFrame, 8), + id: "node://agent-1", name: "agent-1", sendCh: make(chan *aop.Envelope, 1), tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), } pool.agents[fake.id] = fake - server := NewAOPChatServer(service) - ctx := context.Background() - if opened, err := server.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1", Participant: "agent-1"}); err != nil || opened.GetAccepted() == nil { - t.Fatalf("open = %v, %v", opened, err) - } - request := &aop.RunTurnRequest{ - RequestId: "run-1", SessionId: "session-1", TurnId: "turn-1", - Input: &aop.Message{Id: "input-1", Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}}, - } - first, err := server.RunTurn(ctx, request) - if err != nil || first.GetAccepted() == nil { - t.Fatalf("first run = %v, %v", first, err) - } - second, err := server.RunTurn(ctx, proto.Clone(request).(*aop.RunTurnRequest)) - if err != nil || !proto.Equal(first, second) { - t.Fatalf("replay = %v, %v; want %v", second, err, first) + value, err := anypb.New(&scanpb.SessionBinding{ScanId: "scan-1"}) + if err != nil { + t.Fatal(err) } - if got := len(fake.sendCh); got != 1 { - t.Fatalf("agent frames = %d, want one run", got) + response, err := NewAOPChatServer(service).OpenSession(context.Background(), "open-1", &aop.OpenSessionRequest{ + SessionId: "session-1", Participant: fake.id, + Extensions: []*anypb.Any{value}, + }) + if err != nil || response.GetAccepted() == nil { + t.Fatalf("OpenSession = %v, %v", response, err) } - conflicting := proto.Clone(request).(*aop.RunTurnRequest) - conflicting.Input.Content[0] = &aop.Content{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "different"}}} - response, err := server.RunTurn(ctx, conflicting) - if err != nil || response.GetRejected().GetCode() != "ALREADY_EXISTS" { - t.Fatalf("conflict = %v, %v", response, err) + ids, err := store.SessionScanIDs(context.Background(), "session-1") + if err != nil || len(ids) != 1 || ids[0] != "scan-1" { + t.Fatalf("session scans = %v, %v", ids, err) } } @@ -198,19 +139,19 @@ func TestCancelTurnTargetsOnlyRequestedTurn(t *testing.T) { pool := NewAgentPool(service.Hub()) service.SetAgentPool(pool) fake := &remoteAgent{ - id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 8), controlCh: make(chan *transport.ServerFrame, 8), + id: "agent-1", name: "agent-1", sendCh: make(chan *aop.Envelope, 8), tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), } pool.agents[fake.id] = fake server := NewAOPChatServer(service) ctx := context.Background() - if opened, err := server.OpenSession(ctx, &aop.OpenSessionRequest{RequestId: "open-1", SessionId: "session-1", Participant: fake.id}); err != nil || opened.GetAccepted() == nil { + if opened, err := server.OpenSession(ctx, "open-1", &aop.OpenSessionRequest{SessionId: "session-1", Participant: fake.id}); err != nil || opened.GetAccepted() == nil { t.Fatalf("open = %v, %v", opened, err) } for _, turnID := range []string{"turn-1", "turn-2"} { - response, err := server.RunTurn(ctx, &aop.RunTurnRequest{ - RequestId: "run-" + turnID, SessionId: "session-1", TurnId: turnID, + response, err := server.RunTurn(ctx, "run-"+turnID, &aop.RunTurnRequest{ + SessionId: "session-1", TurnId: turnID, Input: &aop.Message{Id: "message-" + turnID, Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: turnID}}}}}, }) if err != nil || response.GetAccepted() == nil { @@ -218,19 +159,29 @@ func TestCancelTurnTargetsOnlyRequestedTurn(t *testing.T) { } } - canceled, err := server.CancelTurn(ctx, &aop.CancelTurnRequest{RequestId: "cancel-1", SessionId: "session-1", TurnId: "turn-1"}) + canceled, err := server.CancelTurn(ctx, "cancel-1", &aop.CancelTurnRequest{SessionId: "session-1", TurnId: "turn-1"}) if err != nil || canceled.GetAccepted().GetTurnId() != "turn-1" { t.Fatalf("CancelTurn = %v, %v", canceled, err) } - select { - case frame := <-fake.controlCh: - request := frame.GetCancelTurn() - if request.GetSessionId() != "session-1" || request.GetTurnId() != "turn-1" { - t.Fatalf("cancel frame = %v", request) + // The cancel shares the single FIFO with the two run dispatches; drain it + // and locate the cancel_turn envelope. + var request *aop.CancelTurnRequest + drain := len(fake.sendCh) + for i := 0; i < drain; i++ { + message, err := aop.Unwrap(<-fake.sendCh) + if err != nil { + t.Fatal(err) + } + if core, ok := message.(*aop.ProtocolMessage); ok && core.GetCancelTurnRequest() != nil { + request = core.GetCancelTurnRequest() } - default: + } + if request == nil { t.Fatal("cancel frame was not sent") } + if request.GetSessionId() != "session-1" || request.GetTurnId() != "turn-1" { + t.Fatalf("cancel frame = %v", request) + } fake.mu.Lock() _, firstPending := fake.tasks["turn-1"] _, secondPending := fake.tasks["turn-2"] @@ -255,7 +206,7 @@ func TestCancelTurnTargetsOnlyRequestedTurn(t *testing.T) { if terminalCount != 1 { t.Fatalf("terminal events after exact cancel = %d, want 1", terminalCount) } - if _, err := server.CancelTurn(ctx, &aop.CancelTurnRequest{RequestId: "cancel-2", SessionId: "session-1", TurnId: "turn-2"}); err != nil { + if _, err := server.CancelTurn(ctx, "cancel-2", &aop.CancelTurnRequest{SessionId: "session-1", TurnId: "turn-2"}); err != nil { t.Fatal(err) } } @@ -270,12 +221,12 @@ func TestAOPRequestJournalSurvivesServerRestart(t *testing.T) { pool := NewAgentPool(service.Hub()) service.SetAgentPool(pool) pool.agents["agent-1"] = &remoteAgent{ - id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 1), controlCh: make(chan *transport.ServerFrame, 1), + id: "agent-1", name: "agent-1", sendCh: make(chan *aop.Envelope, 1), tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: map[string]struct{}{"session-1": {}}, childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), } - request := &aop.OpenSessionRequest{RequestId: "open-durable", SessionId: "session-1", Participant: "agent-1", Title: "original"} - first, err := NewAOPChatServer(service).OpenSession(context.Background(), request) + request := &aop.OpenSessionRequest{SessionId: "session-1", Participant: "agent-1", Title: "original"} + first, err := NewAOPChatServer(service).OpenSession(context.Background(), "open-durable", request) if err != nil || first.GetAccepted() == nil { t.Fatalf("first open = %v, %v", first, err) } @@ -292,13 +243,13 @@ func TestAOPRequestJournalSurvivesServerRestart(t *testing.T) { service = NewService(ServiceConfig{Store: store}) defer service.Close() server := NewAOPChatServer(service) - replayed, err := server.OpenSession(context.Background(), proto.Clone(request).(*aop.OpenSessionRequest)) + replayed, err := server.OpenSession(context.Background(), "open-durable", proto.Clone(request).(*aop.OpenSessionRequest)) if err != nil || !proto.Equal(first, replayed) { t.Fatalf("durable replay = %v, %v; want %v", replayed, err, first) } conflict := proto.Clone(request).(*aop.OpenSessionRequest) conflict.Title = "different" - rejected, err := server.OpenSession(context.Background(), conflict) + rejected, err := server.OpenSession(context.Background(), "open-durable", conflict) if err != nil || rejected.GetRejected().GetCode() != "ALREADY_EXISTS" { t.Fatalf("durable conflict = %v, %v", rejected, err) } diff --git a/pkg/web/aop_ws.go b/pkg/web/aop_ws.go new file mode 100644 index 00000000..37ea30cf --- /dev/null +++ b/pkg/web/aop_ws.go @@ -0,0 +1,333 @@ +package web + +import ( + "context" + "fmt" + "strconv" + "sync" + + aop "github.com/chainreactors/aiscan/aop" + filepb "github.com/chainreactors/aiscan/aop/file" + ptypb "github.com/chainreactors/aiscan/aop/pty" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" + terminalcodec "github.com/chainreactors/aiscan/pkg/web/terminal" + "github.com/chainreactors/utils/pty" + protobuf "google.golang.org/protobuf/proto" +) + +type browserPTYRoute struct { + nodeURI string + unsubscribe func() +} + +func (s *Service) serveBrowserAOP(parent context.Context, stream aop.EnvelopeStream, first *aop.Envelope) error { + if s == nil || s.agents == nil || stream == nil { + return fmt.Errorf("browser AOP connection is unavailable") + } + ctx, cancel := context.WithCancel(parent) + defer cancel() + + sendCh := make(chan *aop.Envelope, 128) + writeErr := make(chan error, 1) + go func() { + for { + select { + case envelope := <-sendCh: + if envelope == nil { + continue + } + if err := stream.Send(envelope); err != nil { + select { + case writeErr <- err: + default: + } + cancel() + return + } + case <-ctx.Done(): + return + } + } + }() + + sendEnvelope := func(envelope *aop.Envelope) error { + select { + case sendCh <- envelope: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + send := func(replyTo, cursor string, message protobuf.Message) error { + envelope, err := aop.Wrap(generateID(), replyTo, message) + if err != nil { + return err + } + envelope.DeliveryCursor = cursor + return sendEnvelope(envelope) + } + fail := func(replyTo, code string, err error) { + if err == nil { + return + } + _ = send(replyTo, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_ProtocolError{ProtocolError: &aop.ProtocolError{Code: code, Message: err.Error()}}}) + } + + var stateMu sync.Mutex + subscriptions := make(map[string]context.CancelFunc) + ptyRoutes := make(map[string]browserPTYRoute) + setSubscription := func(id string, subscriptionCancel context.CancelFunc) { + stateMu.Lock() + if previous := subscriptions[id]; previous != nil { + previous() + } + subscriptions[id] = subscriptionCancel + stateMu.Unlock() + } + cancelSubscription := func(id string) { + stateMu.Lock() + if subscriptionCancel := subscriptions[id]; subscriptionCancel != nil { + delete(subscriptions, id) + subscriptionCancel() + } + stateMu.Unlock() + } + removePTY := func(streamID string, detach bool) { + stateMu.Lock() + route, ok := ptyRoutes[streamID] + if ok { + delete(ptyRoutes, streamID) + } + stateMu.Unlock() + if !ok { + return + } + route.unsubscribe() + if detach { + s.agents.CloseTerminal(route.nodeURI, streamID) + } + } + defer func() { + stateMu.Lock() + cancels := make([]context.CancelFunc, 0, len(subscriptions)) + routes := make(map[string]browserPTYRoute, len(ptyRoutes)) + for _, subscriptionCancel := range subscriptions { + cancels = append(cancels, subscriptionCancel) + } + for streamID, route := range ptyRoutes { + routes[streamID] = route + } + stateMu.Unlock() + for _, subscriptionCancel := range cancels { + subscriptionCancel() + } + for streamID, route := range routes { + route.unsubscribe() + s.agents.CloseTerminal(route.nodeURI, streamID) + } + }() + + chat := NewAOPChatServer(s) + scans := newScanServiceCore(s) + handle := func(envelope *aop.Envelope) { + message, err := aop.Unwrap(envelope) + if err != nil { + fail(envelope.GetId(), "INVALID_PAYLOAD", err) + return + } + switch value := message.(type) { + case *aop.ProtocolMessage: + switch payload := value.Message.(type) { + case *aop.ProtocolMessage_OpenSessionRequest: + go func() { + response, err := chat.OpenSession(ctx, envelope.Id, payload.OpenSessionRequest) + if err != nil { + fail(envelope.Id, "OPEN_SESSION_FAILED", err) + return + } + _ = send(envelope.Id, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_OpenSessionResponse{OpenSessionResponse: response}}) + }() + case *aop.ProtocolMessage_RunTurnRequest: + go func() { + response, err := chat.RunTurn(ctx, envelope.Id, payload.RunTurnRequest) + if err != nil { + fail(envelope.Id, "RUN_TURN_FAILED", err) + return + } + _ = send(envelope.Id, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_RunTurnResponse{RunTurnResponse: response}}) + }() + case *aop.ProtocolMessage_CancelTurnRequest: + go func() { + response, err := chat.CancelTurn(ctx, envelope.Id, payload.CancelTurnRequest) + if err != nil { + fail(envelope.Id, "CANCEL_TURN_FAILED", err) + return + } + _ = send(envelope.Id, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CancelTurnResponse{CancelTurnResponse: response}}) + }() + case *aop.ProtocolMessage_CloseSessionRequest: + go func() { + response, err := chat.CloseSession(ctx, envelope.Id, payload.CloseSessionRequest) + if err != nil { + fail(envelope.Id, "CLOSE_SESSION_FAILED", err) + return + } + _ = send(envelope.Id, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CloseSessionResponse{CloseSessionResponse: response}}) + }() + case *aop.ProtocolMessage_ListEventsRequest: + go func() { + response, err := chat.ListEvents(ctx, payload.ListEventsRequest) + if err != nil { + fail(envelope.Id, "LIST_EVENTS_FAILED", err) + return + } + _ = send(envelope.Id, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_ListEventsResponse{ListEventsResponse: response}}) + }() + case *aop.ProtocolMessage_WatchEventsRequest: + subscriptionCtx, subscriptionCancel := context.WithCancel(ctx) + setSubscription(envelope.Id, subscriptionCancel) + go func(subscriptionID string) { + defer cancelSubscription(subscriptionID) + err := chat.watchEvents(payload.WatchEventsRequest, subscriptionCtx, func(delivery *aop.EventDelivery) error { + if delivery.GetEvent() == nil { + return nil + } + return send(subscriptionID, delivery.GetCursor(), &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: delivery.Event}}) + }) + if err != nil && subscriptionCtx.Err() == nil { + fail(subscriptionID, "WATCH_EVENTS_FAILED", err) + } + }(envelope.Id) + case *aop.ProtocolMessage_CancelOperation: + target := payload.CancelOperation.GetTargetId() + cancelSubscription(target) + removePTY(target, true) + default: + fail(envelope.Id, "UNSUPPORTED_MESSAGE", fmt.Errorf("unsupported AOP core message")) + } + + case *commandpb.ProtocolMessage: + request := value.GetRequest() + if request == nil { + fail(envelope.Id, "UNSUPPORTED_MESSAGE", fmt.Errorf("unsupported AIScan command message")) + return + } + go func() { + operationID, err := s.ExecuteSessionCommand(request.SessionId, request.Line) + if err != nil { + fail(envelope.Id, "COMMAND_FAILED", err) + return + } + _ = send(envelope.Id, "", &commandpb.ProtocolMessage{Message: &commandpb.ProtocolMessage_Receipt{Receipt: &commandpb.Receipt{OperationId: operationID, SessionId: request.SessionId, State: "running"}}}) + }() + + case *filepb.ProtocolMessage: + request := value.GetUploadRequest() + if request == nil { + fail(envelope.Id, "UNSUPPORTED_MESSAGE", fmt.Errorf("only file upload is supported by the browser peer")) + return + } + go func() { + result, err := s.HandleFileUpload(ctx, request.SessionId, request.Filename, request.Data) + if err != nil { + fail(envelope.Id, "FILE_UPLOAD_FAILED", err) + return + } + _ = send(envelope.Id, "", &filepb.ProtocolMessage{Message: &filepb.ProtocolMessage_Result{Result: result}}) + }() + + case *scanpb.ProtocolMessage: + request := value.GetWatchEventsRequest() + if request == nil { + fail(envelope.Id, "UNSUPPORTED_MESSAGE", fmt.Errorf("unsupported AIScan scan message")) + return + } + subscriptionCtx, subscriptionCancel := context.WithCancel(ctx) + setSubscription(envelope.Id, subscriptionCancel) + go func(subscriptionID string) { + defer cancelSubscription(subscriptionID) + err := scans.WatchScanEvents(request, subscriptionCtx, func(event *scanpb.ScanEvent) error { + if event == nil { + return nil + } + return send(subscriptionID, strconv.FormatUint(event.Sequence, 10), &scanpb.ProtocolMessage{Message: &scanpb.ProtocolMessage_Event{Event: event}}) + }) + if err != nil && subscriptionCtx.Err() == nil { + fail(subscriptionID, "WATCH_SCAN_FAILED", err) + } + }(envelope.Id) + + case *ptypb.ProtocolMessage: + frame := terminalcodec.FromProto(value) + if frame.StreamID == "" { + fail(envelope.Id, "INVALID_PTY", fmt.Errorf("PTY stream_id is required")) + return + } + nodeURI := "" + switch payload := value.Message.(type) { + case *ptypb.ProtocolMessage_Open: + nodeURI = payload.Open.NodeUri + case *ptypb.ProtocolMessage_List: + nodeURI = payload.List.NodeUri + } + stateMu.Lock() + route, routed := ptyRoutes[frame.StreamID] + stateMu.Unlock() + if nodeURI == "" && routed { + nodeURI = route.nodeURI + } + if nodeURI == "" { + fail(envelope.Id, "INVALID_PTY", fmt.Errorf("PTY node_uri is required when opening a stream")) + return + } + if !routed { + events, online, unsubscribe := s.agents.subscribePTY(nodeURI, frame.StreamID) + stateMu.Lock() + ptyRoutes[frame.StreamID] = browserPTYRoute{nodeURI: nodeURI, unsubscribe: unsubscribe} + stateMu.Unlock() + go func(streamID string, values <-chan pty.Frame) { + for { + select { + case next, ok := <-values: + if !ok { + return + } + _ = send(streamID, "", terminalcodec.ToProto(next)) + case <-ctx.Done(): + return + } + } + }(frame.StreamID, events) + if !online { + _ = send(frame.StreamID, "", terminalcodec.ToProto(pty.Frame{Type: pty.FrameDetached, StreamID: frame.StreamID})) + } + } + if err := s.agents.sendAgentMessage(nodeURI, generateID(), "", value); err != nil { + fail(envelope.Id, "PTY_FORWARD_FAILED", err) + removePTY(frame.StreamID, false) + return + } + if frame.Type == pty.FrameDetach || frame.Type == pty.FrameClosed { + removePTY(frame.StreamID, false) + } + + default: + fail(envelope.Id, "UNSUPPORTED_NAMESPACE", fmt.Errorf("unsupported browser AOP namespace")) + } + } + + handle(first) + for { + envelope, err := stream.Recv() + if err != nil { + return err + } + handle(envelope) + select { + case err := <-writeErr: + return err + default: + } + } +} diff --git a/pkg/web/broker.go b/pkg/web/broker.go index a686a450..3769ef9c 100644 --- a/pkg/web/broker.go +++ b/pkg/web/broker.go @@ -4,7 +4,7 @@ import ( "sync" aop "github.com/chainreactors/aiscan/aop" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" protobuf "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) diff --git a/pkg/web/broker_test.go b/pkg/web/broker_test.go index 16e52c10..334cdaf5 100644 --- a/pkg/web/broker_test.go +++ b/pkg/web/broker_test.go @@ -7,8 +7,8 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -79,7 +79,7 @@ func TestScanSubscriptionReturnsSnapshotSequenceBoundary(t *testing.T) { if sequence != 1 { t.Fatalf("subscription sequence = %d, want 1", sequence) } - snapshot := scanSnapshot(&ScanJob{ID: "scan-1"}, sequence) + snapshot := scanSnapshot(&scanpb.Scan{Id: "scan-1"}, sequence) if snapshot.Sequence != sequence { t.Fatalf("snapshot sequence = %d, want %d", snapshot.Sequence, sequence) } @@ -170,6 +170,12 @@ func TestScanCompletePersistsTypedAOPExtension(t *testing.T) { } defer store.Close() createStoredSession(t, store, "session-scan") + if err := store.Create(context.Background(), &scanpb.Scan{ + Id: "scan-123", Target: "127.0.0.1", Mode: "quick", + Status: scanpb.ScanStatus_SCAN_STATUS_COMPLETED, CreatedAt: nowProto(), UpdatedAt: nowProto(), + }); err != nil { + t.Fatal(err) + } service := NewService(ServiceConfig{Store: store}) service.registerSessionTask("scan-123", "session-scan", "") service.broadcastScanComplete("scan-123") @@ -179,11 +185,11 @@ func TestScanCompletePersistsTypedAOPExtension(t *testing.T) { t.Fatalf("events = %+v, err = %v", events, err) } extension := events[0].GetExtension() - if extension == nil || extension.Type != "io.chainreactors.aiscan.scan" { + value := new(scanpb.SessionScanEvent) + if extension == nil || !extension.MessageIs(value) { t.Fatalf("extension = %+v", extension) } - value := new(scanpb.SessionScanEvent) - if err := aop.DecodeProtoJSON(extension.Value, value); err != nil { + if err := extension.UnmarshalTo(value); err != nil { t.Fatal(err) } if value.ScanId != "scan-123" || value.Status != scanpb.ScanStatus_SCAN_STATUS_COMPLETED { @@ -196,28 +202,27 @@ func TestScanCompletePersistsTypedAOPExtension(t *testing.T) { } func TestWatchScanEventsImmediatelyReturnsTerminalSnapshot(t *testing.T) { - for _, status := range []ScanStatus{StatusCompleted, StatusFailed, StatusCanceled} { - t.Run(string(status), func(t *testing.T) { + for _, status := range []scanpb.ScanStatus{scanpb.ScanStatus_SCAN_STATUS_COMPLETED, scanpb.ScanStatus_SCAN_STATUS_FAILED, scanpb.ScanStatus_SCAN_STATUS_CANCELED} { + t.Run(scanStatusToDB(status), func(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) if err != nil { t.Fatal(err) } defer store.Close() - now := time.Now() - job := &ScanJob{ID: "terminal-scan", Target: "127.0.0.1", Mode: "quick", Status: status, CreatedAt: now, UpdatedAt: now} - if err := store.Create(context.Background(), job); err != nil { + scan := &scanpb.Scan{Id: "terminal-scan", Target: "127.0.0.1", Mode: "quick", Status: status, CreatedAt: nowProto(), UpdatedAt: nowProto()} + if err := store.Create(context.Background(), scan); err != nil { t.Fatal(err) } service := NewService(ServiceConfig{Store: store}) - var responses []*scanpb.WatchScanEventsResponse + var responses []*scanpb.ScanEvent err = newScanServiceCore(service).WatchScanEvents( - &scanpb.WatchScanEventsRequest{ScanId: job.ID}, context.Background(), - func(response *scanpb.WatchScanEventsResponse) error { - responses = append(responses, response) + &scanpb.WatchScanEventsRequest{ScanId: scan.Id}, context.Background(), + func(event *scanpb.ScanEvent) error { + responses = append(responses, event) return nil }, ) - if err != nil || len(responses) != 1 || responses[0].GetEvent().GetSnapshot().GetId() != job.ID { + if err != nil || len(responses) != 1 || responses[0].GetSnapshot().GetId() != scan.Id { t.Fatalf("responses = %+v, err = %v", responses, err) } }) diff --git a/pkg/web/command_test.go b/pkg/web/command_test.go index a5082c96..8b52d266 100644 --- a/pkg/web/command_test.go +++ b/pkg/web/command_test.go @@ -7,8 +7,8 @@ import ( "testing" "connectrpc.com/connect" - chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" - "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" + "github.com/chainreactors/aiscan/pkg/rpc/chat/chatconnect" + chatpb "github.com/chainreactors/aiscan/pkg/types/chat" ) func TestParseCommand(t *testing.T) { diff --git a/pkg/web/config_connect.go b/pkg/web/config_connect.go new file mode 100644 index 00000000..f3ed62f0 --- /dev/null +++ b/pkg/web/config_connect.go @@ -0,0 +1,85 @@ +package web + +import ( + "context" + "errors" + + "connectrpc.com/connect" + agentprobe "github.com/chainreactors/aiscan/agent/probe" + "github.com/chainreactors/aiscan/pkg/rpc/config/configconnect" + configpb "github.com/chainreactors/aiscan/pkg/types/config" + "google.golang.org/protobuf/types/known/emptypb" +) + +type connectConfigServer struct { + configconnect.UnimplementedConfigServiceHandler + service *Service +} + +func (s *connectConfigServer) TestLLM(ctx context.Context, req *connect.Request[configpb.LLMProbeRequest]) (*connect.Response[configpb.LLMProbeResult], error) { + result, err := s.service.TestLLM(ctx, llmProbeRequest(req.Msg)) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewResponse(&configpb.LLMProbeResult{Ok: result.OK, Provider: result.Provider, Model: result.Model, LatencyMs: result.LatencyMs, Reply: result.Reply, Error: result.Error}), nil +} + +func (s *connectConfigServer) ListModels(ctx context.Context, req *connect.Request[configpb.LLMProbeRequest]) (*connect.Response[configpb.ListModelsResult], error) { + result, err := s.service.ListLLMModels(ctx, llmProbeRequest(req.Msg)) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewResponse(&configpb.ListModelsResult{Ok: result.OK, Supported: result.Supported, Models: result.Models, Error: result.Error}), nil +} + +func (s *connectConfigServer) TestConnection(ctx context.Context, req *connect.Request[configpb.TestConnectionRequest]) (*connect.Response[configpb.TestConnectionResponse], error) { + checks, err := s.service.TestConn(ctx, req.Msg.GetSection(), req.Msg.GetConfig()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + response := &configpb.TestConnectionResponse{Checks: make([]*configpb.ConnectionCheck, 0, len(checks))} + for _, check := range checks { + response.Checks = append(response.Checks, &configpb.ConnectionCheck{Name: check.Name, Ok: check.OK, LatencyMs: check.LatencyMs, Detail: check.Detail, Error: check.Error}) + } + return connect.NewResponse(response), nil +} + +func llmProbeRequest(req *configpb.LLMProbeRequest) agentprobe.LLMProbeRequest { + if req == nil { + return agentprobe.LLMProbeRequest{} + } + return agentprobe.LLMProbeRequest{ProfileID: req.ProfileId, Provider: req.Provider, BaseURL: req.BaseUrl, APIKey: req.ApiKey, Model: req.Model, Proxy: req.Proxy} +} + +func newConnectConfigServer(service *Service) *connectConfigServer { + return &connectConfigServer{service: service} +} + +func (s *connectConfigServer) GetConfig(ctx context.Context, _ *connect.Request[emptypb.Empty]) (*connect.Response[configpb.GetConfigResponse], error) { + view, err := s.service.GetConfigView(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&configpb.GetConfigResponse{Config: view}), nil +} + +func (s *connectConfigServer) UpdateConfig(ctx context.Context, req *connect.Request[configpb.UpdateConfigRequest]) (*connect.Response[configpb.UpdateConfigResponse], error) { + if req.Msg.GetConfig() == nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("config is required")) + } + view, err := s.service.SaveConfig(ctx, req.Msg.Config) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewResponse(&configpb.UpdateConfigResponse{Config: view}), nil +} + +func (s *connectConfigServer) ActivateProfile(ctx context.Context, req *connect.Request[configpb.ActivateProfileRequest]) (*connect.Response[configpb.ActivateProfileResponse], error) { + view, err := s.service.ActivateLLMProfile(ctx, req.Msg.ProfileId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewResponse(&configpb.ActivateProfileResponse{Config: view}), nil +} + +var _ configconnect.ConfigServiceHandler = (*connectConfigServer)(nil) diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go index 6a730763..ae45f180 100644 --- a/pkg/web/config_profiles_test.go +++ b/pkg/web/config_profiles_test.go @@ -4,16 +4,19 @@ import ( "context" "testing" - proto "github.com/chainreactors/aiscan/core/config" + cfg "github.com/chainreactors/aiscan/core/config" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) func TestActivateLLMProfileSelectsByID(t *testing.T) { store := &fakeConfigStore{} - store.cfg.LLM.ActiveProfile = "primary" - store.cfg.LLM.Providers = []proto.LLMProviderConfig{ - {ID: "primary", Name: "Primary", Provider: "openai", Model: "gpt-primary", APIKey: "key-1"}, - {ID: "fast", Name: "Fast", Provider: "openai", Model: "deepseek-fast", APIKey: "key-2"}, - } + store.cfg = &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + ActiveProfile: "primary", + Providers: []*configpb.LLMProviderConfig{ + {Id: "primary", Name: "Primary", Provider: "openai", Model: "gpt-primary", ApiKey: "key-1"}, + {Id: "fast", Name: "Fast", Provider: "openai", Model: "deepseek-fast", ApiKey: "key-2"}, + }, + }} service := NewService(ServiceConfig{ConfigStore: store}) status, err := service.ActivateLLMProfile(context.Background(), "fast") @@ -22,48 +25,50 @@ func TestActivateLLMProfileSelectsByID(t *testing.T) { } // Selection is by id: the list order is untouched and Active() resolves // the chosen profile. - if store.cfg.LLM.ActiveProfile != "fast" || store.cfg.LLM.Providers[0].ID != "primary" { - t.Fatalf("active profile not switched by id: %+v", store.cfg.LLM) + if store.cfg.Llm.ActiveProfile != "fast" || store.cfg.Llm.Providers[0].Id != "primary" { + t.Fatalf("active profile not switched by id: %+v", store.cfg.Llm) } - if active := store.cfg.LLM.Active(); active.Provider != "openai" || active.Model != "deepseek-fast" || active.APIKey != "key-2" { + if active := cfg.ActiveLLMProvider(store.cfg.Llm); active.Provider != "openai" || active.Model != "deepseek-fast" || active.ApiKey != "key-2" { t.Fatalf("Active() did not resolve the selected profile: %+v", active) } - if status.LLM.ActiveProfile != "fast" || status.LLM.Provider != "openai" || status.LLM.Model != "deepseek-fast" { - t.Fatalf("status not synchronized: %+v", status.LLM) + if status.GetLlm().GetActiveProfile() != "fast" || status.GetLlm().GetActive().GetProvider() != "openai" || status.GetLlm().GetActive().GetModel() != "deepseek-fast" { + t.Fatalf("view not synchronized: %+v", status.GetLlm()) } } func TestConfigStatusIncludesModelLimits(t *testing.T) { - var cfg proto.DistributeConfig - cfg.LLM.ActiveProfile = "large" - cfg.LLM.Providers = []proto.LLMProviderConfig{{ - ID: "large", Provider: "anthropic", Model: "glm-5.2[1m]", - MaxTokens: 32768, ContextWindow: 1000000, + conf := &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + ActiveProfile: "large", + Providers: []*configpb.LLMProviderConfig{{ + Id: "large", Provider: "anthropic", Model: "glm-5.2[1m]", + MaxTokens: 32768, ContextWindow: 1000000, + }}, }} - status := ConfigStatusFromDistribute(&cfg, "aiscan.yaml", true) - if status.LLM.MaxTokens != 32768 || status.LLM.ContextWindow != 1000000 { - t.Fatalf("active limits missing from status: %+v", status.LLM) + view := ConfigViewFromDistribute(conf, "aiscan.yaml", true) + if view.GetLlm().GetActive().GetMaxTokens() != 32768 || view.GetLlm().GetActive().GetContextWindow() != 1000000 { + t.Fatalf("active limits missing from view: %+v", view.GetLlm()) } - if len(status.LLM.Profiles) != 1 || status.LLM.Profiles[0].MaxTokens != 32768 || status.LLM.Profiles[0].ContextWindow != 1000000 { - t.Fatalf("profile limits missing from status: %+v", status.LLM.Profiles) + if len(view.GetLlm().GetProviders()) != 1 || view.GetLlm().GetProviders()[0].GetMaxTokens() != 32768 || view.GetLlm().GetProviders()[0].GetContextWindow() != 1000000 { + t.Fatalf("profile limits missing from view: %+v", view.GetLlm().GetProviders()) } } func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) { store := &fakeConfigStore{} service := NewService(ServiceConfig{ConfigStore: store}) - for _, mutate := range []func(*proto.LLMProviderConfig){ - func(p *proto.LLMProviderConfig) { p.MaxTokens = -1 }, - func(p *proto.LLMProviderConfig) { p.ContextWindow = -1 }, + for _, mutate := range []func(*configpb.LLMProviderConfig){ + func(p *configpb.LLMProviderConfig) { p.MaxTokens = -1 }, + func(p *configpb.LLMProviderConfig) { p.ContextWindow = -1 }, } { - var cfg proto.DistributeConfig - profile := proto.LLMProviderConfig{ID: "bad", Model: "test-model"} - mutate(&profile) - cfg.LLM.Providers = []proto.LLMProviderConfig{profile} - if _, err := service.SaveConfig(context.Background(), cfg); err == nil { + profile := &configpb.LLMProviderConfig{Id: "bad", Model: "test-model"} + mutate(profile) + conf := &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + Providers: []*configpb.LLMProviderConfig{profile}, + }} + if _, err := service.SaveConfig(context.Background(), conf); err == nil { t.Fatal("SaveConfig() accepted a negative model limit") } - if len(store.cfg.LLM.Providers) != 0 { + if store.cfg != nil && len(store.cfg.GetLlm().GetProviders()) != 0 { t.Fatal("invalid config was persisted") } } @@ -72,30 +77,33 @@ func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) { func TestSaveConfigRejectsEmptyProfileModel(t *testing.T) { store := &fakeConfigStore{} service := NewService(ServiceConfig{ConfigStore: store}) - var cfg proto.DistributeConfig - cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "empty", Name: "Empty", Model: " "}} + conf := &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + Providers: []*configpb.LLMProviderConfig{{Id: "empty", Name: "Empty", Model: " "}}, + }} - if _, err := service.SaveConfig(context.Background(), cfg); err == nil { + if _, err := service.SaveConfig(context.Background(), conf); err == nil { t.Fatal("SaveConfig() accepted an empty profile model") } - if len(store.cfg.LLM.Providers) != 0 { + if store.cfg != nil && len(store.cfg.GetLlm().GetProviders()) != 0 { t.Fatal("invalid config was persisted") } } func TestActivateLLMProfileRejectsEmptyModel(t *testing.T) { store := &fakeConfigStore{} - store.cfg.LLM.ActiveProfile = "primary" - store.cfg.LLM.Providers = []proto.LLMProviderConfig{ - {ID: "primary", Model: "gpt-primary"}, - {ID: "empty", Model: ""}, - } + store.cfg = &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + ActiveProfile: "primary", + Providers: []*configpb.LLMProviderConfig{ + {Id: "primary", Model: "gpt-primary"}, + {Id: "empty", Model: ""}, + }, + }} service := NewService(ServiceConfig{ConfigStore: store}) if _, err := service.ActivateLLMProfile(context.Background(), "empty"); err == nil { t.Fatal("ActivateLLMProfile() accepted an empty model") } - if store.cfg.LLM.ActiveProfile != "primary" { - t.Fatalf("active profile = %q, want primary", store.cfg.LLM.ActiveProfile) + if store.cfg.Llm.ActiveProfile != "primary" { + t.Fatalf("active profile = %q, want primary", store.cfg.Llm.ActiveProfile) } } diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go index fb1a1360..7c8a0925 100644 --- a/pkg/web/config_reload_test.go +++ b/pkg/web/config_reload_test.go @@ -5,115 +5,100 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + configpb "github.com/chainreactors/aiscan/pkg/types/config" + reloadpb "github.com/chainreactors/aiscan/pkg/types/reload" ) -func newFakeAgent(id string, buf int) *remoteAgent { +func newFakeAgent(id string, buffer int) *remoteAgent { return &remoteAgent{ - id: id, - name: id, - sendCh: make(chan *transport.ServerFrame, buf), - controlCh: make(chan *transport.ServerFrame, 1), - tasks: make(map[string]chan taskResult), - turns: make(map[string]int), - done: make(chan struct{}), + id: id, name: id, sendCh: make(chan *aop.Envelope, buffer), + tasks: make(map[string]chan taskResult), turns: make(map[string]int), done: make(chan struct{}), } } -// TestBroadcastConfigReload verifies config updates use the control channel and -// are not blocked by a saturated task/output channel. -func TestBroadcastConfigReload(t *testing.T) { +func TestBroadcastConfigReloadUsesApplicationFIFO(t *testing.T) { pool := NewAgentPool(nil) - open := newFakeAgent("open", 1) - full := newFakeAgent("full", 1) - full.sendCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_Exec{Exec: &transport.ExecRequest{TaskId: "busy"}}} // saturate the buffer - pool.register(open) - pool.register(full) + agent := newFakeAgent("agent", 1) + pool.register(agent) + config := &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ActiveProfile: "primary"}} - if n := pool.BroadcastConfigReload(); n != 2 { - t.Fatalf("notified = %d, want 2", n) + if n := pool.BroadcastConfigReload(config); n != 1 { + t.Fatalf("notified = %d, want 1", n) } - select { - case msg := <-open.controlCh: - if msg.GetReloadConfig() == nil { - t.Fatalf("open agent got %+v, want reload_config", msg) - } - default: - t.Fatal("open agent got no config message") + envelope := <-agent.sendCh + message, err := aop.Unwrap(envelope) + if err != nil { + t.Fatal(err) } - select { - case msg := <-full.controlCh: - if msg.GetReloadConfig() == nil { - t.Fatalf("full agent got %+v, want reload_config", msg) - } - default: - t.Fatal("full agent got no config control message") + reload, ok := message.(*reloadpb.ProtocolMessage) + if !ok || reload.GetRequest().GetConfig().GetLlm().GetActiveProfile() != "primary" { + t.Fatalf("reload = %T %+v", message, message) } } -func TestBroadcastConfigReloadWaitsBehindCancellationFrames(t *testing.T) { +func TestBroadcastConfigReloadWaitsInFIFOOrder(t *testing.T) { pool := NewAgentPool(nil) - agent := newFakeAgent("busy-control", 1) - agent.controlCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_CancelTurn{CancelTurn: &aop.CancelTurnRequest{TurnId: "task-1"}}} + agent := newFakeAgent("busy", 1) + cancel := aop.MustWrap("cancel", "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CancelOperation{CancelOperation: &aop.CancelOperation{TargetId: "task-1"}}}) + agent.sendCh <- cancel pool.register(agent) - if n := pool.BroadcastConfigReload(); n != 1 { - t.Fatalf("notified = %d, want 1", n) + done := make(chan int, 1) + go func() { done <- pool.BroadcastConfigReload(&configpb.DistributeConfig{}) }() + select { + case <-done: + t.Fatal("reload bypassed the full FIFO") + case <-time.After(50 * time.Millisecond): } - if msg := <-agent.controlCh; msg.GetCancelTurn().GetTurnId() != "task-1" { - t.Fatalf("first control frame = %+v, want cancel turn", msg) + if first := <-agent.sendCh; first.Id != "cancel" { + t.Fatalf("first envelope = %+v", first) } - select { - case msg := <-agent.controlCh: - if msg.GetReloadConfig() == nil { - t.Fatalf("queued control frame = %+v, want reload config", msg) - } - case <-time.After(time.Second): - t.Fatal("config reload was dropped behind a full cancellation queue") + if notified := <-done; notified != 1 { + t.Fatalf("notified = %d", notified) + } + message, _ := aop.Unwrap(<-agent.sendCh) + if reload, ok := message.(*reloadpb.ProtocolMessage); !ok || reload.GetRequest() == nil { + t.Fatalf("second message = %T", message) } } func TestHandleAgentStatusUpdate(t *testing.T) { pool := NewAgentPool(nil) - a := newFakeAgent("n1", 1) - a.runtime = &transport.AgentRuntimeInfo{Pid: 4242, Hostname: "local-1"} - a.status = &transport.AgentStatus{Provider: "anthropic", Model: "old-model"} - pool.register(a) + agent := newFakeAgent("n1", 1) + agent.runtime = &aop.AgentRuntimeInfo{Pid: 4242, Hostname: "local-1"} + agent.status = &aop.AgentStatus{Provider: "anthropic", Model: "old-model"} + pool.register(agent) - pool.handleAgentFrame(a, &transport.AgentFrame{Payload: &transport.AgentFrame_Status{Status: &transport.AgentStatus{ + pool.handleAgentEnvelope(agent, aop.MustWrap("status", "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_AgentStatus{AgentStatus: &aop.AgentStatus{ Provider: "anthropic", Model: "glm-5.2", Bound: true, - }}}) + }}})) - got := a.info().Status - if got.Model != "glm-5.2" { - t.Errorf("Model = %q, want glm-5.2", got.Model) + view := agent.view() + if view.GetStatus().GetModel() != "glm-5.2" || view.GetStatus().GetProvider() != "anthropic" { + t.Fatalf("status = %+v", view.GetStatus()) } - if got.Provider != "anthropic" { - t.Errorf("Provider = %q, want anthropic", got.Provider) - } - if runtime := a.info().Runtime; runtime.Hostname != "local-1" || runtime.PID != 4242 { - t.Errorf("runtime clobbered: Hostname=%q PID=%d", runtime.Hostname, runtime.PID) + if runtime := view.GetHello().GetRuntime(); runtime.GetHostname() != "local-1" || runtime.GetPid() != 4242 { + t.Fatalf("runtime clobbered: %+v", runtime) } } func TestHandleConfigReloadResultUpdatesAgentStatus(t *testing.T) { pool := NewAgentPool(nil) - a := newFakeAgent("n1", 1) - a.status = &transport.AgentStatus{Provider: "openai", Model: "old-model"} - pool.register(a) + agent := newFakeAgent("n1", 1) + agent.status = &aop.AgentStatus{Provider: "openai", Model: "old-model"} + pool.register(agent) - pool.handleAgentFrame(a, &transport.AgentFrame{Payload: &transport.AgentFrame_ConfigReload{ConfigReload: &transport.ConfigReloadResult{ + pool.handleAgentEnvelope(agent, aop.MustWrap("reload-result", "reload", &reloadpb.ProtocolMessage{Message: &reloadpb.ProtocolMessage_Result{Result: &reloadpb.Result{ Ok: true, Provider: "openai", Model: "deepseek-v4-pro", - }}}) - got := a.info().Status - if got.Provider != "openai" || got.Model != "deepseek-v4-pro" || got.ConfigError != "" { + }}})) + if got := agent.view().GetStatus(); got.GetProvider() != "openai" || got.GetModel() != "deepseek-v4-pro" || got.GetConfigError() != "" { t.Fatalf("unexpected config result status: %+v", got) } - pool.handleAgentFrame(a, &transport.AgentFrame{Payload: &transport.AgentFrame_ConfigReload{ConfigReload: &transport.ConfigReloadResult{ + pool.handleAgentEnvelope(agent, aop.MustWrap("reload-error", "reload", &reloadpb.ProtocolMessage{Message: &reloadpb.ProtocolMessage_Result{Result: &reloadpb.Result{ Ok: false, Error: "invalid API key", - }}}) - if got := a.info().Status; got.ConfigError != "invalid API key" { - t.Fatalf("config error = %q", got.ConfigError) + }}})) + if got := agent.view().GetStatus(); got.GetConfigError() != "invalid API key" { + t.Fatalf("config error = %q", got.GetConfigError()) } } diff --git a/pkg/web/config_transaction_test.go b/pkg/web/config_transaction_test.go index f6d9c74e..1e00bb1b 100644 --- a/pkg/web/config_transaction_test.go +++ b/pkg/web/config_transaction_test.go @@ -7,27 +7,28 @@ import ( "testing" "time" - proto "github.com/chainreactors/aiscan/core/config" + cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/runner" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) type transactionalConfigStore struct { mu sync.Mutex - cfg proto.DistributeConfig + cfg *configpb.DistributeConfig commitErr error discarded int prepareLog []string } -func (s *transactionalConfigStore) GetDistributeConfig(context.Context) (string, bool, proto.DistributeConfig, error) { +func (s *transactionalConfigStore) GetDistributeConfig(context.Context) (string, bool, *configpb.DistributeConfig, error) { s.mu.Lock() defer s.mu.Unlock() return "config.yaml", true, s.cfg, nil } -func (s *transactionalConfigStore) PrepareDistributeConfig(_ context.Context, cfg proto.DistributeConfig) (*PreparedConfig, error) { +func (s *transactionalConfigStore) PrepareDistributeConfig(_ context.Context, cfg *configpb.DistributeConfig) (*PreparedConfig, error) { s.mu.Lock() - s.prepareLog = append(s.prepareLog, cfg.LLM.Active().Model) + s.prepareLog = append(s.prepareLog, activeModel(cfg)) s.mu.Unlock() return &PreparedConfig{Config: cfg, TargetPath: "config.yaml"}, nil } @@ -48,6 +49,13 @@ func (s *transactionalConfigStore) DiscardDistributeConfig(*PreparedConfig) { s.mu.Unlock() } +func activeModel(c *configpb.DistributeConfig) string { + if active := cfg.ActiveLLMProvider(c.GetLlm()); active != nil { + return active.Model + } + return "" +} + type recordingCloser struct { once sync.Once done chan struct{} @@ -62,11 +70,11 @@ func (c *recordingCloser) Close() { c.once.Do(func() { close(c.done) }) } -func configForModel(model string) proto.DistributeConfig { - var cfg proto.DistributeConfig - cfg.LLM.ActiveProfile = "primary" - cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "primary", Provider: "openai", Model: model}} - return cfg +func configForModel(model string) *configpb.DistributeConfig { + return &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + ActiveProfile: "primary", + Providers: []*configpb.LLMProviderConfig{{Id: "primary", Provider: "openai", Model: model}}, + }} } func TestSaveConfigBuildFailureKeepsCommittedConfigAndCurrentApp(t *testing.T) { @@ -75,7 +83,7 @@ func TestSaveConfigBuildFailureKeepsCommittedConfigAndCurrentApp(t *testing.T) { svc := NewService(ServiceConfig{ App: oldApp, ConfigStore: store, AppFactory: func(_ context.Context, prepared *PreparedConfig) (*runner.App, error) { - if got := prepared.Config.LLM.Active().Model; got != "new-model" { + if got := activeModel(prepared.Config); got != "new-model" { t.Fatalf("candidate model = %q", got) } return nil, errors.New("candidate build failed") @@ -89,7 +97,7 @@ func TestSaveConfigBuildFailureKeepsCommittedConfigAndCurrentApp(t *testing.T) { if err != nil { t.Fatal(err) } - if got := committed.LLM.Active().Model; got != "old-model" { + if got := activeModel(committed); got != "old-model" { t.Fatalf("committed model = %q, want old-model", got) } app, release := svc.acquireApp() @@ -169,7 +177,7 @@ func TestSaveConfigSerializesConcurrentCandidates(t *testing.T) { svc := NewService(ServiceConfig{ App: oldApp, ConfigStore: store, AppFactory: func(_ context.Context, prepared *PreparedConfig) (*runner.App, error) { - model := prepared.Config.LLM.Active().Model + model := activeModel(prepared.Config) entered <- model if model == "first-model" { <-releaseFirst @@ -214,7 +222,7 @@ func TestSaveConfigSerializesConcurrentCandidates(t *testing.T) { if err != nil { t.Fatal(err) } - if got := committed.LLM.Active().Model; got != "second-model" { + if got := activeModel(committed); got != "second-model" { t.Fatalf("final committed model = %q", got) } } diff --git a/pkg/web/conn_probe_test.go b/pkg/web/conn_probe_test.go index 59e8ca16..1552c060 100644 --- a/pkg/web/conn_probe_test.go +++ b/pkg/web/conn_probe_test.go @@ -8,18 +8,20 @@ import ( "strings" "testing" - proto "github.com/chainreactors/aiscan/core/config" + "connectrpc.com/connect" "github.com/chainreactors/aiscan/pkg/probe" + "github.com/chainreactors/aiscan/pkg/rpc/config/configconnect" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) -type cfgT = proto.DistributeConfig +type cfgT = *configpb.DistributeConfig // configWith builds a DistributeConfig, letting each test set only the fields // it cares about. Pass nil for an empty config. -func configWith(fn func(*cfgT)) cfgT { - var c cfgT +func configWith(fn func(*configpb.DistributeConfig)) cfgT { + c := &configpb.DistributeConfig{} if fn != nil { - fn(&c) + fn(c) } return c } @@ -62,7 +64,9 @@ func TestProbeCyberhubSuccess(t *testing.T) { defer srv.Close() svc := newService(&fakeConfigStore{}) - cfg := configWith(func(c *cfgT) { c.Cyberhub.URL = srv.URL; c.Cyberhub.Key = "hub-key" }) + cfg := configWith(func(c *configpb.DistributeConfig) { + c.Cyberhub = &configpb.CyberhubConfig{Url: srv.URL, Key: "hub-key"} + }) resp, err := svc.TestConn(context.Background(), "cyberhub", cfg) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -80,7 +84,9 @@ func TestProbeCyberhubAuthError(t *testing.T) { defer srv.Close() svc := newService(&fakeConfigStore{}) - cfg := configWith(func(c *cfgT) { c.Cyberhub.URL = srv.URL; c.Cyberhub.Key = "nope" }) + cfg := configWith(func(c *configpb.DistributeConfig) { + c.Cyberhub = &configpb.CyberhubConfig{Url: srv.URL, Key: "nope"} + }) resp, err := svc.TestConn(context.Background(), "cyberhub", cfg) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -105,7 +111,7 @@ func TestProbeFofaSuccessAndStoredKeyFallback(t *testing.T) { // FOFA key left blank in the request: the stored secret must be used. store := &fakeConfigStore{} - store.cfg.Recon.FofaKey = "stored-fofa" + store.cfg = &configpb.DistributeConfig{Recon: &configpb.ReconConfig{FofaKey: "stored-fofa"}} svc := newService(store) resp, err := svc.TestConn(context.Background(), "recon", configWith(nil)) @@ -134,7 +140,9 @@ func TestProbeFofaError(t *testing.T) { defer func() { probe.FofaInfoEndpoint = orig }() svc := newService(&fakeConfigStore{}) - resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *cfgT) { c.Recon.FofaKey = "bad" })) + resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *configpb.DistributeConfig) { + c.Recon = &configpb.ReconConfig{FofaKey: "bad"} + })) c, ok := findCheck(resp, "fofa") if !ok || c.OK { t.Fatalf("expected fofa failure, got %+v", resp) @@ -160,7 +168,9 @@ func TestProbeHunterSuccess(t *testing.T) { defer func() { probe.HunterSearchEndpoint = orig }() svc := newService(&fakeConfigStore{}) - resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *cfgT) { c.Recon.HunterAPIKey = "hk" })) + resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *configpb.DistributeConfig) { + c.Recon = &configpb.ReconConfig{HunterApiKey: "hk"} + })) if c, ok := findCheck(resp, "hunter"); !ok || !c.OK { t.Fatalf("expected hunter ok, got %+v", resp) } @@ -176,7 +186,9 @@ func TestProbeHunterError(t *testing.T) { defer func() { probe.HunterSearchEndpoint = orig }() svc := newService(&fakeConfigStore{}) - resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *cfgT) { c.Recon.HunterToken = "bad" })) + resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *configpb.DistributeConfig) { + c.Recon = &configpb.ReconConfig{HunterToken: "bad"} + })) c, ok := findCheck(resp, "hunter") if !ok || c.OK { t.Fatalf("expected hunter failure, got %+v", resp) @@ -198,34 +210,23 @@ func TestHandlerTestConnRouting(t *testing.T) { svc := newService(&fakeConfigStore{}) srv := httptest.NewServer(NewHandler(svc, nil, nil, nil, nil, "")) defer srv.Close() + client := configconnect.NewConfigServiceClient(srv.Client(), srv.URL) - // The {section} wildcard must coexist with the static /llm/test route and - // dispatch to testConn. Empty config yields a failing check but a 200 - // response, proving routing + dispatch worked. - resp, err := http.Post(srv.URL+"/api/config/cyberhub/test", "application/json", strings.NewReader("{}")) + response, err := client.TestConnection(context.Background(), connect.NewRequest(&configpb.TestConnectionRequest{ + Section: "cyberhub", Config: &configpb.DistributeConfig{}, + })) if err != nil { - t.Fatalf("post: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp.StatusCode) + t.Fatalf("TestConnection: %v", err) } - var out []probe.ConnCheck - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - t.Fatalf("decode: %v", err) - } - if len(out) != 1 || out[0].Name != "cyberhub" { - t.Fatalf("expected one cyberhub check, got %+v", out) + if len(response.Msg.Checks) != 1 || response.Msg.Checks[0].Name != "cyberhub" { + t.Fatalf("expected one cyberhub check, got %+v", response.Msg.Checks) } - // An untestable section is rejected with 400. - resp2, err := http.Post(srv.URL+"/api/config/agent/test", "application/json", strings.NewReader("{}")) - if err != nil { - t.Fatalf("post: %v", err) - } - defer resp2.Body.Close() - if resp2.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400 for untestable section, got %d", resp2.StatusCode) + _, err = client.TestConnection(context.Background(), connect.NewRequest(&configpb.TestConnectionRequest{ + Section: "agent", Config: &configpb.DistributeConfig{}, + })) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("expected invalid_argument for untestable section, got %v", err) } } @@ -240,7 +241,9 @@ func TestProbeIOASuccess(t *testing.T) { defer srv.Close() svc := newService(&fakeConfigStore{}) - resp, err := svc.TestConn(context.Background(), "ioa", configWith(func(c *cfgT) { c.IOA.URL = srv.URL; c.IOA.Token = "t" })) + resp, err := svc.TestConn(context.Background(), "ioa", configWith(func(c *configpb.DistributeConfig) { + c.Ioa = &configpb.IOAConfig{Url: srv.URL, Token: "t"} + })) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/web/connect.go b/pkg/web/connect.go index 4abf35e6..c06bd9e1 100644 --- a/pkg/web/connect.go +++ b/pkg/web/connect.go @@ -6,23 +6,23 @@ import ( "net/http" "connectrpc.com/connect" - aop "github.com/chainreactors/aiscan/aop" - "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" - "github.com/chainreactors/aiscan/aop/aiscan/scan/scanconnect" - "github.com/chainreactors/aiscan/aop/aopconnect" + "github.com/chainreactors/aiscan/pkg/rpc/agent/agentconnect" + "github.com/chainreactors/aiscan/pkg/rpc/chat/chatconnect" + "github.com/chainreactors/aiscan/pkg/rpc/config/configconnect" + "github.com/chainreactors/aiscan/pkg/rpc/scan/scanconnect" + "github.com/chainreactors/aiscan/pkg/rpc/sco/scoconnect" + "github.com/chainreactors/aiscan/pkg/rpc/system/systemconnect" "github.com/chainreactors/aiscan/pkg/web/auth" - "google.golang.org/grpc/status" ) -// Protobuf JSON base64-encodes bytes, so a 50 MiB UploadSessionFile payload can -// occupy roughly 67 MiB on the wire. Leave enough envelope headroom while the -// business method continues to enforce the exact 50 MiB file limit. +// Protobuf JSON base64-encodes SCO import bytes, so the 50 MiB business limit +// needs roughly 67 MiB on the management wire. const connectMaxMessageBytes = 72 << 20 -// NewConnectHandler exposes the public AOP service and AIScan's product-specific -// chat service from the same protobuf schemas. Generated Connect handlers also -// accept native gRPC and gRPC-Web requests on the canonical procedure paths. -func NewConnectHandler(accessKey string, service *Service) http.Handler { +// NewConnectHandler exposes AIScan's management/query services from their +// protobuf schemas. Realtime AOP, file, command and PTY traffic is not mounted +// here; it uses the single application WebSocket. +func NewConnectHandler(accessKey string, service *Service, pool *AgentPool, local *LocalAgents) http.Handler { interceptor := connectAuthInterceptor{accessKey: accessKey} opts := []connect.HandlerOption{ connect.WithInterceptors(interceptor), @@ -30,50 +30,22 @@ func NewConnectHandler(accessKey string, service *Service) http.Handler { connect.WithSendMaxBytes(connectMaxMessageBytes), } mux := http.NewServeMux() - chatCore := NewAOPChatServer(service).(*aopChatServer) - chatPath, chatHandler := aopconnect.NewChatServiceHandler(&connectChatServer{core: chatCore}, opts...) + chatCore := NewAOPChatServer(service) sessionPath, sessionHandler := chatconnect.NewSessionServiceHandler(newConnectSessionServer(service, chatCore), opts...) scanPath, scanHandler := scanconnect.NewScanServiceHandler(newConnectScanServer(service), opts...) - mux.Handle(chatPath, chatHandler) + configPath, configHandler := configconnect.NewConfigServiceHandler(newConnectConfigServer(service), opts...) + agentPath, agentHandler := agentconnect.NewAgentServiceHandler(&connectAgentServer{pool: pool, local: local}, opts...) + systemPath, systemHandler := systemconnect.NewSystemServiceHandler(&connectSystemServer{service: service, pool: pool, serverURL: "/"}, opts...) + scoPath, scoHandler := scoconnect.NewSCOServiceHandler(&connectSCOServer{service: service}, opts...) mux.Handle(sessionPath, sessionHandler) mux.Handle(scanPath, scanHandler) + mux.Handle(configPath, configHandler) + mux.Handle(agentPath, agentHandler) + mux.Handle(systemPath, systemHandler) + mux.Handle(scoPath, scoHandler) return mux } -type connectChatServer struct { - aopconnect.UnimplementedChatServiceHandler - core *aopChatServer -} - -func (s *connectChatServer) OpenSession(ctx context.Context, req *connect.Request[aop.OpenSessionRequest]) (*connect.Response[aop.OpenSessionResponse], error) { - response, err := s.core.OpenSession(ctx, req.Msg) - return connectResponse(response, err) -} - -func (s *connectChatServer) RunTurn(ctx context.Context, req *connect.Request[aop.RunTurnRequest]) (*connect.Response[aop.RunTurnResponse], error) { - response, err := s.core.RunTurn(ctx, req.Msg) - return connectResponse(response, err) -} - -func (s *connectChatServer) CancelTurn(ctx context.Context, req *connect.Request[aop.CancelTurnRequest]) (*connect.Response[aop.CancelTurnResponse], error) { - response, err := s.core.CancelTurn(ctx, req.Msg) - return connectResponse(response, err) -} - -func (s *connectChatServer) CloseSession(ctx context.Context, req *connect.Request[aop.CloseSessionRequest]) (*connect.Response[aop.CloseSessionResponse], error) { - response, err := s.core.CloseSession(ctx, req.Msg) - return connectResponse(response, err) -} - -func (s *connectChatServer) ListEvents(ctx context.Context, req *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { - response, err := s.core.ListEvents(ctx, req.Msg) - return connectResponse(response, err) -} - -func (s *connectChatServer) WatchEvents(ctx context.Context, req *connect.Request[aop.WatchEventsRequest], stream *connect.ServerStream[aop.WatchEventsResponse]) error { - return asConnectError(s.core.watchEvents(req.Msg, ctx, stream.Send)) -} - func connectResponse[T any](response *T, err error) (*connect.Response[T], error) { if err != nil { return nil, asConnectError(err) @@ -89,9 +61,6 @@ func asConnectError(err error) error { if errors.As(err, &connectErr) { return connectErr } - if grpcStatus, ok := status.FromError(err); ok { - return connect.NewError(connect.Code(grpcStatus.Code()), errors.New(grpcStatus.Message())) - } return connect.NewError(connect.CodeInternal, err) } @@ -135,5 +104,4 @@ func connectAuthenticated(header http.Header, accessKey string) bool { return false } -var _ aopconnect.ChatServiceHandler = (*connectChatServer)(nil) var _ chatconnect.SessionServiceHandler = (*connectSessionServer)(nil) diff --git a/pkg/web/connect_protocol_test.go b/pkg/web/connect_protocol_test.go new file mode 100644 index 00000000..6aaa4e89 --- /dev/null +++ b/pkg/web/connect_protocol_test.go @@ -0,0 +1,42 @@ +package web + +import ( + "context" + "net/http/httptest" + "testing" + + "connectrpc.com/connect" + "github.com/chainreactors/aiscan/pkg/rpc/system/systemconnect" + systempb "github.com/chainreactors/aiscan/pkg/types/system" +) + +func TestConnectHandlerSupportsConnectGRPCWebAndGRPC(t *testing.T) { + service := NewService(ServiceConfig{}) + defer service.Close() + + server := httptest.NewUnstartedServer(NewConnectHandler("", service, NewAgentPool(nil), nil)) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + tests := []struct { + name string + opts []connect.ClientOption + }{ + {name: "connect"}, + {name: "grpc-web", opts: []connect.ClientOption{connect.WithGRPCWeb()}}, + {name: "grpc", opts: []connect.ClientOption{connect.WithGRPC()}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := systemconnect.NewSystemServiceClient(server.Client(), server.URL, test.opts...) + response, err := client.GetStatus(context.Background(), connect.NewRequest(&systempb.GetStatusRequest{})) + if err != nil { + t.Fatal(err) + } + if response.Msg.GetStatus() == nil { + t.Fatal("status is missing") + } + }) + } +} diff --git a/pkg/web/connect_test.go b/pkg/web/connect_test.go deleted file mode 100644 index 11677807..00000000 --- a/pkg/web/connect_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package web - -import ( - "context" - "net/http" - "net/http/httptest" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "connectrpc.com/connect" - aop "github.com/chainreactors/aiscan/aop" - chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" - "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" - "github.com/chainreactors/aiscan/aop/aopconnect" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/protobuf/proto" -) - -func TestConnectJSONAndGRPCShareChatContract(t *testing.T) { - service, pool, stop := newConnectTestService(t) - defer stop() - handler := NewHandler(service, pool, nil, nil, nil, "") - server := httptest.NewUnstartedServer(handler) - server.EnableHTTP2 = true - server.StartTLS() - defer server.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - connectClient := aopconnect.NewChatServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) - opened, err := connectClient.OpenSession(ctx, connect.NewRequest(&aop.OpenSessionRequest{ - RequestId: "connect-open", SessionId: "connect-session", Participant: "agent-1", Title: "connect", - })) - if err != nil || opened.Msg.GetAccepted().GetState() != "open" { - t.Fatalf("Connect OpenSession = %v, %v", opened, err) - } - watch, err := connectClient.WatchEvents(ctx, connect.NewRequest(&aop.WatchEventsRequest{SessionId: "connect-session"})) - if err != nil { - t.Fatal(err) - } - input := &aop.Message{Id: "message-client", Role: "user", Name: "operator", Content: []*aop.Content{{ - Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello over Connect"}}, - }}} - run, err := connectClient.RunTurn(ctx, connect.NewRequest(&aop.RunTurnRequest{ - RequestId: "connect-run", SessionId: "connect-session", TurnId: "connect-turn", Input: input, - })) - if err != nil || run.Msg.GetAccepted().GetState() != "running" { - t.Fatalf("Connect RunTurn = %v, %v", run, err) - } - var sawInput, sawEnd bool - for watch.Receive() { - event := watch.Msg().GetDelivery().GetEvent() - if message := event.GetMessage(); message != nil && message.Id == input.Id { - sawInput = proto.Equal(message, input) - } - if event.GetTurnEnded() != nil && event.TurnId == "connect-turn" { - sawEnd = true - break - } - } - if err := watch.Err(); err != nil && !sawEnd { - t.Fatal(err) - } - if !sawInput || !sawEnd { - t.Fatalf("Connect stream sawInput=%v sawEnd=%v", sawInput, sawEnd) - } - - sessionClient := chatconnect.NewSessionServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) - resetRequest := &chatpb.ResetSessionRequest{ - RequestId: "reset-1", SessionId: "connect-session", NewSessionId: "connect-session-reset", - } - reset, err := sessionClient.ResetSession(ctx, connect.NewRequest(resetRequest)) - if err != nil || reset.Msg.GetAccepted().GetCurrent().GetSession().GetId() != "connect-session-reset" { - t.Fatalf("ResetSession = %v, %v", reset, err) - } - assertResetHistory := func() { - t.Helper() - oldEvents, err := connectClient.ListEvents(ctx, connect.NewRequest(&aop.ListEventsRequest{SessionId: "connect-session", Limit: 100})) - if err != nil { - t.Fatal(err) - } - oldMessage, oldEnded := 0, 0 - for _, delivery := range oldEvents.Msg.Events { - event := delivery.Event - if event.GetMessage().GetId() == input.Id { - oldMessage++ - } - if event.GetSessionEnded().GetReason() == "reset" { - oldEnded++ - } - } - if oldMessage != 1 || oldEnded != 1 { - t.Fatalf("old session history message=%d reset_end=%d events=%v", oldMessage, oldEnded, oldEvents.Msg.Events) - } - newEvents, err := connectClient.ListEvents(ctx, connect.NewRequest(&aop.ListEventsRequest{SessionId: "connect-session-reset", Limit: 100})) - if err != nil { - t.Fatal(err) - } - started := 0 - for _, delivery := range newEvents.Msg.Events { - event := delivery.Event - if event.GetSessionStarted() != nil { - started++ - } - if event.GetMessage() != nil || event.GetTurnStarted() != nil || event.GetTurnEnded() != nil { - t.Fatalf("reset session inherited chat history: %v", event) - } - } - if started != 1 { - t.Fatalf("new session_started count = %d, events=%v", started, newEvents.Msg.Events) - } - } - assertResetHistory() - replayedReset, err := sessionClient.ResetSession(ctx, connect.NewRequest(proto.Clone(resetRequest).(*chatpb.ResetSessionRequest))) - if err != nil || !proto.Equal(reset.Msg, replayedReset.Msg) { - t.Fatalf("ResetSession replay = %v, %v; want %v", replayedReset, err, reset) - } - assertResetHistory() - - tlsConfig := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone() - tlsConfig.InsecureSkipVerify = true //nolint:gosec // httptest certificate - grpcConn, err := grpc.NewClient(strings.TrimPrefix(server.URL, "https://"), grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) - if err != nil { - t.Fatal(err) - } - defer grpcConn.Close() - grpcClient := aop.NewChatServiceClient(grpcConn) - grpcOpened, err := grpcClient.OpenSession(ctx, &aop.OpenSessionRequest{ - RequestId: "grpc-open", SessionId: "grpc-session", Participant: "agent-1", Title: "grpc", - }) - if err != nil || grpcOpened.GetAccepted().GetState() != "open" { - t.Fatalf("gRPC OpenSession through Connect handler = %v, %v", grpcOpened, err) - } -} - -func TestConnectBearerAuthentication(t *testing.T) { - store, err := NewSQLiteStore(t.TempDir() + "/auth.db") - if err != nil { - t.Fatal(err) - } - defer store.Close() - service := NewService(ServiceConfig{Store: store}) - defer service.Close() - server := httptest.NewServer(NewHandler(service, nil, nil, nil, nil, "secret")) - defer server.Close() - client := chatconnect.NewSessionServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) - - if _, err := client.ListSessions(context.Background(), connect.NewRequest(&chatpb.ListSessionsRequest{})); connect.CodeOf(err) != connect.CodeUnauthenticated { - t.Fatalf("unauthenticated ListSessions error = %v", err) - } - request := connect.NewRequest(&chatpb.ListSessionsRequest{}) - request.Header().Set("Authorization", "Bearer secret") - if _, err := client.ListSessions(context.Background(), request); err != nil { - t.Fatalf("authenticated ListSessions: %v", err) - } -} - -func TestExternalGoModuleConnectClientEndToEnd(t *testing.T) { - service, pool, stop := newConnectTestService(t) - defer stop() - server := httptest.NewServer(NewHandler(service, pool, nil, nil, nil, "external-secret")) - defer server.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - clientDir, err := filepath.Abs(filepath.Join("..", "..", "examples", "external-go-client")) - if err != nil { - t.Fatal(err) - } - command := exec.CommandContext(ctx, "go", "run", ".", - "-url", server.URL, - "-token", "external-secret", - "-agent", "agent-1", - "-prompt", "hello from an independent module", - "-timeout", "30s", - ) - command.Dir = clientDir - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("external client failed: %v\n%s", err, output) - } - text := string(output) - if !strings.Contains(text, "done") || !strings.Contains(text, "stop=completed") { - t.Fatalf("external client output = %q", text) - } -} - -func newConnectTestService(t *testing.T) (*Service, *AgentPool, func()) { - t.Helper() - store, err := NewSQLiteStore(t.TempDir() + "/connect.db") - if err != nil { - t.Fatal(err) - } - service := NewService(ServiceConfig{Store: store}) - pool := NewAgentPool(service.Hub()) - service.SetAgentPool(pool) - fake := &remoteAgent{ - id: "agent-1", name: "agent-1", sendCh: make(chan *transport.ServerFrame, 32), controlCh: make(chan *transport.ServerFrame, 32), - tasks: make(map[string]chan taskResult), turns: make(map[string]int), openSessions: make(map[string]struct{}), - childSessions: make(map[string]map[string]struct{}), done: make(chan struct{}), - } - pool.agents[fake.id] = fake - stop := make(chan struct{}) - go func() { - for { - select { - case frame := <-fake.sendCh: - respondToConnectTestFrame(pool, fake, frame) - case frame := <-fake.controlCh: - respondToConnectTestFrame(pool, fake, frame) - case <-stop: - return - } - } - }() - return service, pool, func() { - close(stop) - service.Close() - _ = store.Close() - } -} - -func respondToConnectTestFrame(pool *AgentPool, fake *remoteAgent, frame *transport.ServerFrame) { - if frame == nil { - return - } - switch payload := frame.Payload.(type) { - case *transport.ServerFrame_OpenSession: - request := payload.OpenSession - pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_OpenSession{OpenSession: &aop.OpenSessionResponse{ - RequestId: request.RequestId, Outcome: &aop.OpenSessionResponse_Accepted{Accepted: &aop.Session{Id: request.SessionId, State: "open", Participant: request.Participant, Title: request.Title}}, - }}}) - pool.handleAgentFrame(fake, &transport.AgentFrame{Payload: &transport.AgentFrame_Event{Event: &aop.Event{ - SessionId: request.SessionId, Emitter: fake.name, Payload: &aop.Event_SessionStarted{SessionStarted: &aop.SessionStarted{}}, - }}}) - case *transport.ServerFrame_RunTurn: - request := payload.RunTurn - pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_RunTurn{RunTurn: &aop.RunTurnResponse{ - RequestId: request.RequestId, Outcome: &aop.RunTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: request.SessionId, TurnId: request.TurnId, State: "running"}}, - }}}) - for _, event := range []*aop.Event{ - {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_TurnStarted{TurnStarted: &aop.TurnStarted{}}}, - {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_Message{Message: proto.Clone(request.Input).(*aop.Message)}}, - {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_Message{Message: &aop.Message{Id: "assistant-1", Role: "assistant", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "done"}}}}}}}, - {SessionId: request.SessionId, TurnId: request.TurnId, Emitter: fake.name, Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}}, - } { - pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: request.TurnId, Payload: &transport.AgentFrame_Event{Event: event}}) - } - case *transport.ServerFrame_CloseSession: - request := payload.CloseSession - pool.handleAgentFrame(fake, &transport.AgentFrame{Payload: &transport.AgentFrame_Event{Event: &aop.Event{ - SessionId: request.SessionId, Emitter: fake.name, Payload: &aop.Event_SessionEnded{SessionEnded: &aop.SessionEnded{Reason: request.Reason}}, - }}}) - pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CloseSession{CloseSession: &aop.CloseSessionResponse{ - RequestId: request.RequestId, Outcome: &aop.CloseSessionResponse_Accepted{Accepted: &aop.Session{Id: request.SessionId, State: "closed"}}, - }}}) - case *transport.ServerFrame_CancelTurn: - request := payload.CancelTurn - pool.handleAgentFrame(fake, &transport.AgentFrame{CorrelationId: frame.CorrelationId, Payload: &transport.AgentFrame_CancelTurn{CancelTurn: &aop.CancelTurnResponse{ - RequestId: request.RequestId, Outcome: &aop.CancelTurnResponse_Accepted{Accepted: &aop.TurnReceipt{SessionId: request.SessionId, TurnId: request.TurnId, State: "canceled"}}, - }}}) - } -} diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go index 543e1835..b7ec4521 100644 --- a/pkg/web/eval_forward_test.go +++ b/pkg/web/eval_forward_test.go @@ -4,7 +4,7 @@ import ( "testing" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" ) type evalSink struct { @@ -58,3 +58,23 @@ func TestForwardStandaloneScanAOPDoesNotCreateChatHistory(t *testing.T) { t.Fatalf("standalone scan AOP was forwarded to chat history: %+v", sink.aopEvents) } } + +func TestForwardUncorrelatedEventForAgentOpenSession(t *testing.T) { + sink := &evalSink{} + pool := NewAgentPool(NewHub()) + pool.SetSessionLookup(sink) + remote := &remoteAgent{openSessions: map[string]struct{}{"session-command": {}}} + event := &aop.Event{ + SessionId: "session-command", + Emitter: "worker", + Payload: &aop.Event_Message{Message: &aop.Message{ + Id: "command-result", Role: "assistant", Content: []*aop.Content{aop.Text("Session: session-command")}, + }}, + } + + pool.forwardAOPFrame(remote, "", event) + + if len(sink.aopEvents) != 1 || sink.aopEvents[0].GetMessage().GetId() != "command-result" { + t.Fatalf("uncorrelated command event was not forwarded: %+v", sink.aopEvents) + } +} diff --git a/pkg/web/grpc.go b/pkg/web/grpc.go deleted file mode 100644 index d4f10b41..00000000 --- a/pkg/web/grpc.go +++ /dev/null @@ -1,61 +0,0 @@ -package web - -import ( - "context" - "strings" - - aop "github.com/chainreactors/aiscan/aop" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" - "github.com/chainreactors/aiscan/pkg/web/auth" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -func NewGRPCServer(accessKey string, service *Service, pool *AgentPool) *grpc.Server { - server := grpc.NewServer( - grpc.ChainUnaryInterceptor(grpcUnaryAuth(accessKey)), - grpc.ChainStreamInterceptor(grpcStreamAuth(accessKey)), - ) - aop.RegisterChatServiceServer(server, NewAOPChatServer(service)) - scanpb.RegisterScanServiceServer(server, newGRPCScanServer(service)) - transport.RegisterAgentTransportServiceServer(server, NewAgentTransportServer(pool)) - return server -} - -func grpcUnaryAuth(accessKey string) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - if !grpcAuthenticated(ctx, accessKey) { - return nil, status.Error(codes.Unauthenticated, "invalid or missing access key") - } - return handler(ctx, req) - } -} - -func grpcStreamAuth(accessKey string) grpc.StreamServerInterceptor { - return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - if !grpcAuthenticated(stream.Context(), accessKey) { - return status.Error(codes.Unauthenticated, "invalid or missing access key") - } - return handler(srv, stream) - } -} - -func grpcAuthenticated(ctx context.Context, accessKey string) bool { - if accessKey == "" { - return true - } - values, ok := metadata.FromIncomingContext(ctx) - if !ok { - return false - } - for _, value := range values.Get("authorization") { - parts := strings.Fields(value) - if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") && auth.AccessKeyMatches(accessKey, parts[1]) { - return true - } - } - return false -} diff --git a/pkg/web/handler.go b/pkg/web/handler.go index 52ad3d9e..383e1a43 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -4,78 +4,40 @@ import ( "encoding/json" "io" "net/http" - "strconv" - "github.com/chainreactors/aiscan/agent/probe" - config "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/pkg/rpc/agent/agentconnect" + "github.com/chainreactors/aiscan/pkg/rpc/chat/chatconnect" + "github.com/chainreactors/aiscan/pkg/rpc/config/configconnect" + "github.com/chainreactors/aiscan/pkg/rpc/scan/scanconnect" + "github.com/chainreactors/aiscan/pkg/rpc/sco/scoconnect" + "github.com/chainreactors/aiscan/pkg/rpc/system/systemconnect" ) -type Handler struct { - handler http.Handler -} +type Handler struct{ handler http.Handler } -func NewHandler(service *Service, agents *AgentPool, local *LocalAgents, ioaHandler http.Handler, static http.Handler, accessKey string, ioaConsole ...IOAConsoleReader) *Handler { +func NewHandler(service *Service, agents *AgentPool, local *LocalAgents, ioaHandler http.Handler, static http.Handler, accessKey string) *Handler { mux := http.NewServeMux() - - var console IOAConsoleReader - if len(ioaConsole) > 0 { - console = ioaConsole[0] - } - h := &handlerImpl{service: service, agents: agents, ioa: console, accessKey: accessKey} registerAuthRoutes(mux, accessKey) - connectHandler := NewConnectHandler(accessKey, service) - mux.Handle("/aop.ChatService/", connectHandler) - mux.Handle("/aiscan.chat.SessionService/", connectHandler) - mux.Handle("/aiscan.scan.ScanService/", connectHandler) - // Retired REST/SSE protocol roots must not fall through to the SPA and - // masquerade as successful HTML responses. - legacyNotFound := func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } - mux.HandleFunc("/api/chat", legacyNotFound) - mux.HandleFunc("/api/chat/", legacyNotFound) - mux.HandleFunc("/api/scans", legacyNotFound) - mux.HandleFunc("/api/scans/", legacyNotFound) - - mux.HandleFunc("GET /api/status", h.serviceStatus) - mux.HandleFunc("GET /api/config", h.getConfig) - mux.HandleFunc("PUT /api/config", h.saveConfig) - mux.HandleFunc("PUT /api/config/llm/active", h.activateLLMProfile) - mux.HandleFunc("GET /api/config/distribute", h.getDistributeConfig) - mux.HandleFunc("POST /api/config/llm/test", h.testLLM) - mux.HandleFunc("POST /api/config/llm/models", h.listLLMModels) - mux.HandleFunc("POST /api/config/{section}/test", h.testConn) - mux.HandleFunc("GET /api/agents", h.listAgents) - if console != nil { - mux.HandleFunc("GET /api/ioa/overview", h.ioaOverview) - } - - mux.HandleFunc("GET /api/sco/nodes", h.listSCONodes) - mux.HandleFunc("GET /api/sco/nodes/{id}", h.getSCONode) - mux.HandleFunc("GET /api/sco/stats", h.scoNodeStats) - mux.HandleFunc("DELETE /api/sco/nodes", h.deleteSCONodes) - mux.HandleFunc("POST /api/sco/import", h.importSCONodes) - mux.HandleFunc("GET /api/sco/artifacts", h.listSupportedArtifacts) - + connectHandler := NewConnectHandler(accessKey, service, agents, local) + mux.Handle("/"+chatconnect.SessionServiceName+"/", connectHandler) + mux.Handle("/"+scanconnect.ScanServiceName+"/", connectHandler) + mux.Handle("/"+configconnect.ConfigServiceName+"/", connectHandler) + mux.Handle("/"+agentconnect.AgentServiceName+"/", connectHandler) + mux.Handle("/"+systemconnect.SystemServiceName+"/", connectHandler) + mux.Handle("/"+scoconnect.SCOServiceName+"/", connectHandler) if agents != nil { - mux.HandleFunc("/api/agents/{id}/terminal/ws", func(w http.ResponseWriter, r *http.Request) { - agents.HandleTerminalWS(r.PathValue("id"), w, r) - }) - mux.HandleFunc("/api/agent/ws", agents.HandleWS) + mux.HandleFunc("/api/aop/ws", func(w http.ResponseWriter, r *http.Request) { HandleAOPWebSocket(service, agents, w, r) }) } - if ioaHandler != nil { mux.Handle("/ioa/", http.StripPrefix("/ioa", ioaHandler)) } - - mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) - - registerLocalAgentRoutes(mux, local) - + mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }) if static != nil { mux.Handle("/", static) } - return &Handler{handler: AccessKeyAuth(accessKey)(mux)} } @@ -91,214 +53,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handler.ServeHTTP(w, r) } -type handlerImpl struct { - service *Service - agents *AgentPool - ioa IOAConsoleReader - accessKey string -} - -func (h *handlerImpl) serviceStatus(w http.ResponseWriter, r *http.Request) { - status := h.service.Status() - if h.agents != nil { - status.Agents = h.agents.Count() - } - if h.accessKey != "" { - host := r.Host - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { - scheme = fwd - } - status.IOAURL = scheme + "://" + h.accessKey + "@" + host + "/ioa" - } - writeJSON(w, http.StatusOK, status) -} - -func (h *handlerImpl) listAgents(w http.ResponseWriter, r *http.Request) { - if h.agents == nil { - writeJSON(w, http.StatusOK, []AgentInfo{}) - return - } - writeJSON(w, http.StatusOK, h.agents.List()) -} - -func (h *handlerImpl) getConfig(w http.ResponseWriter, r *http.Request) { - cs, err := h.service.GetConfigStatus(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, cs) -} - -func (h *handlerImpl) saveConfig(w http.ResponseWriter, r *http.Request) { - var req config.DistributeConfig - if err := decodeJSON(r.Body, &req); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - cs, err := h.service.SaveConfig(r.Context(), req) - if err != nil { - writeError(w, http.StatusUnprocessableEntity, err.Error()) - return - } - writeJSON(w, http.StatusOK, cs) -} - -func (h *handlerImpl) activateLLMProfile(w http.ResponseWriter, r *http.Request) { - var req struct { - ID string `json:"id"` - } - if err := decodeJSON(r.Body, &req); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - cs, err := h.service.ActivateLLMProfile(r.Context(), req.ID) - if err != nil { - writeError(w, http.StatusUnprocessableEntity, err.Error()) - return - } - writeJSON(w, http.StatusOK, cs) -} - -func (h *handlerImpl) getDistributeConfig(w http.ResponseWriter, r *http.Request) { - cfg, err := h.service.GetDistributeConfig(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, cfg) -} - -func (h *handlerImpl) testLLM(w http.ResponseWriter, r *http.Request) { - var req probe.LLMProbeRequest - if !decodeBody(w, r, &req) { - return - } - result, err := h.service.TestLLM(r.Context(), req) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, result) -} - -func (h *handlerImpl) listLLMModels(w http.ResponseWriter, r *http.Request) { - var req probe.LLMProbeRequest - if !decodeBody(w, r, &req) { - return - } - result, err := h.service.ListLLMModels(r.Context(), req) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, result) -} - -func (h *handlerImpl) testConn(w http.ResponseWriter, r *http.Request) { - var cfg config.DistributeConfig - if !decodeOptionalBody(w, r, &cfg) { - return - } - result, err := h.service.TestConn(r.Context(), r.PathValue("section"), cfg) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, http.StatusOK, result) -} - -// ── SCO Nodes ── - -func (h *handlerImpl) listSCONodes(w http.ResponseWriter, r *http.Request) { - nodeType := r.URL.Query().Get("type") - scanID := r.URL.Query().Get("scan_id") - limit := 500 - if v := r.URL.Query().Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - limit = n - } - } - nodes, err := h.service.store.ListSCONodesByScanID(r.Context(), scanID, nodeType, limit) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if nodes == nil { - nodes = []json.RawMessage{} - } - writeJSON(w, http.StatusOK, nodes) -} - -func (h *handlerImpl) getSCONode(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - node, err := h.service.store.GetSCONode(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "node not found") - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(node) -} - -func (h *handlerImpl) scoNodeStats(w http.ResponseWriter, r *http.Request) { - stats, err := h.service.store.SCONodeStats(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, stats) -} - -func (h *handlerImpl) deleteSCONodes(w http.ResponseWriter, r *http.Request) { - scanID := r.URL.Query().Get("scan_id") - if scanID == "" { - writeError(w, http.StatusBadRequest, "scan_id required") - return - } - if err := h.service.store.DeleteSCONodesByScan(r.Context(), scanID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) -} - -func writeJSON(w http.ResponseWriter, status int, v interface{}) { +func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) + _ = json.NewEncoder(w).Encode(value) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } -func decodeJSON(body io.ReadCloser, v interface{}) error { +func decodeJSON(body io.ReadCloser, value any) error { defer body.Close() - return json.NewDecoder(body).Decode(v) + return json.NewDecoder(body).Decode(value) } -// decodeBody decodes the JSON request body into v, writing a 400 on failure. -// Returns false when the caller should return early. -func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool { - if err := decodeJSON(r.Body, v); err != nil { +func decodeBody(w http.ResponseWriter, r *http.Request, value any) bool { + if err := decodeJSON(r.Body, value); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return false } return true } - -// decodeOptionalBody decodes the request body only when one is present. An absent -// body is fine (returns true); a present-but-invalid body writes a 400 and -// returns false so the caller returns early. -func decodeOptionalBody(w http.ResponseWriter, r *http.Request, v any) bool { - if r.ContentLength == 0 { - return true - } - return decodeBody(w, r, v) -} diff --git a/pkg/web/handler_import.go b/pkg/web/handler_import.go deleted file mode 100644 index 799768b4..00000000 --- a/pkg/web/handler_import.go +++ /dev/null @@ -1,81 +0,0 @@ -package web - -import ( - "encoding/json" - "io" - "net/http" - "strings" - - "github.com/chainreactors/libcstx/go" -) - -func (h *handlerImpl) importSCONodes(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 50<<20) - if err := r.ParseMultipartForm(50 << 20); err != nil { //nolint:gosec // bounded by MaxBytesReader above - writeError(w, http.StatusBadRequest, "parse form: "+err.Error()) - return - } - - artifact := strings.TrimSpace(r.FormValue("artifact")) - if artifact == "" { - writeError(w, http.StatusBadRequest, "artifact is required") - return - } - - file, _, err := r.FormFile("file") - if err != nil { - writeError(w, http.StatusBadRequest, "file is required: "+err.Error()) - return - } - defer file.Close() - - data, err := io.ReadAll(io.LimitReader(file, 50<<20)) - if err != nil { - writeError(w, http.StatusBadRequest, "read file: "+err.Error()) - return - } - - nodes, err := cstx.Parse(artifact, data) - if err != nil { - writeError(w, http.StatusUnprocessableEntity, "transform failed: "+err.Error()) - return - } - - rawNodes := make([]json.RawMessage, 0, len(nodes)) - seen := make(map[string]struct{}, len(nodes)) - for _, n := range nodes { - id := n.CstxID() - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - if raw, err := json.Marshal(n); err == nil { - rawNodes = append(rawNodes, raw) - } - } - - scanID := r.FormValue("scan_id") - if scanID == "" { - scanID = "import" - } - - if err := h.service.store.UpsertSCONodes(r.Context(), scanID, rawNodes); err != nil { - writeError(w, http.StatusInternalServerError, "store: "+err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "nodes": len(rawNodes), - "artifact": artifact, - "duplicates": len(nodes) - len(rawNodes), - }) -} - -func (h *handlerImpl) listSupportedArtifacts(w http.ResponseWriter, _ *http.Request) { - arts := cstx.SupportedArtifacts() - if arts == nil { - arts = []string{} - } - writeJSON(w, http.StatusOK, arts) -} diff --git a/pkg/web/ioa_auth.go b/pkg/web/ioa_auth.go new file mode 100644 index 00000000..b1b19368 --- /dev/null +++ b/pkg/web/ioa_auth.go @@ -0,0 +1,33 @@ +package web + +import ( + "net/http" + + "github.com/chainreactors/aiscan/pkg/web/auth" +) + +// ShareWebAuthWithIOA maps an authenticated AIScan Web request to the IOA +// node token reserved for the browser UI. Native IOA clients keep their own +// bearer tokens and continue through the IOA authentication middleware +// unchanged. +func ShareWebAuthWithIOA(accessKey, ioaToken string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webAuthenticated := auth.AuthenticateRequest(r, accessKey) + if accessKey == "" && r.Header.Get("Authorization") != "" { + // In auth-disabled development mode, preserve explicit IOA identities. + webAuthenticated = false + } + if !webAuthenticated || ioaToken == "" { + next.ServeHTTP(w, r) + return + } + + request := r.Clone(r.Context()) + request.Header = r.Header.Clone() + request.Header.Set("Authorization", "Bearer "+ioaToken) + if accessKey != "" { + request.Header.Set("X-Access-Key", accessKey) + } + next.ServeHTTP(w, request) + }) +} diff --git a/pkg/web/ioa_auth_test.go b/pkg/web/ioa_auth_test.go new file mode 100644 index 00000000..d17cae9d --- /dev/null +++ b/pkg/web/ioa_auth_test.go @@ -0,0 +1,59 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/chainreactors/aiscan/pkg/web/auth" +) + +func TestShareWebAuthWithIOA(t *testing.T) { + const accessKey = "test-token" + const ioaToken = "ioa-web-token" + + var authorization, forwardedAccessKey string + handler := ShareWebAuthWithIOA(accessKey, ioaToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + forwardedAccessKey = r.Header.Get("X-Access-Key") + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/ioa/nodes", nil) + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: auth.SessionValue(accessKey)}) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent) + } + if authorization != "Bearer "+ioaToken { + t.Fatalf("Authorization = %q", authorization) + } + if forwardedAccessKey != accessKey { + t.Fatalf("X-Access-Key = %q", forwardedAccessKey) + } +} + +func TestShareWebAuthWithIOAPreservesNativeIdentity(t *testing.T) { + const nativeToken = "native-ioa-token" + + var authorization string + handler := ShareWebAuthWithIOA("test-token", "ioa-web-token", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/ioa/nodes", nil) + req.Header.Set("Authorization", "Bearer "+nativeToken) + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: auth.SessionValue("test-token")}) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent) + } + if authorization != "Bearer "+nativeToken { + t.Fatalf("Authorization = %q, want native token", authorization) + } +} diff --git a/pkg/web/ioa_console.go b/pkg/web/ioa_console.go deleted file mode 100644 index a6119a4e..00000000 --- a/pkg/web/ioa_console.go +++ /dev/null @@ -1,59 +0,0 @@ -package web - -import ( - "context" - "net/http" - - "github.com/chainreactors/ioa/protocols" -) - -// IOAConsoleReader is the read-only IOA projection exposed to the authenticated -// AIScan web console. Agent registration and message writes still go through -// the native IOA API and its per-node authentication. -type IOAConsoleReader interface { - ListNodes(context.Context) ([]protocols.Node, error) - ListSpaces(context.Context) ([]protocols.SpaceInfo, error) - ListMessages(context.Context, protocols.MessageFilter) ([]protocols.Message, error) -} - -type ioaOverviewResponse struct { - Nodes []protocols.Node `json:"nodes"` - Spaces []protocols.SpaceInfo `json:"spaces"` - Messages []protocols.Message `json:"messages"` -} - -func (h *handlerImpl) ioaOverview(w http.ResponseWriter, r *http.Request) { - if h.ioa == nil { - writeError(w, http.StatusServiceUnavailable, "IOA console is unavailable") - return - } - - nodes, err := h.ioa.ListNodes(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - spaces, err := h.ioa.ListSpaces(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - messages, err := h.ioa.ListMessages(r.Context(), protocols.MessageFilter{}) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - if nodes == nil { - nodes = []protocols.Node{} - } - if spaces == nil { - spaces = []protocols.SpaceInfo{} - } - if messages == nil { - messages = []protocols.Message{} - } - writeJSON(w, http.StatusOK, ioaOverviewResponse{ - Nodes: nodes, Spaces: spaces, Messages: messages, - }) -} diff --git a/pkg/web/ioa_console_test.go b/pkg/web/ioa_console_test.go deleted file mode 100644 index 8e094362..00000000 --- a/pkg/web/ioa_console_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package web - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/chainreactors/ioa/protocols" - ioaserver "github.com/chainreactors/ioa/server" -) - -var _ IOAConsoleReader = (*ioaserver.Service)(nil) - -type fakeIOAConsole struct{} - -func (fakeIOAConsole) ListNodes(context.Context) ([]protocols.Node, error) { - return []protocols.Node{{ID: "node-1", Name: "scanner-1"}}, nil -} - -func (fakeIOAConsole) ListSpaces(context.Context) ([]protocols.SpaceInfo, error) { - return []protocols.SpaceInfo{{ID: "space-1", Name: "default", MessageCount: 1}}, nil -} - -func (fakeIOAConsole) ListMessages(context.Context, protocols.MessageFilter) ([]protocols.Message, error) { - return []protocols.Message{{ - ID: "message-1", SpaceID: "space-1", Sender: "node-1", - Content: map[string]any{"content": "hello"}, - }}, nil -} - -func TestIOAOverview(t *testing.T) { - svc := NewService(ServiceConfig{}) - server := httptest.NewServer(NewHandler(svc, nil, nil, nil, nil, "", fakeIOAConsole{})) - defer server.Close() - - resp, err := http.Get(server.URL + "/api/ioa/overview") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d", resp.StatusCode) - } - - var overview ioaOverviewResponse - if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil { - t.Fatal(err) - } - if len(overview.Nodes) != 1 || overview.Nodes[0].ID != "node-1" { - t.Fatalf("nodes = %+v", overview.Nodes) - } - if len(overview.Spaces) != 1 || overview.Spaces[0].ID != "space-1" { - t.Fatalf("spaces = %+v", overview.Spaces) - } - if len(overview.Messages) != 1 || overview.Messages[0].ID != "message-1" { - t.Fatalf("messages = %+v", overview.Messages) - } -} diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go index 9065d1b8..23d40e3d 100644 --- a/pkg/web/llm_probe_test.go +++ b/pkg/web/llm_probe_test.go @@ -9,19 +9,26 @@ import ( "testing" "github.com/chainreactors/aiscan/agent/probe" - proto "github.com/chainreactors/aiscan/core/config" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) // fakeConfigStore is a minimal in-memory ConfigStore for probe tests. type fakeConfigStore struct { - cfg proto.DistributeConfig + cfg *configpb.DistributeConfig } -func (f *fakeConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, proto.DistributeConfig, error) { - return "config.yaml", true, f.cfg, nil +func (f *fakeConfigStore) current() *configpb.DistributeConfig { + if f.cfg == nil { + f.cfg = &configpb.DistributeConfig{} + } + return f.cfg +} + +func (f *fakeConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, *configpb.DistributeConfig, error) { + return "config.yaml", true, f.current(), nil } -func (f *fakeConfigStore) PrepareDistributeConfig(_ context.Context, cfg proto.DistributeConfig) (*PreparedConfig, error) { +func (f *fakeConfigStore) PrepareDistributeConfig(_ context.Context, cfg *configpb.DistributeConfig) (*PreparedConfig, error) { return &PreparedConfig{Config: cfg, TargetPath: "config.yaml"}, nil } @@ -97,7 +104,9 @@ func TestTestLLMFallsBackToStoredKey(t *testing.T) { defer srv.Close() store := &fakeConfigStore{} - store.cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "default", Provider: "openai", APIKey: "sk-stored"}} + store.cfg = &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + Providers: []*configpb.LLMProviderConfig{{Id: "default", Provider: "openai", ApiKey: "sk-stored"}}, + }} svc := NewService(ServiceConfig{ConfigStore: store}) // APIKey left blank: the stored secret must be used. @@ -187,7 +196,9 @@ func TestListLLMModelsFallsBackToStoredKey(t *testing.T) { defer srv.Close() store := &fakeConfigStore{} - store.cfg.LLM.Providers = []proto.LLMProviderConfig{{ID: "default", Provider: "openai", APIKey: "sk-stored"}} + store.cfg = &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + Providers: []*configpb.LLMProviderConfig{{Id: "default", Provider: "openai", ApiKey: "sk-stored"}}, + }} svc := NewService(ServiceConfig{ConfigStore: store}) // APIKey left blank: the stored secret must be used. @@ -212,11 +223,13 @@ func TestListLLMModelsUsesSelectedProfileStoredKey(t *testing.T) { defer srv.Close() store := &fakeConfigStore{} - store.cfg.LLM.ActiveProfile = "primary" - store.cfg.LLM.Providers = []proto.LLMProviderConfig{ - {ID: "primary", Provider: "openai", APIKey: "sk-primary"}, - {ID: "secondary", Provider: "openai", APIKey: "sk-secondary"}, - } + store.cfg = &configpb.DistributeConfig{Llm: &configpb.LLMConfig{ + ActiveProfile: "primary", + Providers: []*configpb.LLMProviderConfig{ + {Id: "primary", Provider: "openai", ApiKey: "sk-primary"}, + {Id: "secondary", Provider: "openai", ApiKey: "sk-secondary"}, + }, + }} svc := NewService(ServiceConfig{ConfigStore: store}) res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{ diff --git a/pkg/web/localagent.go b/pkg/web/localagent.go index 5405f46d..3ee07668 100644 --- a/pkg/web/localagent.go +++ b/pkg/web/localagent.go @@ -3,22 +3,14 @@ package web import ( "context" "fmt" - "net/http" "net/url" "os" "os/exec" "strings" "sync" -) -// LocalAgentView is the API-facing view of a hub-hosted agent, cross-referenced -// with the live pool for connection state. -type LocalAgentView struct { - Name string `json:"name"` - PID int `json:"pid"` - Registered bool `json:"registered"` // has connected back to the hub pool - Busy bool `json:"busy,omitempty"` -} + agentpb "github.com/chainreactors/aiscan/pkg/types/agent" +) // localProc is the process handle for one launched `aiscan agent` child. type localProc struct { @@ -33,9 +25,7 @@ type localProc struct { // like any node. The hub holds the only handle to these processes, so StopAll // kills them on shutdown rather than leaving orphans. type LocalAgents struct { - webURL string // hub loopback address children dial (derived from web --addr) - webAuthURL string // same base with the access token as userinfo, for /api/agent/ws auth - ioaURL string // hub IOA endpoint carrying the embedded access token + serverURL string // authenticated hub base; Agent derives /api/aop/ws and /ioa configFile string // explicit hub config inherited by hub-launched children pool *AgentPool // live pool, for registration/busy cross-reference @@ -44,14 +34,12 @@ type LocalAgents struct { seq int } -// NewLocalAgents builds a launcher. hubURL is the loopback base the children -// dial (e.g. http://127.0.0.1:8080); ioaToken is embedded into the child's IOA -// URL. Children are launched from the current aiscan executable. -func NewLocalAgents(hubURL, ioaToken, configFile string, pool *AgentPool) *LocalAgents { +// NewLocalAgents builds a launcher. hubURL is the loopback AIScan server base +// the children dial (e.g. http://127.0.0.1:8080); accessKey is embedded once in +// that URL and the Agent derives both its AOP WebSocket and /ioa endpoints. +func NewLocalAgents(hubURL, accessKey, configFile string, pool *AgentPool) *LocalAgents { return &LocalAgents{ - webURL: hubURL, - webAuthURL: webURLWithToken(hubURL, ioaToken), - ioaURL: nodeIOAURL(hubURL, ioaToken), + serverURL: webURLWithToken(hubURL, accessKey), configFile: strings.TrimSpace(configFile), pool: pool, } @@ -59,7 +47,7 @@ func NewLocalAgents(hubURL, ioaToken, configFile string, pool *AgentPool) *Local // webURLWithToken embeds the access token as userinfo on the hub's loopback web // URL (http://@host), so a launched agent can authenticate its -// /api/agent/ws pool connection — the hub gates /api/* behind that key. An empty +// /api/aop/ws pool connection — the hub gates /api/* behind that key. An empty // token or unparseable hubURL yields hubURL unchanged. func webURLWithToken(hubURL, token string) string { if hubURL == "" || token == "" { @@ -73,31 +61,14 @@ func webURLWithToken(hubURL, token string) string { return u.String() } -// nodeIOAURL embeds the access token as userinfo and points at the /ioa path, -// yielding http://@host:port/ioa. An empty or unparseable hubURL yields "". -func nodeIOAURL(hubURL, token string) string { - if hubURL == "" { - return "" - } - u, err := url.Parse(strings.TrimRight(hubURL, "/")) - if err != nil { - return "" - } - if token != "" { - u.User = url.User(token) - } - u.Path = "/ioa" - return u.String() -} - // Launch spawns an `aiscan agent` on the hub host wired to the hub's loopback // web + IOA endpoints, and tracks it. The LLM provider/model/key arrive via the // hub's config push on registration, so nothing about the model is passed here. -func (l *LocalAgents) Launch(ctx context.Context) (*LocalAgentView, error) { +func (l *LocalAgents) Launch(ctx context.Context) (*agentpb.LocalAgent, error) { if err := ctx.Err(); err != nil { return nil, err } - if l.webURL == "" { + if l.serverURL == "" { return nil, fmt.Errorf("hub local address unknown; cannot launch a local agent (check the web --addr)") } bin, err := os.Executable() @@ -112,8 +83,7 @@ func (l *LocalAgents) Launch(ctx context.Context) (*LocalAgentView, error) { args := []string{ "agent", - "--web-url", l.webAuthURL, - "--server-url", l.ioaURL, + "--server-url", l.serverURL, "--space", "default", "--node-name", name, } @@ -138,18 +108,18 @@ func (l *LocalAgents) Launch(ctx context.Context) (*LocalAgentView, error) { }() v := l.view(p) - return &v, nil + return v, nil } // List returns the tracked local agents (launch order), cross-referenced with // the pool for connection state. -func (l *LocalAgents) List() []LocalAgentView { +func (l *LocalAgents) List() []*agentpb.LocalAgent { l.mu.Lock() all := make([]*localProc, len(l.procs)) copy(all, l.procs) l.mu.Unlock() - views := make([]LocalAgentView, 0, len(all)) + views := make([]*agentpb.LocalAgent, 0, len(all)) for _, p := range all { views = append(views, l.view(p)) } @@ -200,14 +170,14 @@ func (l *LocalAgents) remove(p *localProc) { } // view cross-references a child against the live pool by its display name. -func (l *LocalAgents) view(p *localProc) LocalAgentView { - v := LocalAgentView{Name: p.name, PID: p.pid} +func (l *LocalAgents) view(p *localProc) *agentpb.LocalAgent { + v := &agentpb.LocalAgent{Name: p.name, Pid: int32(p.pid)} if l.pool == nil { return v } for _, a := range l.pool.List() { - if a.Name == p.name { - v.Registered, v.Busy = true, a.Busy + if a.GetHello().GetName() == p.name { + v.Registered, v.Busy = true, a.GetBusy() break } } @@ -220,39 +190,3 @@ func killLocalProc(cmd *exec.Cmd) { _ = cmd.Process.Kill() } } - -// --------------------------------------------------------------------------- -// HTTP surface -// --------------------------------------------------------------------------- - -func (l *LocalAgents) handleLaunch(w http.ResponseWriter, r *http.Request) { - view, err := l.Launch(r.Context()) - if err != nil { - writeError(w, http.StatusUnprocessableEntity, err.Error()) - return - } - writeJSON(w, http.StatusOK, view) -} - -func (l *LocalAgents) handleList(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, l.List()) -} - -func (l *LocalAgents) handleStop(w http.ResponseWriter, r *http.Request) { - if err := l.Stop(r.PathValue("id")); err != nil { - writeError(w, http.StatusUnprocessableEntity, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"}) -} - -// registerLocalAgentRoutes wires the hub-hosted local-agent endpoints. The -// literal "local" segment never collides with a real id, so plain paths suffice. -func registerLocalAgentRoutes(mux *http.ServeMux, l *LocalAgents) { - if l == nil { - return - } - mux.HandleFunc("POST /api/deploy/local", l.handleLaunch) - mux.HandleFunc("GET /api/deploy/local", l.handleList) - mux.HandleFunc("DELETE /api/deploy/local/{id}", l.handleStop) -} diff --git a/pkg/web/probe.go b/pkg/web/probe.go index 67725b26..7d8d8dc5 100644 --- a/pkg/web/probe.go +++ b/pkg/web/probe.go @@ -5,27 +5,31 @@ import ( "strings" agentprobe "github.com/chainreactors/aiscan/agent/probe" - config "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/probe" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) // TestConn probes one settings section's external dependencies, resolving blank // secrets against the stored config, then delegates to pkg/probe. Probe failures // live inside the response; a returned error only signals an untestable section. -func (s *Service) TestConn(ctx context.Context, section string, in config.DistributeConfig) ([]probe.ConnCheck, error) { +func (s *Service) TestConn(ctx context.Context, section string, in *configpb.DistributeConfig) ([]probe.ConnCheck, error) { stored, _ := s.storedConfig(ctx) return probe.TestConn(ctx, section, toProbeConfig(in), toProbeConfig(stored)) } -func toProbeConfig(dc config.DistributeConfig) probe.ProbeConfig { +func toProbeConfig(dc *configpb.DistributeConfig) probe.ProbeConfig { + if dc == nil { + return probe.ProbeConfig{} + } return probe.ProbeConfig{ - Cyberhub: probe.CyberhubProbe{URL: dc.Cyberhub.URL, Key: dc.Cyberhub.Key}, + Cyberhub: probe.CyberhubProbe{URL: dc.GetCyberhub().GetUrl(), Key: dc.GetCyberhub().GetKey()}, Recon: probe.ReconProbe{ - FofaKey: dc.Recon.FofaKey, HunterToken: dc.Recon.HunterToken, - HunterAPIKey: dc.Recon.HunterAPIKey, Proxy: dc.Recon.Proxy, + FofaKey: dc.GetRecon().GetFofaKey(), HunterToken: dc.GetRecon().GetHunterToken(), + HunterAPIKey: dc.GetRecon().GetHunterApiKey(), Proxy: dc.GetRecon().GetProxy(), }, - Search: probe.SearchProbe{TavilyKeys: dc.Search.TavilyKeys}, - IOA: probe.IOAProbe{URL: dc.IOA.URL, Token: dc.IOA.Token}, + Search: probe.SearchProbe{TavilyKeys: dc.GetSearch().GetTavilyKeys()}, + IOA: probe.IOAProbe{URL: dc.GetIoa().GetUrl(), Token: dc.GetIoa().GetToken()}, } } @@ -51,27 +55,29 @@ func (s *Service) storedLLMAPIKey(ctx context.Context, profileID string) string if dc, err := s.GetDistributeConfig(ctx); err == nil { profileID = strings.TrimSpace(profileID) if profileID != "" { - for _, profile := range dc.LLM.Providers { - if profile.ID == profileID { - return strings.TrimSpace(profile.APIKey) + for _, profile := range dc.GetLlm().GetProviders() { + if profile.Id == profileID { + return strings.TrimSpace(profile.ApiKey) } } return "" } - return strings.TrimSpace(dc.LLM.Active().APIKey) + if active := config.ActiveLLMProvider(dc.GetLlm()); active != nil { + return strings.TrimSpace(active.ApiKey) + } } return "" } // storedConfig returns the config persisted on the server, or ok=false when no // config store is wired or it cannot be read. -func (s *Service) storedConfig(ctx context.Context) (config.DistributeConfig, bool) { +func (s *Service) storedConfig(ctx context.Context) (*configpb.DistributeConfig, bool) { if s.config == nil { - return config.DistributeConfig{}, false + return nil, false } dc, err := s.GetDistributeConfig(ctx) if err != nil { - return config.DistributeConfig{}, false + return nil, false } return dc, true } diff --git a/pkg/web/replay_test.go b/pkg/web/replay_test.go index b84c3f43..7ccb2d98 100644 --- a/pkg/web/replay_test.go +++ b/pkg/web/replay_test.go @@ -11,7 +11,6 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -65,7 +64,7 @@ func TestListEventsReplayHasNoSideEffects(t *testing.T) { pool := NewAgentPool(NewHub()) svc := NewService(ServiceConfig{Store: store, AgentPool: pool}) remote := &remoteAgent{ - id: "agent-1", name: "worker", sendCh: make(chan *transport.ServerFrame, 8), + id: "agent-1", name: "worker", sendCh: make(chan *aop.Envelope, 8), done: make(chan struct{}), tasks: map[string]chan taskResult{}, turns: map[string]int{}, } taskCh := make(chan taskResult, 1) @@ -79,20 +78,20 @@ func TestListEventsReplayHasNoSideEffects(t *testing.T) { } arguments, _ := aop.JSONValue(map[string]string{"command": "ls"}) stored := []*aop.Event{ - {Id: "e-1", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 1, 0, time.UTC)), SessionId: session.ID, Emitter: "aiscan", + {Id: "e-1", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 1, 0, time.UTC)), SessionId: session.GetSession().GetId(), Emitter: "aiscan", Payload: &aop.Event_Message{Message: &aop.Message{Id: "m-1", Role: "user", Content: []*aop.Content{aop.Text("hi")}}}}, - {Id: "e-2", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 2, 0, time.UTC)), SessionId: session.ID, Emitter: "aiscan", + {Id: "e-2", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 2, 0, time.UTC)), SessionId: session.GetSession().GetId(), Emitter: "aiscan", Payload: &aop.Event_ToolCall{ToolCall: &aop.ToolCall{Id: "tc-1", Name: "bash", Arguments: arguments}}}, - {Id: "e-3", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 3, 0, time.UTC)), SessionId: session.ID, TurnId: "turn-1", Emitter: "aiscan", + {Id: "e-3", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 3, 0, time.UTC)), SessionId: session.GetSession().GetId(), TurnId: "turn-1", Emitter: "aiscan", Payload: &aop.Event_TurnEnded{TurnEnded: &aop.TurnEnded{StopReason: "completed"}}}, } for _, event := range stored { - if err := store.AddAOPEvent(ctx, session.ID, event); err != nil { + if err := store.AddAOPEvent(ctx, session.GetSession().GetId(), event); err != nil { t.Fatal(err) } } - response, err := NewAOPChatServer(svc).ListEvents(ctx, &aop.ListEventsRequest{SessionId: session.ID, Limit: 100}) + response, err := NewAOPChatServer(svc).ListEvents(ctx, &aop.ListEventsRequest{SessionId: session.GetSession().GetId(), Limit: 100}) if err != nil { t.Fatal(err) } @@ -120,7 +119,7 @@ func TestListEventsReplayHasNoSideEffects(t *testing.T) { t.Fatalf("replay wrote to task channel: result=%+v ok=%v", result, ok) default: } - after, err := store.ListAOPEvents(ctx, session.ID, 100) + after, err := store.ListAOPEvents(ctx, session.GetSession().GetId(), 100) if err != nil || len(after) != len(stored) { t.Fatalf("stored events after replay = %d, %v", len(after), err) } @@ -138,10 +137,9 @@ func TestWatchEventsResumesAfterCursor(t *testing.T) { t.Fatal(err) } for seq := 1; seq <= 3; seq++ { - detail, _ := aop.JSONValue(map[string]int{"seq": seq}) - if err := store.AddAOPEvent(context.Background(), session.ID, &aop.Event{ - Id: string(rune('0' + seq)), EmittedAt: timestamppb.Now(), SessionId: session.ID, Emitter: "aiscan", - Payload: &aop.Event_Status{Status: &aop.Status{State: "running", Detail: detail}}, + if err := store.AddAOPEvent(context.Background(), session.GetSession().GetId(), &aop.Event{ + Id: string(rune('0' + seq)), EmittedAt: timestamppb.Now(), SessionId: session.GetSession().GetId(), Emitter: "aiscan", + Payload: &aop.Event_Status{Status: &aop.Status{State: "running"}}, }); err != nil { t.Fatal(err) } @@ -149,10 +147,10 @@ func TestWatchEventsResumesAfterCursor(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var deliveries []*aop.EventDelivery - err = NewAOPChatServer(svc).(*aopChatServer).watchEvents(&aop.WatchEventsRequest{ - SessionId: session.ID, AfterCursor: "2", - }, ctx, func(response *aop.WatchEventsResponse) error { - deliveries = append(deliveries, response.Delivery) + err = NewAOPChatServer(svc).watchEvents(&aop.WatchEventsRequest{ + SessionId: session.GetSession().GetId(), AfterCursor: "2", + }, ctx, func(delivery *aop.EventDelivery) error { + deliveries = append(deliveries, delivery) cancel() return nil }) diff --git a/pkg/web/report.go b/pkg/web/report.go index b2b8d74c..5132c7ad 100644 --- a/pkg/web/report.go +++ b/pkg/web/report.go @@ -1,18 +1,232 @@ package web -import "github.com/chainreactors/aiscan/core/output" +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/chainreactors/libcstx/go" +) -// defaultReportLang is the language the report is frozen in at scan time; the -// stored copy is only a fallback because GetReport re-renders per request. const defaultReportLang = "zh" -func buildMarkdownReport(target, mode string, result *output.Result, lang string) string { - return output.RenderReport(result, output.ReportOptions{ - Style: output.StyleMarkdown, - Lang: lang, - Title: target, - Mode: mode, - Sitemap: true, - CollapseBare: true, - }) +type scoReportFacts struct { + ips []*cstx.Ip + ports []*cstx.Port + apps []*cstx.App + urls []*cstx.Url + frameworks []*cstx.Framework + vulns []*cstx.Vuln + other map[string]int +} + +func buildMarkdownReport(target, mode string, rawNodes []json.RawMessage, lang string) string { + facts := collectSCOReportFacts(rawNodes) + if strings.EqualFold(lang, "en") { + return renderSCOReportEN(target, mode, facts) + } + return renderSCOReportZH(target, mode, facts) +} + +func collectSCOReportFacts(rawNodes []json.RawMessage) scoReportFacts { + facts := scoReportFacts{other: make(map[string]int)} + for _, raw := range rawNodes { + node, err := cstx.ParseSCONode(raw) + if err != nil || node == nil { + continue + } + switch value := node.(type) { + case *cstx.Ip: + facts.ips = append(facts.ips, value) + case *cstx.Port: + facts.ports = append(facts.ports, value) + case *cstx.App: + facts.apps = append(facts.apps, value) + case *cstx.Url: + facts.urls = append(facts.urls, value) + case *cstx.Framework: + facts.frameworks = append(facts.frameworks, value) + case *cstx.Vuln: + facts.vulns = append(facts.vulns, value) + default: + facts.other[node.CstxType()]++ + } + } + sort.Slice(facts.ips, func(i, j int) bool { return facts.ips[i].CstxID() < facts.ips[j].CstxID() }) + sort.Slice(facts.ports, func(i, j int) bool { return facts.ports[i].CstxID() < facts.ports[j].CstxID() }) + sort.Slice(facts.apps, func(i, j int) bool { return facts.apps[i].CstxID() < facts.apps[j].CstxID() }) + sort.Slice(facts.urls, func(i, j int) bool { return facts.urls[i].CstxID() < facts.urls[j].CstxID() }) + sort.Slice(facts.frameworks, func(i, j int) bool { return facts.frameworks[i].CstxID() < facts.frameworks[j].CstxID() }) + sort.Slice(facts.vulns, func(i, j int) bool { return facts.vulns[i].CstxID() < facts.vulns[j].CstxID() }) + return facts +} + +func renderSCOReportZH(target, mode string, facts scoReportFacts) string { + var out strings.Builder + fmt.Fprintf(&out, "# 扫描报告\n\n- 目标:`%s`\n- 模式:%s\n\n", markdownInline(target), scanModeLabel(mode, false)) + writeSCOOverview(&out, facts, false) + writeSCOSections(&out, facts, false) + return out.String() +} + +func renderSCOReportEN(target, mode string, facts scoReportFacts) string { + var out strings.Builder + fmt.Fprintf(&out, "# Scan Report\n\n- Target: `%s`\n- Mode: %s\n\n", markdownInline(target), scanModeLabel(mode, true)) + writeSCOOverview(&out, facts, true) + writeSCOSections(&out, facts, true) + return out.String() +} + +func writeSCOOverview(out *strings.Builder, facts scoReportFacts, english bool) { + title, typeLabel, countLabel := "## 概览", "类型", "数量" + labels := []string{"IP", "端口", "应用", "URL", "框架", "漏洞"} + if english { + title, typeLabel, countLabel = "## Overview", "Type", "Count" + labels = []string{"IP", "Port", "App", "URL", "Framework", "Vulnerability"} + } + fmt.Fprintf(out, "%s\n\n| %s | %s |\n|---|---:|\n", title, typeLabel, countLabel) + counts := []int{len(facts.ips), len(facts.ports), len(facts.apps), len(facts.urls), len(facts.frameworks), len(facts.vulns)} + for i, label := range labels { + fmt.Fprintf(out, "| %s | %d |\n", label, counts[i]) + } + otherTypes := make([]string, 0, len(facts.other)) + for nodeType := range facts.other { + otherTypes = append(otherTypes, nodeType) + } + sort.Strings(otherTypes) + for _, nodeType := range otherTypes { + fmt.Fprintf(out, "| `%s` | %d |\n", markdownInline(nodeType), facts.other[nodeType]) + } + out.WriteString("\n") +} + +func writeSCOSections(out *strings.Builder, facts scoReportFacts, english bool) { + if len(facts.ips)+len(facts.ports)+len(facts.apps)+len(facts.urls)+len(facts.frameworks)+len(facts.vulns) == 0 && len(facts.other) == 0 { + if english { + out.WriteString("No SCO facts were emitted.\n") + } else { + out.WriteString("本次扫描未产生 SCO 事实。\n") + } + return + } + if len(facts.ips) > 0 { + writeSectionTitle(out, "IP", "IP", english) + for _, value := range facts.ips { + fmt.Fprintf(out, "- `%s`\n", markdownInline(value.Ip)) + } + out.WriteString("\n") + } + if len(facts.ports) > 0 { + writeSectionTitle(out, "端口", "Ports", english) + for _, value := range facts.ports { + fmt.Fprintf(out, "- `%s:%s/%s`\n", markdownInline(value.Ip), markdownInline(value.Port), markdownInline(value.Protocol)) + } + out.WriteString("\n") + } + if len(facts.apps) > 0 { + writeSectionTitle(out, "应用", "Applications", english) + for _, value := range facts.apps { + label := firstReportValue(value.Title, value.AppId, value.Url, value.CstxID()) + fmt.Fprintf(out, "- **%s**", markdownInline(label)) + if value.Url != "" { + fmt.Fprintf(out, " — `%s`", markdownInline(value.Url)) + } + if value.StatusCode != 0 { + fmt.Fprintf(out, " — HTTP %d", value.StatusCode) + } + out.WriteString("\n") + } + out.WriteString("\n") + } + if len(facts.urls) > 0 { + writeSectionTitle(out, "WEB", "Web", english) + for _, value := range facts.urls { + url := value.Scheme + "://" + value.Host + if value.Port != "" { + url += ":" + value.Port + } + url += value.Path + fmt.Fprintf(out, "- `%s`", markdownInline(url)) + if value.StatusCode != 0 { + fmt.Fprintf(out, " — HTTP %d", value.StatusCode) + } + if value.Title != "" { + fmt.Fprintf(out, " — %s", markdownInline(value.Title)) + } + out.WriteString("\n") + } + out.WriteString("\n") + } + if len(facts.frameworks) > 0 { + writeSectionTitle(out, "框架", "Frameworks", english) + for _, value := range facts.frameworks { + name := firstReportValue(value.Name, value.Product, value.CstxID()) + if value.Version != "" { + name += " " + value.Version + } + fmt.Fprintf(out, "- %s\n", markdownInline(name)) + } + out.WriteString("\n") + } + if len(facts.vulns) > 0 { + writeSectionTitle(out, "漏洞", "Vulnerabilities", english) + for _, value := range facts.vulns { + name := firstReportValue(value.Name, value.VulnId, value.Value, value.CstxID()) + fmt.Fprintf(out, "- **%s**", markdownInline(name)) + if value.Severity != "" { + fmt.Fprintf(out, " — `%s`", markdownInline(value.Severity)) + } + if value.Url != "" { + fmt.Fprintf(out, " — `%s`", markdownInline(value.Url)) + } + out.WriteString("\n") + } + out.WriteString("\n") + } +} + +func writeSectionTitle(out *strings.Builder, zh, en string, english bool) { + if english { + fmt.Fprintf(out, "## %s\n\n", en) + return + } + fmt.Fprintf(out, "## %s\n\n", zh) +} + +func scanModeLabel(mode string, english bool) string { + if english { + switch mode { + case "quick": + return "Quick" + case "full": + return "Full" + default: + return markdownInline(mode) + } + } + switch mode { + case "quick": + return "快速" + case "full": + return "完整" + default: + return markdownInline(mode) + } +} + +func firstReportValue(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "-" +} + +func markdownInline(value string) string { + value = strings.ReplaceAll(value, "\r", " ") + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "|", "\\|") + return strings.TrimSpace(value) } diff --git a/pkg/web/scan_connect.go b/pkg/web/scan_connect.go index 6d2bd0ac..84ae08b8 100644 --- a/pkg/web/scan_connect.go +++ b/pkg/web/scan_connect.go @@ -4,8 +4,8 @@ import ( "context" "connectrpc.com/connect" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" - "github.com/chainreactors/aiscan/aop/aiscan/scan/scanconnect" + "github.com/chainreactors/aiscan/pkg/rpc/scan/scanconnect" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" ) type connectScanServer struct { @@ -49,10 +49,6 @@ func (s *connectScanServer) CancelScan(ctx context.Context, req *connect.Request return connect.NewResponse(response), nil } -func (s *connectScanServer) WatchScanEvents(ctx context.Context, req *connect.Request[scanpb.WatchScanEventsRequest], stream *connect.ServerStream[scanpb.WatchScanEventsResponse]) error { - return asConnectScanError(s.core.WatchScanEvents(req.Msg, ctx, stream.Send)) -} - func (s *connectScanServer) GetScanReport(ctx context.Context, req *connect.Request[scanpb.GetScanReportRequest]) (*connect.Response[scanpb.GetScanReportResponse], error) { response, err := s.core.GetScanReport(ctx, req.Msg) if err != nil { diff --git a/pkg/web/scan_grpc.go b/pkg/web/scan_grpc.go deleted file mode 100644 index 8821427b..00000000 --- a/pkg/web/scan_grpc.go +++ /dev/null @@ -1,44 +0,0 @@ -package web - -import ( - "context" - - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" -) - -type grpcScanServer struct { - scanpb.UnimplementedScanServiceServer - core *scanServiceCore -} - -func newGRPCScanServer(service *Service) scanpb.ScanServiceServer { - return &grpcScanServer{core: newScanServiceCore(service)} -} - -func (s *grpcScanServer) SubmitScan(ctx context.Context, req *scanpb.SubmitScanRequest) (*scanpb.SubmitScanResponse, error) { - return s.core.SubmitScan(ctx, req) -} - -func (s *grpcScanServer) GetScan(ctx context.Context, req *scanpb.GetScanRequest) (*scanpb.GetScanResponse, error) { - return s.core.GetScan(ctx, req) -} - -func (s *grpcScanServer) ListScans(ctx context.Context, req *scanpb.ListScansRequest) (*scanpb.ListScansResponse, error) { - return s.core.ListScans(ctx, req) -} - -func (s *grpcScanServer) CancelScan(ctx context.Context, req *scanpb.CancelScanRequest) (*scanpb.CancelScanResponse, error) { - return s.core.CancelScan(ctx, req) -} - -func (s *grpcScanServer) WatchScanEvents(req *scanpb.WatchScanEventsRequest, stream scanpb.ScanService_WatchScanEventsServer) error { - return s.core.WatchScanEvents(req, stream.Context(), func(response *scanpb.WatchScanEventsResponse) error { - return stream.Send(response) - }) -} - -func (s *grpcScanServer) GetScanReport(ctx context.Context, req *scanpb.GetScanReportRequest) (*scanpb.GetScanReportResponse, error) { - return s.core.GetScanReport(ctx, req) -} - -var _ scanpb.ScanServiceServer = (*grpcScanServer)(nil) diff --git a/pkg/web/scan_lifecycle_test.go b/pkg/web/scan_lifecycle_test.go index 20de65eb..bd3ae2b6 100644 --- a/pkg/web/scan_lifecycle_test.go +++ b/pkg/web/scan_lifecycle_test.go @@ -2,7 +2,6 @@ package web import ( "context" - "encoding/json" "net/http" "net/http/httptest" "path/filepath" @@ -11,23 +10,23 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" - "github.com/chainreactors/aiscan/core/output" + toolpb "github.com/chainreactors/aiscan/aop/tool" + reloadpb "github.com/chainreactors/aiscan/pkg/types/reload" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" ) -func waitScanStatus(t *testing.T, store *SQLiteStore, id string, want ScanStatus) *ScanJob { +func waitScanStatus(t *testing.T, store *SQLiteStore, id string, want scanpb.ScanStatus) *scanpb.Scan { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - job, err := store.Get(context.Background(), id) - if err == nil && job.Status == want { - return job + scan, err := store.Get(context.Background(), id) + if err == nil && scan.Status == want { + return scan } time.Sleep(10 * time.Millisecond) } - job, err := store.Get(context.Background(), id) - t.Fatalf("scan %s status = %+v, err = %v; want %s", id, job, err, want) + scan, err := store.Get(context.Background(), id) + t.Fatalf("scan %s status = %+v, err = %v; want %s", id, scan, err, want) return nil } @@ -42,32 +41,35 @@ func TestCancelRemoteScanStopsAgentAndPreservesCanceledStatus(t *testing.T) { pool := NewAgentPool(svc.Hub()) svc.SetAgentPool(pool) - srv, _ := setupTestServerWithPool(t, pool) + srv, _ := setupTestServerWithPool(t, svc, pool) conn := dialAgent(t, srv, "scan-agent", []string{"scan"}) t.Cleanup(func() { _ = conn.Close() }) waitAgents(t, pool, 1) - job, err := svc.SubmitScan(context.Background(), "127.0.0.1", "quick", false, false, false) + scan, err := svc.SubmitScan(context.Background(), "127.0.0.1", "quick", false, false, false) if err != nil { t.Fatal(err) } - call := readServerFrame(t, conn) - if call.GetToolCall().GetTaskId() != job.ID { - t.Fatalf("scan dispatch = %+v", call) + callEnvelope := readHubEnvelope(t, conn) + if callEnvelope.GetId() != scan.Id { + t.Fatalf("scan dispatch = %+v", callEnvelope) + } + if message := unwrapEnvelope(t, callEnvelope); message.(*toolpb.ProtocolMessage).GetCall() == nil { + t.Fatalf("scan dispatch = %+v", message) } - waitScanStatus(t, store, job.ID, StatusRunning) + waitScanStatus(t, store, scan.Id, scanpb.ScanStatus_SCAN_STATUS_RUNNING) - if err := svc.CancelScan(job.ID); err != nil { + if err := svc.CancelScan(scan.Id); err != nil { t.Fatal(err) } _ = conn.SetReadDeadline(time.Now().Add(time.Second)) - cancel := readServerFrame(t, conn) - if cancel.GetCancelOperation().GetTaskId() != job.ID { - t.Fatalf("cancel frame = %+v", cancel) + cancel := unwrapEnvelope(t, readHubEnvelope(t, conn)) + if cancel.(*aop.ProtocolMessage).GetCancelOperation().GetTargetId() != scan.Id { + t.Fatalf("cancel envelope = %+v", cancel) } - waitScanStatus(t, store, job.ID, StatusCanceled) + waitScanStatus(t, store, scan.Id, scanpb.ScanStatus_SCAN_STATUS_CANCELED) deadline := time.Now().Add(time.Second) for len(svc.sem) != 0 && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) @@ -77,13 +79,12 @@ func TestCancelRemoteScanStopsAgentAndPreservesCanceledStatus(t *testing.T) { } // A result that races with cancellation must not resurrect the scan. - resultJSON, _ := aop.JSONValue(&output.Result{}) - pool.handleAgentFrame(pool.Pick(), &transport.AgentFrame{CorrelationId: job.ID, Payload: &transport.AgentFrame_Event{Event: &aop.Event{ - SessionId: job.ID, TurnId: job.ID, Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{CallId: job.ID, Detail: resultJSON}}, - }}}) + pool.handleAgentEnvelope(pool.Pick(), wrapMessage(t, generateID(), scan.Id, &aop.ProtocolMessage{Message: &aop.ProtocolMessage_Event{Event: &aop.Event{ + SessionId: scan.Id, TurnId: scan.Id, Payload: &aop.Event_ToolResult{ToolResult: &aop.ToolResult{CallId: scan.Id}}, + }}})) time.Sleep(20 * time.Millisecond) - if got, err := store.Get(context.Background(), job.ID); err != nil || got.Status != StatusCanceled { - t.Fatalf("late result changed canceled scan: job=%+v err=%v", got, err) + if got, err := store.Get(context.Background(), scan.Id); err != nil || got.Status != scanpb.ScanStatus_SCAN_STATUS_CANCELED { + t.Fatalf("late result changed canceled scan: scan=%+v err=%v", got, err) } } @@ -97,7 +98,7 @@ func TestCancelQueuedScanDoesNotWaitForConcurrencySlot(t *testing.T) { svc := NewService(ServiceConfig{Store: store, MaxConcurrent: 1, ScanTimeout: time.Minute}) pool := NewAgentPool(svc.Hub()) svc.SetAgentPool(pool) - srv, _ := setupTestServerWithPool(t, pool) + srv, _ := setupTestServerWithPool(t, svc, pool) conn := dialAgent(t, srv, "queue-agent", []string{"scan"}) t.Cleanup(func() { _ = conn.Close() }) waitAgents(t, pool, 1) @@ -106,25 +107,25 @@ func TestCancelQueuedScanDoesNotWaitForConcurrencySlot(t *testing.T) { if err != nil { t.Fatal(err) } - _ = readServerFrame(t, conn) - waitScanStatus(t, store, running.ID, StatusRunning) + _ = readHubEnvelope(t, conn) + waitScanStatus(t, store, running.Id, scanpb.ScanStatus_SCAN_STATUS_RUNNING) queued, err := svc.SubmitScan(context.Background(), "127.0.0.2", "quick", false, false, false) if err != nil { t.Fatal(err) } - waitScanStatus(t, store, queued.ID, StatusQueued) - if err := svc.CancelScan(queued.ID); err != nil { + waitScanStatus(t, store, queued.Id, scanpb.ScanStatus_SCAN_STATUS_QUEUED) + if err := svc.CancelScan(queued.Id); err != nil { t.Fatal(err) } - waitScanStatus(t, store, queued.ID, StatusCanceled) + waitScanStatus(t, store, queued.Id, scanpb.ScanStatus_SCAN_STATUS_CANCELED) - if err := svc.CancelScan(running.ID); err != nil { + if err := svc.CancelScan(running.Id); err != nil { t.Fatal(err) } _ = conn.SetReadDeadline(time.Now().Add(time.Second)) - _ = readServerFrame(t, conn) - waitScanStatus(t, store, running.ID, StatusCanceled) + _ = readHubEnvelope(t, conn) + waitScanStatus(t, store, running.Id, scanpb.ScanStatus_SCAN_STATUS_CANCELED) } type controlledDeadlineContext struct { @@ -162,49 +163,52 @@ func TestRemoteScanTimeoutCancelsAgentAndFailsScan(t *testing.T) { agent := newFakeAgent("timeout-agent", 1) pool.register(agent) - now := time.Now() - job := &ScanJob{ - ID: "timeout-scan", Target: "127.0.0.1", Mode: "quick", - Status: StatusRunning, CreatedAt: now, UpdatedAt: now, + scan := &scanpb.Scan{ + Id: "timeout-scan", Target: "127.0.0.1", Mode: "quick", + Status: scanpb.ScanStatus_SCAN_STATUS_RUNNING, CreatedAt: nowProto(), UpdatedAt: nowProto(), } - if err := store.Create(context.Background(), job); err != nil { + if err := store.Create(context.Background(), scan); err != nil { t.Fatal(err) } - jobID := job.ID + scanID := scan.Id ctx := newControlledDeadlineContext() done := make(chan struct{}) go func() { - svc.runScanViaAgent(ctx, job) + svc.runScanViaAgent(ctx, scan) close(done) }() - var call *transport.ServerFrame + var call *aop.Envelope select { case call = <-agent.sendCh: case <-time.After(time.Second): t.Fatal("agent did not receive scan dispatch") } - if call.GetToolCall().GetTaskId() != jobID { + if call.GetId() != scanID { t.Fatalf("scan dispatch = %+v", call) } ctx.expire() - var cancel *transport.ServerFrame + var cancel *aop.Envelope select { - case cancel = <-agent.controlCh: + case cancel = <-agent.sendCh: case <-time.After(time.Second): t.Fatal("agent did not receive timeout cancellation") } - if cancel.GetCancelOperation().GetTaskId() != jobID { - t.Fatalf("timeout cancel frame = %+v", cancel) + cancelMessage, err := aop.Unwrap(cancel) + if err != nil { + t.Fatal(err) + } + if cancelMessage.(*aop.ProtocolMessage).GetCancelOperation().GetTargetId() != scanID { + t.Fatalf("timeout cancel envelope = %+v", cancelMessage) } select { case <-done: case <-time.After(time.Second): t.Fatal("timed-out remote scan did not return") } - failed := waitScanStatus(t, store, jobID, StatusFailed) + failed := waitScanStatus(t, store, scanID, scanpb.ScanStatus_SCAN_STATUS_FAILED) if failed.Error != "scan timed out" { t.Fatalf("timeout error = %q", failed.Error) } @@ -223,19 +227,18 @@ func TestRemoteScanExpiredBeforeDispatchFailsScan(t *testing.T) { agent := newFakeAgent("timeout-agent", 1) pool.register(agent) - now := time.Now() - job := &ScanJob{ - ID: "expired-scan", Target: "127.0.0.1", Mode: "quick", - Status: StatusRunning, CreatedAt: now, UpdatedAt: now, + scan := &scanpb.Scan{ + Id: "expired-scan", Target: "127.0.0.1", Mode: "quick", + Status: scanpb.ScanStatus_SCAN_STATUS_RUNNING, CreatedAt: nowProto(), UpdatedAt: nowProto(), } - if err := store.Create(context.Background(), job); err != nil { + if err := store.Create(context.Background(), scan); err != nil { t.Fatal(err) } ctx := newControlledDeadlineContext() ctx.expire() - svc.runScanViaAgent(ctx, job) + svc.runScanViaAgent(ctx, scan) - failed := waitScanStatus(t, store, job.ID, StatusFailed) + failed := waitScanStatus(t, store, scan.Id, scanpb.ScanStatus_SCAN_STATUS_FAILED) if failed.Error != "scan timed out" { t.Fatalf("timeout error = %q", failed.Error) } @@ -246,48 +249,63 @@ func TestRemoteScanExpiredBeforeDispatchFailsScan(t *testing.T) { } } -func setupTestServerWithPool(t *testing.T, pool *AgentPool) (*httptest.Server, *AgentPool) { +func setupTestServerWithPool(t *testing.T, svc *Service, pool *AgentPool) (*httptest.Server, *AgentPool) { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/api/agent/ws", pool.HandleWS) + mux.HandleFunc("/api/aop/ws", func(w http.ResponseWriter, r *http.Request) { + HandleAOPWebSocket(svc, pool, w, r) + }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv, pool } -func TestCancelTaskUsesControlChannelWhenTaskQueueIsFull(t *testing.T) { +func TestCancelTaskQueuesBehindFullSendChannel(t *testing.T) { pool := NewAgentPool(NewHub()) remote := newFakeAgent("agent-1", 1) remote.toolCalls = map[string]struct{}{"scan-1": {}} remote.tasks["scan-1"] = make(chan taskResult, 1) - remote.sendCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_Exec{Exec: &transport.ExecRequest{TaskId: "busy"}}} + remote.sendCh <- aop.MustWrap("busy", "", &reloadpb.ProtocolMessage{Message: &reloadpb.ProtocolMessage_Request{Request: &reloadpb.Request{}}}) // saturate the buffer pool.agents[remote.id] = remote - if err := pool.CancelTask(remote.id, "scan-1"); err != nil { + canceled := make(chan error, 1) + go func() { canceled <- pool.CancelTask(remote.id, "scan-1") }() + select { + case <-canceled: + t.Fatal("cancellation bypassed the full send channel") + case <-time.After(50 * time.Millisecond): + } + if first := <-remote.sendCh; first.GetId() != "busy" { + t.Fatalf("first envelope = %+v", first) + } + if err := <-canceled; err != nil { t.Fatal(err) } select { - case msg := <-remote.controlCh: - if msg.GetCancelOperation().GetTaskId() != "scan-1" { - t.Fatalf("control cancellation = %+v", msg) + case envelope := <-remote.sendCh: + message, err := aop.Unwrap(envelope) + if err != nil { + t.Fatal(err) } - default: - t.Fatal("cancellation was not queued on the control channel") + if message.(*aop.ProtocolMessage).GetCancelOperation().GetTargetId() != "scan-1" { + t.Fatalf("queued cancellation = %+v", message) + } + case <-time.After(time.Second): + t.Fatal("cancellation was not queued on the send channel") } } -func TestCancelTaskWaitsForSaturatedControlChannel(t *testing.T) { +func TestCancelTaskWaitsForSaturatedSendChannel(t *testing.T) { pool := NewAgentPool(NewHub()) remote := newFakeAgent("agent-1", 1) remote.toolCalls = map[string]struct{}{"scan-1": {}} resultCh := make(chan taskResult, 1) remote.tasks["scan-1"] = resultCh - remote.controlCh <- &transport.ServerFrame{Payload: &transport.ServerFrame_ReloadConfig{ReloadConfig: &transport.ReloadConfig{}}} + remote.sendCh <- aop.MustWrap("reload", "", &reloadpb.ProtocolMessage{Message: &reloadpb.ProtocolMessage_Request{Request: &reloadpb.Request{}}}) pool.agents[remote.id] = remote - if err := pool.CancelTask(remote.id, "scan-1"); err != nil { - t.Fatal(err) - } + canceled := make(chan error, 1) + go func() { canceled <- pool.CancelTask(remote.id, "scan-1") }() select { case _, ok := <-resultCh: if ok { @@ -297,64 +315,48 @@ func TestCancelTaskWaitsForSaturatedControlChannel(t *testing.T) { t.Fatal("cancellation did not converge the pending task") } - <-remote.controlCh + <-remote.sendCh // drain the reload so the cancel can enqueue + if err := <-canceled; err != nil { + t.Fatal(err) + } select { - case msg := <-remote.controlCh: - if msg.GetCancelOperation().GetTaskId() != "scan-1" { - t.Fatalf("queued cancellation = %+v", msg) + case envelope := <-remote.sendCh: + message, err := aop.Unwrap(envelope) + if err != nil { + t.Fatal(err) + } + if message.(*aop.ProtocolMessage).GetCancelOperation().GetTargetId() != "scan-1" { + t.Fatalf("queued cancellation = %+v", message) } case <-time.After(time.Second): - t.Fatal("cancellation was dropped under control-channel backpressure") - } -} - -func TestDecodeScanResultRejectsInvalidEnvelopes(t *testing.T) { - for _, tc := range []struct { - name string - raw json.RawMessage - }{ - {name: "empty"}, - {name: "null", raw: json.RawMessage("null")}, - {name: "malformed", raw: json.RawMessage("{")}, - } { - t.Run(tc.name, func(t *testing.T) { - if _, err := decodeScanResult(tc.raw); err == nil { - t.Fatal("decodeScanResult() accepted an invalid result") - } - }) - } - - result, err := decodeScanResult(json.RawMessage("{}")) - if err != nil || result == nil { - t.Fatalf("decodeScanResult({}) = %+v, %v", result, err) + t.Fatal("cancellation was dropped under send-channel backpressure") } } -func TestCompleteJobCannotOverwriteCanceledScan(t *testing.T) { +func TestCompleteScanCannotOverwriteCanceledScan(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) - now := time.Now() - job := &ScanJob{ID: "scan-canceled", Target: "127.0.0.1", Mode: "quick", Status: StatusCanceled, CreatedAt: now, UpdatedAt: now} - if err := store.Create(context.Background(), job); err != nil { + scan := &scanpb.Scan{Id: "scan-canceled", Target: "127.0.0.1", Mode: "quick", Status: scanpb.ScanStatus_SCAN_STATUS_CANCELED, CreatedAt: nowProto(), UpdatedAt: nowProto()} + if err := store.Create(context.Background(), scan); err != nil { t.Fatal(err) } svc := NewService(ServiceConfig{Store: store}) - changed, err := svc.completeJob(context.Background(), job, "", &output.Result{}) + changed, err := svc.completeScan(context.Background(), scan) if err != nil { t.Fatal(err) } if changed { - t.Fatal("completeJob() completed a canceled scan") + t.Fatal("completeScan() completed a canceled scan") } - stored, err := store.Get(context.Background(), job.ID) + stored, err := store.Get(context.Background(), scan.Id) if err != nil { t.Fatal(err) } - if stored.Status != StatusCanceled || strings.TrimSpace(stored.Report) != "" { + if stored.Status != scanpb.ScanStatus_SCAN_STATUS_CANCELED || strings.TrimSpace(stored.Report) != "" { t.Fatalf("canceled scan was mutated: %+v", stored) } } @@ -366,21 +368,20 @@ func TestCancelCompletedScanReturnsConflictAndPreservesStatus(t *testing.T) { } t.Cleanup(func() { _ = store.Close() }) - now := time.Now() - job := &ScanJob{ - ID: "scan-completed", + scan := &scanpb.Scan{ + Id: "scan-completed", Target: "127.0.0.1", Mode: "quick", - Status: StatusCompleted, - CreatedAt: now, - UpdatedAt: now, + Status: scanpb.ScanStatus_SCAN_STATUS_COMPLETED, + CreatedAt: nowProto(), + UpdatedAt: nowProto(), } - if err := store.Create(context.Background(), job); err != nil { + if err := store.Create(context.Background(), scan); err != nil { t.Fatal(err) } response, err := newScanServiceCore(NewService(ServiceConfig{Store: store})).CancelScan(context.Background(), &scanpb.CancelScanRequest{ - RequestId: "cancel-completed", ScanId: job.ID, + RequestId: "cancel-completed", ScanId: scan.Id, }) if err != nil { t.Fatal(err) @@ -388,12 +389,12 @@ func TestCancelCompletedScanReturnsConflictAndPreservesStatus(t *testing.T) { if response.GetRejected().GetCode() != "FAILED_PRECONDITION" { t.Fatalf("CancelScan rejection = %+v; want FAILED_PRECONDITION", response.GetRejected()) } - stored, err := store.Get(context.Background(), job.ID) + stored, err := store.Get(context.Background(), scan.Id) if err != nil { t.Fatal(err) } - if stored.Status != StatusCompleted { - t.Fatalf("completed scan status = %s; want %s", stored.Status, StatusCompleted) + if stored.Status != scanpb.ScanStatus_SCAN_STATUS_COMPLETED { + t.Fatalf("completed scan status = %s; want COMPLETED", stored.Status) } } diff --git a/pkg/web/scan_rpc.go b/pkg/web/scan_rpc.go index eb80a53c..5c8fe245 100644 --- a/pkg/web/scan_rpc.go +++ b/pkg/web/scan_rpc.go @@ -8,13 +8,16 @@ import ( "strings" "connectrpc.com/connect" - aop "github.com/chainreactors/aiscan/aop" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" "google.golang.org/protobuf/types/known/timestamppb" ) +var ( + errInvalidScanRequest = errors.New("invalid scan request") + errScanServiceUnavailable = errors.New("scan service is unavailable") + errScanReportNotReady = errors.New("scan report is not ready") +) + type scanServiceCore struct { service *Service } @@ -25,78 +28,74 @@ func newScanServiceCore(service *Service) *scanServiceCore { func (s *scanServiceCore) SubmitScan(ctx context.Context, request *scanpb.SubmitScanRequest) (*scanpb.SubmitScanResponse, error) { if s.service == nil || request == nil || strings.TrimSpace(request.RequestId) == "" { - return rejectedSubmitScan(request, codes.InvalidArgument, "request_id is required"), nil + return rejectedSubmitScan(request, "INVALID_ARGUMENT", "request_id is required"), nil } options := request.GetOptions() - job, err := s.service.SubmitScan(ctx, request.Target, request.Mode, options.GetVerify(), options.GetSniper(), options.GetDeep()) + scan, err := s.service.SubmitScan(ctx, request.Target, request.Mode, options.GetVerify(), options.GetSniper(), options.GetDeep()) if err != nil { - return rejectedSubmitScan(request, codes.InvalidArgument, err.Error()), nil + return rejectedSubmitScan(request, "INVALID_ARGUMENT", err.Error()), nil } - return &scanpb.SubmitScanResponse{RequestId: request.RequestId, Outcome: &scanpb.SubmitScanResponse_Accepted{Accepted: scanToProto(job)}}, nil + return &scanpb.SubmitScanResponse{RequestId: request.RequestId, Outcome: &scanpb.SubmitScanResponse_Accepted{Accepted: scan}}, nil } func (s *scanServiceCore) GetScan(ctx context.Context, request *scanpb.GetScanRequest) (*scanpb.GetScanResponse, error) { if s.service == nil || request == nil || strings.TrimSpace(request.ScanId) == "" { - return nil, status.Error(codes.InvalidArgument, "scan_id is required") + return nil, fmt.Errorf("%w: scan_id is required", errInvalidScanRequest) } - job, err := s.service.GetScan(ctx, request.ScanId) + scan, err := s.service.GetScan(ctx, request.ScanId) if err != nil { return nil, scanRPCError(err) } - return &scanpb.GetScanResponse{Scan: scanToProto(job)}, nil + return &scanpb.GetScanResponse{Scan: scan}, nil } func (s *scanServiceCore) ListScans(ctx context.Context, _ *scanpb.ListScansRequest) (*scanpb.ListScansResponse, error) { if s.service == nil { - return nil, status.Error(codes.Unavailable, "scan service is unavailable") + return nil, errScanServiceUnavailable } - jobs, err := s.service.ListScans(ctx) + scans, err := s.service.ListScans(ctx) if err != nil { - return nil, status.Error(codes.Internal, err.Error()) + return nil, fmt.Errorf("list scans: %w", err) } - response := &scanpb.ListScansResponse{Scans: make([]*scanpb.Scan, 0, len(jobs))} - for _, job := range jobs { - response.Scans = append(response.Scans, scanToProto(job)) - } - return response, nil + return &scanpb.ListScansResponse{Scans: scans}, nil } func (s *scanServiceCore) CancelScan(ctx context.Context, request *scanpb.CancelScanRequest) (*scanpb.CancelScanResponse, error) { if s.service == nil || request == nil || strings.TrimSpace(request.RequestId) == "" || strings.TrimSpace(request.ScanId) == "" { - return rejectedCancelScan(request, codes.InvalidArgument, "request_id and scan_id are required"), nil + return rejectedCancelScan(request, "INVALID_ARGUMENT", "request_id and scan_id are required"), nil } if err := s.service.CancelScan(request.ScanId); err != nil { - code := codes.FailedPrecondition + code := "FAILED_PRECONDITION" if errors.Is(err, ErrScanNotFound) { - code = codes.NotFound + code = "NOT_FOUND" } return rejectedCancelScan(request, code, err.Error()), nil } - job, err := s.service.GetScan(ctx, request.ScanId) + scan, err := s.service.GetScan(ctx, request.ScanId) if err != nil { return nil, scanRPCError(err) } - return &scanpb.CancelScanResponse{RequestId: request.RequestId, Outcome: &scanpb.CancelScanResponse_Accepted{Accepted: scanToProto(job)}}, nil + return &scanpb.CancelScanResponse{RequestId: request.RequestId, Outcome: &scanpb.CancelScanResponse_Accepted{Accepted: scan}}, nil } -func (s *scanServiceCore) WatchScanEvents(request *scanpb.WatchScanEventsRequest, ctx context.Context, send func(*scanpb.WatchScanEventsResponse) error) error { +func (s *scanServiceCore) WatchScanEvents(request *scanpb.WatchScanEventsRequest, ctx context.Context, send func(*scanpb.ScanEvent) error) error { if s.service == nil || request == nil || strings.TrimSpace(request.ScanId) == "" { - return status.Error(codes.InvalidArgument, "scan_id is required") + return fmt.Errorf("%w: scan_id is required", errInvalidScanRequest) } if send == nil { - return status.Error(codes.Internal, "scan event sender is unavailable") + return errors.New("scan event sender is unavailable") } live, snapshotSequence, unsubscribe := s.service.hub.SubscribeScan(request.ScanId) defer unsubscribe() - job, err := s.service.GetScan(ctx, request.ScanId) + scan, err := s.service.GetScan(ctx, request.ScanId) if err != nil { return scanRPCError(err) } - snapshot := scanSnapshot(job, snapshotSequence) - if err := send(&scanpb.WatchScanEventsResponse{Event: snapshot}); err != nil { + snapshot := scanSnapshot(scan, snapshotSequence) + if err := send(snapshot); err != nil { return err } - if scanTerminal(job.Status) { + if scanTerminal(scan.Status) { return nil } last := snapshot.Sequence @@ -111,7 +110,7 @@ func (s *scanServiceCore) WatchScanEvents(request *scanpb.WatchScanEventsRequest if event == nil || event.Sequence <= last { continue } - if err := send(&scanpb.WatchScanEventsResponse{Event: event}); err != nil { + if err := send(event); err != nil { return err } last = event.Sequence @@ -124,78 +123,39 @@ func (s *scanServiceCore) WatchScanEvents(request *scanpb.WatchScanEventsRequest func (s *scanServiceCore) GetScanReport(ctx context.Context, request *scanpb.GetScanReportRequest) (*scanpb.GetScanReportResponse, error) { if s.service == nil || request == nil || strings.TrimSpace(request.ScanId) == "" { - return nil, status.Error(codes.InvalidArgument, "scan_id is required") + return nil, fmt.Errorf("%w: scan_id is required", errInvalidScanRequest) } markdown, err := s.service.GetReport(ctx, request.ScanId, request.Language) if err != nil { return nil, scanRPCError(err) } if markdown == "" { - return nil, status.Error(codes.FailedPrecondition, "scan report is not ready") + return nil, errScanReportNotReady } return &scanpb.GetScanReportResponse{Markdown: markdown, MediaType: "text/markdown; charset=utf-8"}, nil } -func scanToProto(job *ScanJob) *scanpb.Scan { - if job == nil { - return nil - } - var result *aop.EncodedValue - if job.Result != nil { - result, _ = aop.JSONValue(job.Result) - } - return &scanpb.Scan{ - Id: job.ID, Target: job.Target, Mode: job.Mode, - Options: &scanpb.ScanOptions{Verify: job.Verify, Sniper: job.Sniper, Deep: job.Deep}, - Status: scanStatusToProto(job.Status), Progress: job.Progress, Report: job.Report, - Result: result, Error: job.Error, - CreatedAt: timestamppb.New(job.CreatedAt), UpdatedAt: timestamppb.New(job.UpdatedAt), - } -} - -func scanStatusToProto(value ScanStatus) scanpb.ScanStatus { - switch value { - case StatusQueued: - return scanpb.ScanStatus_SCAN_STATUS_QUEUED - case StatusRunning: - return scanpb.ScanStatus_SCAN_STATUS_RUNNING - case StatusCompleted: - return scanpb.ScanStatus_SCAN_STATUS_COMPLETED - case StatusFailed: - return scanpb.ScanStatus_SCAN_STATUS_FAILED - case StatusCanceled: - return scanpb.ScanStatus_SCAN_STATUS_CANCELED - default: - return scanpb.ScanStatus_SCAN_STATUS_UNSPECIFIED - } -} - -func scanSnapshot(job *ScanJob, sequence uint64) *scanpb.ScanEvent { - return &scanpb.ScanEvent{ScanId: job.ID, Sequence: sequence, EmittedAt: timestamppb.Now(), Payload: &scanpb.ScanEvent_Snapshot{Snapshot: scanToProto(job)}} +func scanSnapshot(scan *scanpb.Scan, sequence uint64) *scanpb.ScanEvent { + return &scanpb.ScanEvent{ScanId: scan.Id, Sequence: sequence, EmittedAt: timestamppb.Now(), Payload: &scanpb.ScanEvent_Snapshot{Snapshot: scan}} } -func scanStatusEvent(scanID string, value ScanStatus) *scanpb.ScanEvent { - return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Status{Status: scanStatusToProto(value)}} +func scanStatusEvent(scanID string, value scanpb.ScanStatus) *scanpb.ScanEvent { + return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Status{Status: value}} } func scanProgressEvent(scanID, data string) *scanpb.ScanEvent { return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Progress{Progress: &scanpb.ScanProgress{Data: data}}} } -func scanCompletedEvent(scanID string, result any) *scanpb.ScanEvent { - encoded, _ := aop.JSONValue(result) - return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Completed{Completed: &scanpb.ScanCompleted{Result: encoded}}} +func scanCompletedEvent(scanID string) *scanpb.ScanEvent { + return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Completed{Completed: &scanpb.ScanCompleted{}}} } func scanFailedEvent(scanID, message string, canceled bool) *scanpb.ScanEvent { return &scanpb.ScanEvent{ScanId: scanID, Payload: &scanpb.ScanEvent_Failed{Failed: &scanpb.ScanFailed{Message: message, Canceled: canceled}}} } -func scanTerminal(value ScanStatus) bool { - return value == StatusCompleted || value == StatusFailed || value == StatusCanceled -} - -func rejectedSubmitScan(request *scanpb.SubmitScanRequest, code codes.Code, message string) *scanpb.SubmitScanResponse { +func rejectedSubmitScan(request *scanpb.SubmitScanRequest, code, message string) *scanpb.SubmitScanResponse { response := &scanpb.SubmitScanResponse{Outcome: &scanpb.SubmitScanResponse_Rejected{Rejected: rejection(code, message)}} if request != nil { response.RequestId = request.RequestId @@ -203,7 +163,7 @@ func rejectedSubmitScan(request *scanpb.SubmitScanRequest, code codes.Code, mess return response } -func rejectedCancelScan(request *scanpb.CancelScanRequest, code codes.Code, message string) *scanpb.CancelScanResponse { +func rejectedCancelScan(request *scanpb.CancelScanRequest, code, message string) *scanpb.CancelScanResponse { response := &scanpb.CancelScanResponse{Outcome: &scanpb.CancelScanResponse_Rejected{Rejected: rejection(code, message)}} if request != nil { response.RequestId = request.RequestId @@ -214,9 +174,9 @@ func rejectedCancelScan(request *scanpb.CancelScanRequest, code codes.Code, mess func scanRPCError(err error) error { switch { case errors.Is(err, ErrScanNotFound), errors.Is(err, sql.ErrNoRows): - return status.Error(codes.NotFound, ErrScanNotFound.Error()) + return ErrScanNotFound default: - return status.Error(codes.Internal, fmt.Sprint(err)) + return fmt.Errorf("scan service: %w", err) } } @@ -224,8 +184,20 @@ func asConnectScanError(err error) error { if err == nil { return nil } - if grpcStatus, ok := status.FromError(err); ok { - return connect.NewError(connect.Code(grpcStatus.Code()), errors.New(grpcStatus.Message())) - } - return connect.NewError(connect.CodeInternal, err) + code := connect.CodeInternal + switch { + case errors.Is(err, errInvalidScanRequest): + code = connect.CodeInvalidArgument + case errors.Is(err, ErrScanNotFound), errors.Is(err, sql.ErrNoRows): + code = connect.CodeNotFound + case errors.Is(err, errScanServiceUnavailable): + code = connect.CodeUnavailable + case errors.Is(err, errScanReportNotReady): + code = connect.CodeFailedPrecondition + case errors.Is(err, context.Canceled): + code = connect.CodeCanceled + case errors.Is(err, context.DeadlineExceeded): + code = connect.CodeDeadlineExceeded + } + return connect.NewError(code, err) } diff --git a/pkg/web/sco_connect.go b/pkg/web/sco_connect.go new file mode 100644 index 00000000..8be8ea58 --- /dev/null +++ b/pkg/web/sco_connect.go @@ -0,0 +1,106 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "connectrpc.com/connect" + aop "github.com/chainreactors/aiscan/aop" + aopsco "github.com/chainreactors/aiscan/aop/sco" + "github.com/chainreactors/aiscan/pkg/rpc/sco/scoconnect" + scopb "github.com/chainreactors/aiscan/pkg/types/sco" + cstx "github.com/chainreactors/libcstx/go" +) + +type connectSCOServer struct { + scoconnect.UnimplementedSCOServiceHandler + service *Service +} + +func (s *connectSCOServer) ListNodes(ctx context.Context, req *connect.Request[scopb.ListNodesRequest]) (*connect.Response[scopb.ListNodesResponse], error) { + limit := int(req.Msg.GetLimit()) + if limit == 0 { + limit = 500 + } + nodes, err := s.service.store.ListSCONodesByScanID(ctx, req.Msg.GetOperationId(), req.Msg.GetType(), limit) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + encoded := make([][]byte, 0, len(nodes)) + for _, node := range nodes { + encoded = append(encoded, append([]byte(nil), node...)) + } + return connect.NewResponse(&scopb.ListNodesResponse{Nodes: &aopsco.Nodes{Nodes: encoded, MediaType: aop.JSONMediaType}}), nil +} + +func (s *connectSCOServer) GetNode(ctx context.Context, req *connect.Request[scopb.GetNodeRequest]) (*connect.Response[scopb.GetNodeResponse], error) { + node, err := s.service.store.GetSCONode(ctx, req.Msg.GetId()) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return connect.NewResponse(&scopb.GetNodeResponse{Node: node, MediaType: aop.JSONMediaType}), nil +} + +func (s *connectSCOServer) GetStats(ctx context.Context, _ *connect.Request[scopb.GetStatsRequest]) (*connect.Response[scopb.GetStatsResponse], error) { + stats, err := s.service.store.SCONodeStats(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + values := make(map[string]uint64, len(stats)) + for name, count := range stats { + values[name] = uint64(count) + } + return connect.NewResponse(&scopb.GetStatsResponse{Values: values}), nil +} + +func (s *connectSCOServer) DeleteNodes(ctx context.Context, req *connect.Request[scopb.DeleteNodesRequest]) (*connect.Response[scopb.DeleteNodesResponse], error) { + if strings.TrimSpace(req.Msg.GetOperationId()) == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("operation_id is required")) + } + if err := s.service.store.DeleteSCONodesByScan(ctx, req.Msg.GetOperationId()); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&scopb.DeleteNodesResponse{}), nil +} + +func (s *connectSCOServer) ImportNodes(ctx context.Context, req *connect.Request[scopb.ImportNodesRequest]) (*connect.Response[scopb.ImportNodesResponse], error) { + if len(req.Msg.GetData()) > 50<<20 { + return nil, connect.NewError(connect.CodeResourceExhausted, errors.New("import exceeds 50 MiB")) + } + artifact := strings.TrimSpace(req.Msg.GetArtifact()) + if artifact == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("artifact is required")) + } + nodes, err := cstx.Parse(artifact, req.Msg.GetData()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + seen := make(map[string]struct{}, len(nodes)) + raw := make([]json.RawMessage, 0, len(nodes)) + for _, node := range nodes { + if _, ok := seen[node.CstxID()]; ok { + continue + } + seen[node.CstxID()] = struct{}{} + encoded, err := json.Marshal(node) + if err == nil { + raw = append(raw, encoded) + } + } + operationID := strings.TrimSpace(req.Msg.GetOperationId()) + if operationID == "" { + operationID = "import" + } + if err := s.service.store.UpsertSCONodes(ctx, operationID, raw); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&scopb.ImportNodesResponse{Nodes: uint64(len(raw)), Duplicates: uint64(len(nodes) - len(raw)), Artifact: artifact}), nil +} + +func (s *connectSCOServer) ListArtifacts(context.Context, *connect.Request[scopb.ListArtifactsRequest]) (*connect.Response[scopb.ListArtifactsResponse], error) { + return connect.NewResponse(&scopb.ListArtifactsResponse{Artifacts: cstx.SupportedArtifacts()}), nil +} + +var _ scoconnect.SCOServiceHandler = (*connectSCOServer)(nil) diff --git a/pkg/web/service.go b/pkg/web/service.go index bc4e53fc..3b750456 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -6,7 +6,6 @@ import ( "crypto/rand" "database/sql" "encoding/hex" - "encoding/json" "errors" "fmt" "io" @@ -17,28 +16,33 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" - scanpb "github.com/chainreactors/aiscan/aop/aiscan/scan" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + filepb "github.com/chainreactors/aiscan/aop/file" "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/runner" "github.com/chainreactors/aiscan/pkg/tui" - scantool "github.com/chainreactors/aiscan/tools/scan" + chatpb "github.com/chainreactors/aiscan/pkg/types/chat" + commandpb "github.com/chainreactors/aiscan/pkg/types/command" + configpb "github.com/chainreactors/aiscan/pkg/types/config" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" + systempb "github.com/chainreactors/aiscan/pkg/types/system" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) type ConfigStore interface { - GetDistributeConfig(ctx context.Context) (path string, loaded bool, cfg config.DistributeConfig, err error) - PrepareDistributeConfig(ctx context.Context, cfg config.DistributeConfig) (*PreparedConfig, error) + GetDistributeConfig(ctx context.Context) (path string, loaded bool, cfg *configpb.DistributeConfig, err error) + PrepareDistributeConfig(ctx context.Context, cfg *configpb.DistributeConfig) (*PreparedConfig, error) CommitDistributeConfig(ctx context.Context, prepared *PreparedConfig) error DiscardDistributeConfig(prepared *PreparedConfig) } type PreparedConfig struct { - Config config.DistributeConfig + Config *configpb.DistributeConfig RuntimePath string TargetPath string } @@ -121,6 +125,7 @@ func (s *Service) Hub() *Hub { return s.hub } func (s *Service) SetAgentPool(pool *AgentPool) { s.agents = pool pool.SetSessionLookup(s) + pool.config = s.GetDistributeConfig } func (s *Service) Close() { @@ -146,58 +151,59 @@ func (s *Service) Close() { } } -func (s *Service) Status() ServiceStatus { +func (s *Service) Status() *systempb.Status { app, release := s.acquireApp() - status := ServiceStatus{ + status := &systempb.Status{ Version: config.Version, - LLMAvailable: app != nil && app.Provider != nil, + LlmAvailable: app != nil && app.Provider != nil, } if app != nil { - status.LLMProvider = app.ProviderConfig.Provider - status.LLMModel = app.ProviderConfig.Model - status.LLMAPIKeyConfigured = strings.TrimSpace(app.ProviderConfig.APIKey) != "" + status.LlmProvider = app.ProviderConfig.Provider + status.LlmModel = app.ProviderConfig.Model + status.LlmApiKeyConfigured = strings.TrimSpace(app.ProviderConfig.APIKey) != "" } release() if s.config != nil { if path, loaded, dc, err := s.config.GetDistributeConfig(context.Background()); err == nil { status.ConfigPath = path status.ConfigLoaded = loaded - active := dc.LLM.Active() - if status.LLMProvider == "" { - status.LLMProvider = active.Provider - } - if status.LLMModel == "" { - status.LLMModel = active.Model + if active := config.ActiveLLMProvider(dc.GetLlm()); active != nil { + if status.LlmProvider == "" { + status.LlmProvider = active.Provider + } + if status.LlmModel == "" { + status.LlmModel = active.Model + } + status.LlmApiKeyConfigured = status.LlmApiKeyConfigured || active.ApiKey != "" } - status.LLMAPIKeyConfigured = status.LLMAPIKeyConfigured || active.APIKey != "" } } return status } -func (s *Service) GetConfigStatus(ctx context.Context) (ConfigStatus, error) { +func (s *Service) GetConfigView(ctx context.Context) (*configpb.ConfigView, error) { if s.config == nil { - return ConfigStatus{}, fmt.Errorf("config store is not configured") + return nil, fmt.Errorf("config store is not configured") } path, loaded, dc, err := s.config.GetDistributeConfig(ctx) if err != nil { - return ConfigStatus{}, err + return nil, err } - return ConfigStatusFromDistribute(&dc, path, loaded), nil + return ConfigViewFromDistribute(dc, path, loaded), nil } -func (s *Service) SaveConfig(ctx context.Context, cfg config.DistributeConfig) (ConfigStatus, error) { +func (s *Service) SaveConfig(ctx context.Context, cfg *configpb.DistributeConfig) (*configpb.ConfigView, error) { s.saveMu.Lock() defer s.saveMu.Unlock() if s.config == nil { - return ConfigStatus{}, fmt.Errorf("config store is not configured") + return nil, fmt.Errorf("config store is not configured") } - if err := ValidateLLMConfig(cfg.LLM); err != nil { - return ConfigStatus{}, err + if err := ValidateLLMConfig(cfg.GetLlm()); err != nil { + return nil, err } prepared, err := s.config.PrepareDistributeConfig(ctx, cfg) if err != nil { - return ConfigStatus{}, err + return nil, err } committed := false defer func() { @@ -206,28 +212,28 @@ func (s *Service) SaveConfig(ctx context.Context, cfg config.DistributeConfig) ( } }() if prepared == nil { - return ConfigStatus{}, fmt.Errorf("config store returned no prepared config") + return nil, fmt.Errorf("config store returned no prepared config") } - if err := ValidateLLMConfig(prepared.Config.LLM); err != nil { - return ConfigStatus{}, err + if err := ValidateLLMConfig(prepared.Config.GetLlm()); err != nil { + return nil, err } var nextApp *runner.App if s.reload != nil { nextApp, err = s.reload(ctx, prepared) if err != nil { - cs, _ := s.GetConfigStatus(ctx) - return cs, fmt.Errorf("reload aiscan runtime: %w", err) + view, _ := s.GetConfigView(ctx) + return view, fmt.Errorf("reload aiscan runtime: %w", err) } if nextApp == nil { - return ConfigStatus{}, fmt.Errorf("reload aiscan runtime returned no app") + return nil, fmt.Errorf("reload aiscan runtime returned no app") } } if err := s.config.CommitDistributeConfig(ctx, prepared); err != nil { if nextApp != nil { nextApp.Close() } - return ConfigStatus{}, err + return nil, err } committed = true if nextApp != nil { @@ -236,45 +242,49 @@ func (s *Service) SaveConfig(ctx context.Context, cfg config.DistributeConfig) ( // Tell connected agents to hot-swap their own provider too — the hub reload // above only refreshes the hub's in-process runtime, not the agent subprocesses. if s.agents != nil { - s.agents.BroadcastConfigReload() + s.agents.BroadcastConfigReload(prepared.Config) } - return s.GetConfigStatus(ctx) + return s.GetConfigView(ctx) } -func (s *Service) ActivateLLMProfile(ctx context.Context, id string) (ConfigStatus, error) { +func (s *Service) ActivateLLMProfile(ctx context.Context, id string) (*configpb.ConfigView, error) { if strings.TrimSpace(id) == "" { - return ConfigStatus{}, fmt.Errorf("LLM profile id is required") + return nil, fmt.Errorf("LLM profile id is required") } if s.config == nil { - return ConfigStatus{}, fmt.Errorf("config store is not configured") + return nil, fmt.Errorf("config store is not configured") } - _, _, cfg, err := s.config.GetDistributeConfig(ctx) + _, _, stored, err := s.config.GetDistributeConfig(ctx) if err != nil { - return ConfigStatus{}, err + return nil, err } found := false - for _, profile := range cfg.LLM.Providers { - if profile.ID == id { + for _, profile := range stored.GetLlm().GetProviders() { + if profile.Id == id { found = true break } } if !found { - return ConfigStatus{}, fmt.Errorf("LLM profile %q was not found", id) + return nil, fmt.Errorf("LLM profile %q was not found", id) } - cfg.LLM.ActiveProfile = id + cfg := proto.Clone(stored).(*configpb.DistributeConfig) + if cfg.Llm == nil { + cfg.Llm = &configpb.LLMConfig{} + } + cfg.Llm.ActiveProfile = id return s.SaveConfig(ctx, cfg) } -func (s *Service) GetDistributeConfig(ctx context.Context) (config.DistributeConfig, error) { +func (s *Service) GetDistributeConfig(ctx context.Context) (*configpb.DistributeConfig, error) { if s.config == nil { - return config.DistributeConfig{}, fmt.Errorf("config store is not configured") + return nil, fmt.Errorf("config store is not configured") } _, _, dc, err := s.config.GetDistributeConfig(ctx) return dc, err } -func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, sniper, deep bool) (*ScanJob, error) { +func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, sniper, deep bool) (*scanpb.Scan, error) { target, err := ValidateTarget(target) if err != nil { return nil, err @@ -287,79 +297,67 @@ func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, s return nil, fmt.Errorf("selected analysis options require an LLM provider") } - now := time.Now() - job := &ScanJob{ - ID: generateID(), + now := nowProto() + scan := &scanpb.Scan{ + Id: generateID(), Target: target, Mode: mode, - Verify: verify, - Sniper: sniper, - Deep: deep, - Status: StatusQueued, + Options: &scanpb.ScanOptions{Verify: verify, Sniper: sniper, Deep: deep}, + Status: scanpb.ScanStatus_SCAN_STATUS_QUEUED, CreatedAt: now, UpdatedAt: now, } - if err := s.store.Create(ctx, job); err != nil { + if err := s.store.Create(ctx, scan); err != nil { return nil, fmt.Errorf("store create: %w", err) } runCtx, cancel := context.WithCancel(context.Background()) s.mu.Lock() - s.cancels[job.ID] = cancel + s.cancels[scan.Id] = cancel s.mu.Unlock() go func() { //nolint:gosec // G118: background scan intentionally outlives the request defer cancel() - s.runScan(runCtx, job.ID) + s.runScan(runCtx, scan.Id) }() - return job, nil + return scan, nil } -func (s *Service) GetScan(ctx context.Context, id string) (*ScanJob, error) { - job, err := s.store.Get(ctx, id) +func (s *Service) GetScan(ctx context.Context, id string) (*scanpb.Scan, error) { + scan, err := s.store.Get(ctx, id) if err != nil { return nil, err } - refreshStructuredAssets(job) - return job, nil + return scan, nil } -func (s *Service) ListScans(ctx context.Context) ([]*ScanJob, error) { - jobs, err := s.store.List(ctx, 100) +func (s *Service) ListScans(ctx context.Context) ([]*scanpb.Scan, error) { + scans, err := s.store.List(ctx, 100) if err != nil { return nil, err } - for _, job := range jobs { - refreshStructuredAssets(job) - } - return jobs, nil -} - -func refreshStructuredAssets(job *ScanJob) { - if job != nil && job.Result != nil && (len(job.Result.Services) > 0 || len(job.Result.WebProbes) > 0) { - job.Result.Assets = scantool.AggregateStructuredResult(job.Result) - } + return scans, nil } func (s *Service) CancelScan(id string) error { ctx := context.Background() - job, err := s.store.Get(ctx, id) + scan, err := s.store.Get(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%w: %s", ErrScanNotFound, id) } return err } - if job.Status == StatusCanceled { + if scan.Status == scanpb.ScanStatus_SCAN_STATUS_CANCELED { return nil } - if job.Status != StatusRunning && job.Status != StatusQueued { - return fmt.Errorf("%w: scan %s is %s", ErrScanNotCancelable, id, job.Status) + if scan.Status != scanpb.ScanStatus_SCAN_STATUS_RUNNING && scan.Status != scanpb.ScanStatus_SCAN_STATUS_QUEUED { + return fmt.Errorf("%w: scan %s is %s", ErrScanNotCancelable, id, scanStatusToDB(scan.Status)) } - job.Status = StatusCanceled - job.UpdatedAt = time.Now() - changed, err := s.store.TransitionScan(ctx, job, StatusRunning, StatusQueued) + scan.Status = scanpb.ScanStatus_SCAN_STATUS_CANCELED + scan.UpdatedAt = nowProto() + changed, err := s.store.TransitionScan(ctx, scan, scanpb.ScanStatus_SCAN_STATUS_RUNNING, scanpb.ScanStatus_SCAN_STATUS_QUEUED) if err != nil { return err } @@ -371,10 +369,10 @@ func (s *Service) CancelScan(id string) error { } return err } - if current.Status == StatusCanceled { + if current.Status == scanpb.ScanStatus_SCAN_STATUS_CANCELED { return nil } - return fmt.Errorf("%w: scan %s is %s", ErrScanNotCancelable, id, current.Status) + return fmt.Errorf("%w: scan %s is %s", ErrScanNotCancelable, id, scanStatusToDB(current.Status)) } s.mu.Lock() @@ -391,32 +389,28 @@ func (s *Service) CancelScan(id string) error { return nil } -// GetReport re-renders the report in the requested language from the stored -// structured result, so a zh user gets a zh report even though the scan ran -// once. It falls back to the report frozen at scan time when the structured -// result is no longer around. +// GetReport returns the report frozen when the scan completed. Canonical scan +// artifacts live in the libcstx SCO store, not inside Scan. func (s *Service) GetReport(ctx context.Context, id, lang string) (string, error) { - job, err := s.GetScan(ctx, id) + _ = lang + scan, err := s.GetScan(ctx, id) if err != nil { return "", err } - if job.Result != nil { - return buildMarkdownReport(job.Target, job.Mode, job.Result, lang), nil - } - return job.Report, nil + return scan.Report, nil } -func (s *Service) runScan(runCtx context.Context, jobID string) { +func (s *Service) runScan(runCtx context.Context, scanID string) { defer func() { s.mu.Lock() - delete(s.cancels, jobID) - delete(s.scanAgents, jobID) + delete(s.cancels, scanID) + delete(s.scanAgents, scanID) s.mu.Unlock() }() defer func() { if recovered := recover(); recovered != nil { - if job, err := s.store.Get(context.Background(), jobID); err == nil { - _, _ = s.failJob(job, fmt.Sprintf("scan runtime panic: %v", recovered)) + if scan, err := s.store.Get(context.Background(), scanID); err == nil { + _, _ = s.failScan(scan, fmt.Sprintf("scan runtime panic: %v", recovered)) } } }() @@ -431,48 +425,48 @@ func (s *Service) runScan(runCtx context.Context, jobID string) { ctx, cancel := context.WithTimeout(runCtx, s.timeout) defer cancel() - job, err := s.store.Get(ctx, jobID) + scan, err := s.store.Get(ctx, scanID) if err != nil { return } - job.Status = StatusRunning - job.UpdatedAt = time.Now() - changed, err := s.store.TransitionScan(context.Background(), job, StatusQueued) + scan.Status = scanpb.ScanStatus_SCAN_STATUS_RUNNING + scan.UpdatedAt = nowProto() + changed, err := s.store.TransitionScan(context.Background(), scan, scanpb.ScanStatus_SCAN_STATUS_QUEUED) if err != nil || !changed { return } - s.hub.BroadcastScan(scanStatusEvent(jobID, StatusRunning), false) + s.hub.BroadcastScan(scanStatusEvent(scanID, scanpb.ScanStatus_SCAN_STATUS_RUNNING), false) // Try agent dispatch first, fall back to local execution. if s.agents != nil && s.agents.Count() > 0 { - s.runScanViaAgent(ctx, job) + s.runScanViaAgent(ctx, scan) return } - s.runScanLocally(ctx, job) + s.runScanLocally(ctx, scan) } -func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { +func (s *Service) runScanViaAgent(ctx context.Context, scan *scanpb.Scan) { agent := s.agents.Pick() if agent == nil { - _, _ = s.failJob(job, "no agents available") + _, _ = s.failScan(scan, "no agents available") return } s.mu.Lock() - s.scanAgents[job.ID] = agent.id + s.scanAgents[scan.Id] = agent.id s.mu.Unlock() if err := ctx.Err(); err != nil { - s.finishScanContext(job, err) + s.finishScanContext(scan, err) return } - cmd := "scan " + strings.Join(scanArgsForJob(job), " ") + cmd := "scan " + strings.Join(scanArgsForScan(scan), " ") args, _ := aop.JSONValue(map[string]any{"command": cmd}) - resultCh, err := s.agents.DispatchToolCall(agent.id, job.ID, &aop.ToolCall{ - Id: job.ID, Name: "bash", Kind: "function", Arguments: args, + resultCh, err := s.agents.DispatchToolCall(agent.id, scan.Id, &aop.ToolCall{ + Id: scan.Id, Name: "bash", Kind: "function", Arguments: args, }) if err != nil { - _, _ = s.failJob(job, err.Error()) + _, _ = s.failScan(scan, err.Error()) return } @@ -483,136 +477,106 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { var ok bool select { case <-ctx.Done(): - _ = s.agents.CancelTask(agent.id, job.ID) - s.finishScanContext(job, ctx.Err()) + _ = s.agents.CancelTask(agent.id, scan.Id) + s.finishScanContext(scan, ctx.Err()) return case res, ok = <-resultCh: } if ctx.Err() != nil { - _ = s.agents.CancelTask(agent.id, job.ID) - s.finishScanContext(job, ctx.Err()) + _ = s.agents.CancelTask(agent.id, scan.Id) + s.finishScanContext(scan, ctx.Err()) return } if !ok { - _, _ = s.failJob(job, "agent disconnected") + _, _ = s.failScan(scan, "agent disconnected") return } if res.Err != "" { - _, _ = s.failJob(job, res.Err) + _, _ = s.failScan(scan, res.Err) return } if progress := lastOutputLine(res.Output); progress != "" { - job.Progress = progress - } - - result, err := decodeScanResult(res.Result) - if err != nil { - _, _ = s.failJob(job, err.Error()) - return + scan.Progress = progress } - _, _ = s.completeJob(context.Background(), job, agent.id, result) + _, _ = s.completeScan(context.Background(), scan) } -func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) { +func (s *Service) runScanLocally(ctx context.Context, scan *scanpb.Scan) { + ctx = output.ContextWithCallID(ctx, scan.Id) streamWriter := &scanStreamWriter{ hub: s.hub, - scanID: job.ID, + scanID: scan.Id, store: s.store, - job: job, + scan: scan, ctx: ctx, } - args := scanArgsForJob(job) - _, result, err := s.executeScan(ctx, args, streamWriter) + args := scanArgsForScan(scan) + _, err := s.executeScan(ctx, args, streamWriter) if err != nil { - s.finishScanContext(job, ctx.Err()) + s.finishScanContext(scan, ctx.Err()) if ctx.Err() == nil { - _, _ = s.failJob(job, err.Error()) + _, _ = s.failScan(scan, err.Error()) } return } - if streamWriter.job != nil { - job = streamWriter.job + if streamWriter.scan != nil { + scan = streamWriter.scan } if ctx.Err() != nil { - s.finishScanContext(job, ctx.Err()) + s.finishScanContext(scan, ctx.Err()) return } - _, _ = s.completeJob(context.Background(), job, "", result) -} - -func decodeScanResult(raw json.RawMessage) (*output.Result, error) { - if len(bytes.TrimSpace(raw)) == 0 { - return nil, fmt.Errorf("agent scan returned an empty result envelope") - } - var result *output.Result - if err := json.Unmarshal(raw, &result); err != nil { - return nil, fmt.Errorf("decode agent scan result: %w", err) - } - if result == nil { - return nil, fmt.Errorf("agent scan returned a null result envelope") - } - return result, nil + _, _ = s.completeScan(context.Background(), scan) } -func (s *Service) finishScanContext(job *ScanJob, err error) { +func (s *Service) finishScanContext(scan *scanpb.Scan, err error) { if err == nil { return } if err == context.DeadlineExceeded { - _, _ = s.failJob(job, "scan timed out") + _, _ = s.failScan(scan, "scan timed out") return } - next := *job - next.Status = StatusCanceled - next.UpdatedAt = time.Now() - _, _ = s.store.TransitionScan(context.Background(), &next, StatusQueued, StatusRunning) + next := proto.Clone(scan).(*scanpb.Scan) + next.Status = scanpb.ScanStatus_SCAN_STATUS_CANCELED + next.UpdatedAt = nowProto() + _, _ = s.store.TransitionScan(context.Background(), next, scanpb.ScanStatus_SCAN_STATUS_QUEUED, scanpb.ScanStatus_SCAN_STATUS_RUNNING) } -func (s *Service) persistResultRecords(scanID, agentID string, result *output.Result) { - recs := resultToRecords(scanID, agentID, result) - if len(recs) > 0 { - _ = s.store.InsertRecords(context.Background(), recs) - } -} - -func (s *Service) completeJob(ctx context.Context, job *ScanJob, agentID string, result *output.Result) (bool, error) { - if result == nil { - return false, fmt.Errorf("scan result is required") +func (s *Service) completeScan(ctx context.Context, scan *scanpb.Scan) (bool, error) { + nodes, err := s.store.ListSCONodesByScanID(ctx, scan.Id, "", 100000) + if err != nil { + return false, fmt.Errorf("load scan SCO facts: %w", err) } - next := *job - next.Status = StatusCompleted - next.Report = buildMarkdownReport(job.Target, job.Mode, result, defaultReportLang) - next.Result = result + next := proto.Clone(scan).(*scanpb.Scan) + next.Status = scanpb.ScanStatus_SCAN_STATUS_COMPLETED + next.Report = buildMarkdownReport(scan.Target, scan.Mode, nodes, defaultReportLang) next.Error = "" - next.UpdatedAt = time.Now() - changed, err := s.store.TransitionScan(ctx, &next, StatusRunning) + next.UpdatedAt = nowProto() + changed, err := s.store.TransitionScan(ctx, next, scanpb.ScanStatus_SCAN_STATUS_RUNNING) if err != nil || !changed { return changed, err } - *job = next - s.persistResultRecords(job.ID, agentID, result) - if len(result.Nodes) > 0 { - _ = s.store.UpsertSCONodes(ctx, job.ID, result.Nodes) - } - s.hub.BroadcastScan(scanCompletedEvent(job.ID, result), true) - s.broadcastScanComplete(job.ID) + proto.Merge(scan, next) + s.hub.BroadcastScan(scanCompletedEvent(scan.Id), true) + s.broadcastScanComplete(scan.Id) return true, nil } -func (s *Service) failJob(job *ScanJob, errMsg string) (bool, error) { - next := *job - next.Status = StatusFailed +func (s *Service) failScan(scan *scanpb.Scan, errMsg string) (bool, error) { + next := proto.Clone(scan).(*scanpb.Scan) + next.Status = scanpb.ScanStatus_SCAN_STATUS_FAILED next.Error = errMsg - next.UpdatedAt = time.Now() - changed, err := s.store.TransitionScan(context.Background(), &next, StatusQueued, StatusRunning) + next.UpdatedAt = nowProto() + changed, err := s.store.TransitionScan(context.Background(), next, scanpb.ScanStatus_SCAN_STATUS_QUEUED, scanpb.ScanStatus_SCAN_STATUS_RUNNING) if err != nil || !changed { return changed, err } - *job = next - s.hub.BroadcastScan(scanFailedEvent(job.ID, errMsg, false), true) + proto.Merge(scan, next) + s.hub.BroadcastScan(scanFailedEvent(scan.Id, errMsg, false), true) return true, nil } @@ -691,58 +655,54 @@ func (s *Service) swapApp(next *runner.App) { } } -func scanArgsForJob(job *ScanJob) []string { - args := []string{"-i", job.Target, "--mode", job.Mode} - if job.Verify { +func scanArgsForScan(scan *scanpb.Scan) []string { + args := []string{"-i", scan.Target, "--mode", scan.Mode} + options := scan.GetOptions() + if options.GetVerify() { args = append(args, "--verify=high") } - if job.Sniper { + if options.GetSniper() { args = append(args, "--sniper") } - if job.Deep { + if options.GetDeep() { args = append(args, "--deep") } return args } -func (s *Service) executeScan(ctx context.Context, args []string, stream io.Writer) (string, *output.Result, error) { +func (s *Service) executeScan(ctx context.Context, args []string, stream io.Writer) (string, error) { app, release := s.acquireApp() defer release() if app == nil || app.Commands == nil { - return "", nil, fmt.Errorf("aiscan runtime is not ready") + return "", fmt.Errorf("aiscan runtime is not ready") } tool, ok := app.Commands.GetTool("bash") if !ok { - return "", nil, fmt.Errorf("bash tool is not registered") + return "", fmt.Errorf("bash tool is not registered") } bash, ok := tool.(*commands.BashTool) if !ok { - return "", nil, fmt.Errorf("registered bash tool has unexpected type") + return "", fmt.Errorf("registered bash tool has unexpected type") } var text strings.Builder - execution, err := bash.RunForeground(ctx, commands.JoinCommandLine("scan", args), commands.BashExecOptions{ + if _, err := bash.RunForeground(ctx, commands.JoinCommandLine("scan", args), commands.BashExecOptions{ OnOutput: func(data []byte) { _, _ = text.Write(data) if stream != nil { _, _ = stream.Write(data) } }, - }) - if err != nil { - return text.String(), nil, err - } - result, ok := execution.Details.(*output.Result) - if !ok || result == nil { - return text.String(), nil, fmt.Errorf("scan execution returned no structured result") + }); err != nil { + return text.String(), err } - return text.String(), result, nil + return text.String(), nil } type scanStreamWriter struct { hub *Hub scanID string store *SQLiteStore - job *ScanJob + scan *scanpb.Scan ctx context.Context buf []byte } @@ -775,19 +735,19 @@ func (w *scanStreamWriter) Write(p []byte) (int, error) { if err != nil { return 0, err } - if current.Status == StatusCanceled { + if current.Status == scanpb.ScanStatus_SCAN_STATUS_CANCELED { return 0, context.Canceled } current.Progress = line - current.UpdatedAt = time.Now() - changed, err := w.store.TransitionScan(context.Background(), current, StatusRunning) + current.UpdatedAt = nowProto() + changed, err := w.store.TransitionScan(context.Background(), current, scanpb.ScanStatus_SCAN_STATUS_RUNNING) if err != nil { return 0, err } if !changed { return 0, context.Canceled } - w.job = current + w.scan = current w.hub.BroadcastScan(scanProgressEvent(w.scanID, line), false) } @@ -907,7 +867,7 @@ func (s *Service) CancelTurn(ctx context.Context, sessionID, turnID string) erro return nil } -func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename string, data []byte) (*transport.FileResult, error) { +func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename string, data []byte) (*filepb.Result, error) { session, err := s.store.GetSession(ctx, sessionID) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -918,19 +878,15 @@ func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename stri if s.agents == nil { return nil, fmt.Errorf("no agent pool available") } - agentID := session.AgentID + agentID := session.GetSession().GetParticipant() if agentID == "" { return nil, fmt.Errorf("session has no assigned agent") } taskID := generateID() - resultCh, err := s.agents.dispatchFrame(agentID, taskID, &transport.ServerFrame{ - CorrelationId: taskID, - Payload: &transport.ServerFrame_FileUpload{FileUpload: &transport.FileUploadRequest{ - TaskId: taskID, SessionId: sessionID, Filename: filename, - MediaType: http.DetectContentType(data), Data: data, - }}, - }) + resultCh, err := s.agents.dispatchMessage(agentID, taskID, &filepb.ProtocolMessage{Message: &filepb.ProtocolMessage_UploadRequest{UploadRequest: &filepb.UploadRequest{ + SessionId: sessionID, Filename: filename, MediaType: http.DetectContentType(data), Data: data, + }}}) if err != nil { return nil, fmt.Errorf("agent dispatch failed: %w", err) } @@ -954,20 +910,17 @@ func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename stri } } -func (s *Service) CreateSession(ctx context.Context, agentID, title string) (*ChatSession, error) { +func (s *Service) CreateSession(ctx context.Context, agentID, title string) (*chatpb.SessionRecord, error) { var agentName string if s.agents != nil { if info := s.agents.get(agentID); info != nil { agentName = info.name } } - now := time.Now() - session := &ChatSession{ - ID: generateID(), - AgentID: agentID, + now := nowProto() + session := &chatpb.SessionRecord{ + Session: &aop.Session{Id: generateID(), State: SessionStateOpen, Participant: agentID, Title: title}, AgentName: agentName, - Title: title, - Status: SessionActive, CreatedAt: now, UpdatedAt: now, } @@ -977,11 +930,11 @@ func (s *Service) CreateSession(ctx context.Context, agentID, title string) (*Ch return session, nil } -func (s *Service) GetSession(ctx context.Context, id string) (*ChatSession, error) { +func (s *Service) GetSession(ctx context.Context, id string) (*chatpb.SessionRecord, error) { return s.store.GetSession(ctx, id) } -func (s *Service) ListSessions(ctx context.Context) ([]*ChatSession, error) { +func (s *Service) ListSessions(ctx context.Context) ([]*chatpb.SessionRecord, error) { return s.store.ListSessions(ctx, 100) } @@ -1008,6 +961,23 @@ func (s *Service) BroadcastAOPEvent(sessionID string, event *aop.Event) { s.broadcastAOPEvent(sessionID, event, cursor) } +func (s *Service) broadcastUserMessage(sessionID, turnID string, message *aop.Message) { + if message == nil || len(message.Content) == 0 { + return + } + canonical := proto.Clone(message).(*aop.Message) + if canonical.Id == "" { + canonical.Id = generateID() + } + canonical.Role = "user" + s.BroadcastAOPEvent(sessionID, &aop.Event{ + SessionId: sessionID, + TurnId: turnID, + Emitter: "aiscan.web", + Payload: &aop.Event_Message{Message: canonical}, + }) +} + func (s *Service) prepareAOPEvent(sessionID string, event *aop.Event) bool { if event.SessionId == "" { event.SessionId = sessionID @@ -1160,8 +1130,8 @@ func (s *Service) handleHelpCommand(sessionID string) { // included). It falls back to the static agent-scope menu when no agent is // bound, so the menu is populated even before an agent connects. This is the // single source both SessionService/ListCommands and /help render from. -func (s *Service) SessionMenu(sessionID string) []*transport.CommandSpec { - hubSpecs := []*transport.CommandSpec{ +func (s *Service) SessionMenu(sessionID string) []*commandpb.Spec { + hubSpecs := []*commandpb.Spec{ {Name: "/help", Description: "查看命令面板"}, {Name: "/agents", Description: "列出已连接的 agent"}, } @@ -1183,17 +1153,23 @@ func (s *Service) handleAgentsCommand(sessionID string) { list := make([]map[string]any, 0, len(agents)) var sb strings.Builder sb.WriteString(fmt.Sprintf("%d agent(s) connected:\n", len(agents))) - for _, a := range agents { + for _, agentView := range agents { + hello := agentView.GetHello() + statusView := agentView.GetStatus() status := "idle" - if a.Busy { + if agentView.GetBusy() { status = "busy" } - sb.WriteString(fmt.Sprintf("- **%s** (%s) — %s", a.Name, a.ID[:8], status)) - entry := map[string]any{"name": a.Name, "id": a.ID[:8], "busy": a.Busy} - if a.Status.Model != "" { - sb.WriteString(fmt.Sprintf(" — %s/%s", a.Status.Provider, a.Status.Model)) - entry["provider"] = a.Status.Provider - entry["model"] = a.Status.Model + shortID := hello.GetAgentId() + if len(shortID) > 8 { + shortID = shortID[:8] + } + sb.WriteString(fmt.Sprintf("- **%s** (%s) — %s", hello.GetName(), shortID, status)) + entry := map[string]any{"name": hello.GetName(), "id": shortID, "busy": agentView.GetBusy()} + if statusView.GetModel() != "" { + sb.WriteString(fmt.Sprintf(" — %s/%s", statusView.GetProvider(), statusView.GetModel())) + entry["provider"] = statusView.GetProvider() + entry["model"] = statusView.GetModel() } sb.WriteString("\n") list = append(list, entry) @@ -1204,13 +1180,13 @@ func (s *Service) handleAgentsCommand(sessionID string) { func (s *Service) sessionAgent(sessionID string) *remoteAgent { session, err := s.store.GetSession(context.Background(), sessionID) - if err != nil || session.AgentID == "" { + if err != nil || session.GetSession().GetParticipant() == "" { return nil } if s.agents == nil { return nil } - return s.agents.get(session.AgentID) + return s.agents.get(session.GetSession().GetParticipant()) } func (s *Service) handleAgentRun(sessionID string, request *aop.RunTurnRequest) { @@ -1225,9 +1201,6 @@ func (s *Service) handleAgentRun(sessionID string, request *aop.RunTurnRequest) if taskID == "" { taskID = generateID() } - if request.RequestId == "" { - request.RequestId = taskID - } request.TurnId = taskID request.SessionId = sessionID s.resetTurnTerminal(sessionID, taskID) @@ -1273,6 +1246,7 @@ func (s *Service) ExecuteSessionCommand(sessionID, line string) (string, error) } return "", err } + s.broadcastUserMessage(sessionID, "", &aop.Message{Role: "user", Content: []*aop.Content{aop.Text(line)}}) if verb, args, ok := parseCommand(line); ok { switch verb { case "help", "agents": @@ -1297,7 +1271,7 @@ func (s *Service) ExecuteSessionCommand(sessionID, line string) (string, error) } taskID := generateID() s.registerSessionTask(taskID, sessionID, agent.id) - resultCh, err := s.agents.DispatchCommand(agent.id, &transport.CommandRequest{TaskId: taskID, SessionId: sessionID, Line: line}) + resultCh, err := s.agents.DispatchCommand(agent.id, taskID, &commandpb.Request{SessionId: sessionID, Line: line}) if err != nil { s.finishSessionTask(taskID) return "", err @@ -1317,16 +1291,13 @@ func (s *Service) ExecuteSessionCommand(sessionID, line string) (string, error) func (s *Service) closeRemoteSession(sessionID string) { session, err := s.store.GetSession(context.Background(), sessionID) - if err != nil || s.agents == nil || session.AgentID == "" { + if err != nil || s.agents == nil || session.GetSession().GetParticipant() == "" { return } requestID := "close:" + sessionID - _ = s.agents.sendAgentFrame(session.AgentID, &transport.ServerFrame{ - CorrelationId: requestID, - Payload: &transport.ServerFrame_CloseSession{CloseSession: &aop.CloseSessionRequest{ - RequestId: requestID, SessionId: sessionID, Reason: "completed", - }}, - }) + _ = s.agents.sendAgentMessage(session.GetSession().GetParticipant(), requestID, "", &aop.ProtocolMessage{Message: &aop.ProtocolMessage_CloseSessionRequest{CloseSessionRequest: &aop.CloseSessionRequest{ + SessionId: sessionID, Reason: "completed", + }}}) } // broadcastSystemMessage persists + broadcasts a system message. code names a @@ -1342,8 +1313,8 @@ func (s *Service) broadcastSystemMessage(sessionID, code, fallback string, param }}, } if code != "" { - metadata, _ := json.Marshal(map[string]any{"code": code, "params": params}) - _ = ext.SetWebMessage(event, ext.WebMessageExtension{Metadata: metadata}) + encodedParams, _ := structpb.NewStruct(params) + _ = ext.SetWebMessage(event, ext.WebMessageExtension{Code: code, Params: encodedParams}) } s.BroadcastAOPEvent(sessionID, event) } @@ -1359,15 +1330,13 @@ func (s *Service) broadcastScanComplete(scanID string) { return } _ = s.store.LinkScanToSession(context.Background(), sid, scanID) - value, err := aop.ProtoJSONValue(&scanpb.SessionScanEvent{ScanId: scanID, Status: scanpb.ScanStatus_SCAN_STATUS_COMPLETED}) + value, err := anypb.New(&scanpb.SessionScanEvent{ScanId: scanID, Status: scanpb.ScanStatus_SCAN_STATUS_COMPLETED}) if err != nil { return } s.BroadcastAOPEvent(sid, &aop.Event{ SessionId: sid, Emitter: "aiscan.web", - Payload: &aop.Event_Extension{Extension: &aop.ExtensionEvent{ - Type: "io.chainreactors.aiscan.scan", Value: value, - }}, + Payload: &aop.Event_Extension{Extension: value}, }) } diff --git a/pkg/web/service_test.go b/pkg/web/service_test.go index f5f3f804..e9dac859 100644 --- a/pkg/web/service_test.go +++ b/pkg/web/service_test.go @@ -2,29 +2,26 @@ package web import ( "context" + "encoding/json" "net/http" "net/http/httptest" "path/filepath" "reflect" "strings" "testing" - "time" aop "github.com/chainreactors/aiscan/aop" - "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/utils/parsers" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" ) func TestScanArgsForSelectedAnalysisOptions(t *testing.T) { - job := &ScanJob{ - Target: "127.0.0.1", - Mode: "full", - Verify: true, - Sniper: true, - Deep: true, + scan := &scanpb.Scan{ + Target: "127.0.0.1", + Mode: "full", + Options: &scanpb.ScanOptions{Verify: true, Sniper: true, Deep: true}, } - got := scanArgsForJob(job) + got := scanArgsForScan(scan) want := []string{"-i", "127.0.0.1", "--mode", "full", "--verify=high", "--sniper", "--deep"} if !reflect.DeepEqual(got, want) { t.Fatalf("scan args = %#v, want %#v", got, want) @@ -33,7 +30,7 @@ func TestScanArgsForSelectedAnalysisOptions(t *testing.T) { func TestServiceStatusReportsLLMAvailability(t *testing.T) { service := NewService(ServiceConfig{}) - if service.Status().LLMAvailable { + if service.Status().GetLlmAvailable() { t.Fatal("LLMAvailable = true, want false without provider") } } @@ -46,8 +43,8 @@ func TestRunTurnRejectsMissingSessionBeforePersisting(t *testing.T) { defer store.Close() svc := NewService(ServiceConfig{Store: store}) - response, err := NewAOPChatServer(svc).RunTurn(context.Background(), &aop.RunTurnRequest{ - RequestId: "run-1", SessionId: "missing", TurnId: "turn-1", + response, err := NewAOPChatServer(svc).RunTurn(context.Background(), "run-1", &aop.RunTurnRequest{ + SessionId: "missing", TurnId: "turn-1", Input: &aop.Message{Role: "user", Content: []*aop.Content{{Value: &aop.Content_Text{Text: &aop.TextContent{Text: "hello"}}}}}, }) if err != nil || response.GetRejected().GetCode() != "NOT_FOUND" { @@ -90,179 +87,31 @@ func TestLegacyChatAndScanRoutesReturnNotFoundBeforeSPAFallback(t *testing.T) { } } -func TestGetScanRebuildsLegacyMergedAssets(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) - if err != nil { - t.Fatalf("NewSQLiteStore() error = %v", err) - } - defer store.Close() - - now := time.Now() - job := &ScanJob{ - ID: "legacy-merged-services", - Target: "111.63.65.103", - Mode: "quick", - Status: StatusCompleted, - CreatedAt: now, - UpdatedAt: now, - Result: &output.Result{ - Services: []*parsers.GOGOResult{ - {Ip: "111.63.65.103", Port: "80", Protocol: "http"}, - {Ip: "111.63.65.103", Port: "443", Protocol: "https"}, - {Ip: "111.63.65.103", Port: "icmp", Protocol: "icmp"}, - }, - WebProbes: []*parsers.SprayResult{ - {UrlString: "http://111.63.65.103/", Status: 200, Source: parsers.CheckSource}, - {UrlString: "https://111.63.65.103/", Status: 301, Source: parsers.CheckSource}, - }, - Assets: []output.Asset{{ - Target: "https://111.63.65.103", - Items: []output.AssetItem{{ - Kind: output.AssetItemResponse, Target: "https://111.63.65.103", Summary: "saved analysis", - }}, - }}, - }, - } - if err := store.Create(context.Background(), job); err != nil { - t.Fatalf("Create() error = %v", err) - } - - got, err := NewService(ServiceConfig{Store: store}).GetScan(context.Background(), job.ID) - if err != nil { - t.Fatalf("GetScan() error = %v", err) - } - if len(got.Result.Assets) != 3 { - t.Fatalf("assets = %d, want 3 separated services: %#v", len(got.Result.Assets), got.Result.Assets) - } - foundAnalysis := false - for _, asset := range got.Result.Assets { - for _, item := range asset.Items { - foundAnalysis = foundAnalysis || item.Kind == output.AssetItemResponse && item.Summary == "saved analysis" - } - } - if !foundAnalysis { - t.Fatal("supplemental analysis item was dropped during legacy asset rebuild") - } -} - -func TestGetScanRebuildsLegacyWebOnlyAssets(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db")) - if err != nil { - t.Fatalf("NewSQLiteStore() error = %v", err) - } - defer store.Close() - - now := time.Now() - job := &ScanJob{ - ID: "legacy-web-only", - Target: "111.63.65.103", - Mode: "quick", - Status: StatusCompleted, - CreatedAt: now, - UpdatedAt: now, - Result: &output.Result{ - WebProbes: []*parsers.SprayResult{ - {UrlString: "http://111.63.65.103/", Status: 200, Source: parsers.CheckSource}, - {UrlString: "https://111.63.65.103/", Status: 301, Source: parsers.CheckSource}, - }, - Assets: []output.Asset{{Target: "http://111.63.65.103"}}, - }, +func TestBuildMarkdownReportUsesLibcstxFacts(t *testing.T) { + nodes := []json.RawMessage{ + json.RawMessage(`{"cstx_type":"ip","cstx_id":"ip:111.63.65.103","ip":"111.63.65.103"}`), + json.RawMessage(`{"cstx_type":"port","cstx_id":"port:111.63.65.103:80:tcp","ip":"111.63.65.103","port":"80","protocol":"tcp"}`), + json.RawMessage(`{"cstx_type":"url","cstx_id":"url:http://111.63.65.103/","scheme":"http","host":"111.63.65.103","path":"/","status_code":200,"title":"BWS/1.1"}`), + json.RawMessage(`{"cstx_type":"framework","cstx_id":"framework:bws","name":"BWS","version":"1.1"}`), + json.RawMessage(`{"cstx_type":"vuln","cstx_id":"vuln:test","value":"CVE-TEST","name":"Example finding","severity":"high","url":"http://111.63.65.103/"}`), } - if err := store.Create(context.Background(), job); err != nil { - t.Fatalf("Create() error = %v", err) - } - - got, err := NewService(ServiceConfig{Store: store}).GetScan(context.Background(), job.ID) - if err != nil { - t.Fatalf("GetScan() error = %v", err) - } - if len(got.Result.Assets) != 2 { - t.Fatalf("assets = %d, want separate http and https web origins: %#v", len(got.Result.Assets), got.Result.Assets) - } -} - -func TestBuildMarkdownReportKeepsAssetDetailAsMarkdown(t *testing.T) { - report := buildMarkdownReport("http://127.0.0.1:8092", "quick", &output.Result{ - Summary: output.Summary{Targets: 1}, - Assets: []output.Asset{ - { - Target: "http://127.0.0.1:8092", - Items: []output.AssetItem{ - { - Kind: output.AssetItemResponse, - Source: "deep", - Status: "response", - Summary: "manual agent response", - Detail: "Let me analyze the collected browser evidence.\n\n## Evidence Analysis\n\n| Asset | Details |\n|---|---|\n| API | GET /api/scans |", - }, - }, - }, - }, - }, "en") - for _, want := range []string{"## Evidence Analysis", "| Asset | Details |"} { - if !strings.Contains(report, want) { - t.Fatalf("report missing %q:\n%s", want, report) - } - } -} - -func TestBuildMarkdownReportLocalizedAndDeNoised(t *testing.T) { - result := &output.Result{ - Summary: output.Summary{Targets: 1, Services: 3, Webs: 2, Probes: 2, Duration: "22.266s"}, - Assets: []output.Asset{ - { - Target: "http://111.63.65.103:80", - Title: "BWS/1.1", - Items: []output.AssetItem{ - {Kind: output.AssetItemService, Source: "gogo_portscan", Data: map[string]any{"service": "http", "port": "80"}}, - {Kind: output.AssetItemPath, Status: "301"}, - {Kind: output.AssetItemPath, Status: "200"}, - }, - }, - { - // Bare live host — only an icmp echo. Must fold into the trailing - // list, not claim its own ### section, and must not inflate the host count. - Target: "111.63.65.103:icmp", - Key: "111.63.65.103:icmp", - Items: []output.AssetItem{ - {Kind: output.AssetItemService, Source: "gogo_portscan", Data: map[string]any{"service": "icmp"}}, - }, - }, - }, - } - - zh := buildMarkdownReport("baidu.com", "quick", result, "zh") - for _, want := range []string{"# 侦察报告", "## 概述", "快速侦察", "1 台主机", "其他存活主机"} { + zh := buildMarkdownReport("baidu.com", "quick", nodes, "zh") + for _, want := range []string{"# 扫描报告", "## 概览", "快速", "## 端口", "## WEB", "## 框架", "## 漏洞"} { if !strings.Contains(zh, want) { t.Fatalf("zh report missing %q:\n%s", want, zh) } } - // The "去 AI 味" contract: no internal scanner names leak, no generic English boilerplate title. - if strings.Contains(zh, "gogo_portscan") { - t.Errorf("zh report leaks scanner source name:\n%s", zh) - } - if strings.Contains(zh, "战利品") { - t.Errorf("zh report leaks internal loot terminology:\n%s", zh) - } - if strings.Contains(zh, "Penetration Test Report") || strings.Contains(zh, "| Metric | Value |") { - t.Errorf("zh report still uses the old boilerplate:\n%s", zh) - } - // icmp is folded, so it must not appear as its own heading. - if strings.Contains(zh, "### 111.63.65.103:icmp") { - t.Errorf("bare icmp host got its own section:\n%s", zh) + for _, old := range []string{"Asset", "Service", "WebProbe", "Loot"} { + if strings.Contains(zh, old) { + t.Fatalf("report leaked removed AIScan taxonomy %q:\n%s", old, zh) + } } - en := buildMarkdownReport("baidu.com", "quick", result, "en") - for _, want := range []string{"## Overview", "Quick recon", "1 host", "Other live hosts"} { + en := buildMarkdownReport("baidu.com", "quick", nodes, "en") + for _, want := range []string{"# Scan Report", "## Overview", "Quick", "## Ports", "## Web", "## Frameworks", "## Vulnerabilities"} { if !strings.Contains(en, want) { t.Fatalf("en report missing %q:\n%s", want, en) } } - if strings.Contains(en, "gogo_portscan") { - t.Errorf("en report leaks scanner source name:\n%s", en) - } - if strings.Contains(strings.ToLower(en), "loot") { - t.Errorf("en report leaks internal loot terminology:\n%s", en) - } } diff --git a/pkg/web/session_connect.go b/pkg/web/session_connect.go index 4fb00867..a271cc0e 100644 --- a/pkg/web/session_connect.go +++ b/pkg/web/session_connect.go @@ -12,17 +12,11 @@ import ( "connectrpc.com/connect" aop "github.com/chainreactors/aiscan/aop" - chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" - "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" - "google.golang.org/grpc/codes" + "github.com/chainreactors/aiscan/pkg/rpc/chat/chatconnect" + chatpb "github.com/chainreactors/aiscan/pkg/types/chat" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" ) -const maxUploadSize = 50 << 20 // 50 MB - -var ErrUploadTooLarge = errors.New("uploaded file exceeds the size limit") - type connectSessionServer struct { chatconnect.UnimplementedSessionServiceHandler service *Service @@ -54,10 +48,7 @@ func (s *connectSessionServer) ListSessions(ctx context.Context, req *connect.Re if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - response := &chatpb.ListSessionsResponse{Sessions: make([]*chatpb.SessionRecord, 0, len(sessions))} - for _, session := range sessions { - response.Sessions = append(response.Sessions, sessionRecord(session)) - } + response := &chatpb.ListSessionsResponse{Sessions: sessions} if more { response.NextCursor = strconv.Itoa(offset + len(sessions)) } @@ -75,13 +66,13 @@ func (s *connectSessionServer) GetSession(ctx context.Context, req *connect.Requ } return nil, connect.NewError(connect.CodeInternal, err) } - return connect.NewResponse(&chatpb.GetSessionResponse{Session: sessionRecord(session)}), nil + return connect.NewResponse(&chatpb.GetSessionResponse{Session: session}), nil } func (s *connectSessionServer) ResetSession(ctx context.Context, req *connect.Request[chatpb.ResetSessionRequest]) (*connect.Response[chatpb.ResetSessionResponse], error) { request := req.Msg if request == nil || strings.TrimSpace(request.RequestId) == "" { - return connect.NewResponse(rejectedReset(request, codes.InvalidArgument, "request_id is required")), nil + return connect.NewResponse(rejectedReset(request, "INVALID_ARGUMENT", "request_id is required")), nil } s.mu.Lock() defer s.mu.Unlock() @@ -94,7 +85,7 @@ func (s *connectSessionServer) ResetSession(ctx context.Context, req *connect.Re return connect.NewResponse(replayed), nil } if conflict { - return connect.NewResponse(rejectedReset(request, codes.AlreadyExists, "request_id conflicts with another request")), nil + return connect.NewResponse(rejectedReset(request, "ALREADY_EXISTS", "request_id conflicts with another request")), nil } finish := func(response *chatpb.ResetSessionResponse) (*connect.Response[chatpb.ResetSessionResponse], error) { if err := s.finishRequest(ctx, "ResetSession", request.RequestId, hash, response); err != nil { @@ -105,7 +96,7 @@ func (s *connectSessionServer) ResetSession(ctx context.Context, req *connect.Re old, err := s.service.store.GetSession(ctx, request.SessionId) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return finish(rejectedReset(request, codes.NotFound, "session not found")) + return finish(rejectedReset(request, "NOT_FOUND", "session not found")) } return nil, connect.NewError(connect.CodeInternal, err) } @@ -113,8 +104,8 @@ func (s *connectSessionServer) ResetSession(ctx context.Context, req *connect.Re if newID == "" { newID = generateID() } - openResponse, err := s.chat.OpenSession(ctx, &aop.OpenSessionRequest{ - RequestId: request.RequestId + ":open", SessionId: newID, Participant: old.AgentID, Title: request.Title, + openResponse, err := s.chat.OpenSession(ctx, request.RequestId+":open", &aop.OpenSessionRequest{ + SessionId: newID, NodeUri: old.GetSession().GetNodeUri(), Title: request.Title, }) if err != nil { return nil, asConnectError(err) @@ -122,8 +113,8 @@ func (s *connectSessionServer) ResetSession(ctx context.Context, req *connect.Re if rejected := openResponse.GetRejected(); rejected != nil { return finish(&chatpb.ResetSessionResponse{RequestId: request.RequestId, Outcome: &chatpb.ResetSessionResponse_Rejected{Rejected: rejected}}) } - closeResponse, err := s.chat.CloseSession(ctx, &aop.CloseSessionRequest{ - RequestId: request.RequestId + ":close", SessionId: old.ID, Reason: "reset", + closeResponse, err := s.chat.CloseSession(ctx, request.RequestId+":close", &aop.CloseSessionRequest{ + SessionId: old.GetSession().GetId(), Reason: "reset", }) if err != nil { _ = s.service.DeleteSession(context.Background(), newID) @@ -138,14 +129,14 @@ func (s *connectSessionServer) ResetSession(ctx context.Context, req *connect.Re return nil, connect.NewError(connect.CodeInternal, err) } return finish(&chatpb.ResetSessionResponse{RequestId: request.RequestId, Outcome: &chatpb.ResetSessionResponse_Accepted{Accepted: &chatpb.ResetSessionReceipt{ - Previous: closeResponse.GetAccepted(), Current: sessionRecord(current), + Previous: closeResponse.GetAccepted(), Current: current, }}}) } func (s *connectSessionServer) DeleteSession(ctx context.Context, req *connect.Request[chatpb.DeleteSessionRequest]) (*connect.Response[chatpb.DeleteSessionResponse], error) { request := req.Msg if request == nil || strings.TrimSpace(request.RequestId) == "" { - return connect.NewResponse(rejectedDelete(request, codes.InvalidArgument, "request_id is required")), nil + return connect.NewResponse(rejectedDelete(request, "INVALID_ARGUMENT", "request_id is required")), nil } s.mu.Lock() defer s.mu.Unlock() @@ -158,7 +149,7 @@ func (s *connectSessionServer) DeleteSession(ctx context.Context, req *connect.R return connect.NewResponse(replayed), nil } if conflict { - return connect.NewResponse(rejectedDelete(request, codes.AlreadyExists, "request_id conflicts with another request")), nil + return connect.NewResponse(rejectedDelete(request, "ALREADY_EXISTS", "request_id conflicts with another request")), nil } finish := func(response *chatpb.DeleteSessionResponse) (*connect.Response[chatpb.DeleteSessionResponse], error) { if err := s.finishRequest(ctx, "DeleteSession", request.RequestId, hash, response); err != nil { @@ -169,7 +160,7 @@ func (s *connectSessionServer) DeleteSession(ctx context.Context, req *connect.R session, err := s.service.store.GetSession(ctx, request.SessionId) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return finish(rejectedDelete(request, codes.NotFound, "session not found")) + return finish(rejectedDelete(request, "NOT_FOUND", "session not found")) } return nil, connect.NewError(connect.CodeInternal, err) } @@ -177,7 +168,7 @@ func (s *connectSessionServer) DeleteSession(ctx context.Context, req *connect.R return nil, connect.NewError(connect.CodeInternal, err) } return finish(&chatpb.DeleteSessionResponse{RequestId: request.RequestId, Outcome: &chatpb.DeleteSessionResponse_Accepted{Accepted: &aop.Session{ - Id: session.ID, State: "deleted", Participant: session.AgentID, Title: session.Title, + Id: session.GetSession().GetId(), State: "deleted", NodeUri: session.GetSession().GetNodeUri(), Title: session.GetSession().GetTitle(), }}}) } @@ -186,89 +177,12 @@ func (s *connectSessionServer) ListCommands(_ context.Context, req *connect.Requ return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("session_id is required")) } specs := s.service.SessionMenu(req.Msg.SessionId) - response := &chatpb.ListCommandsResponse{Commands: make([]*chatpb.CommandSpec, 0, len(specs))} - for _, spec := range specs { - response.Commands = append(response.Commands, &chatpb.CommandSpec{Name: spec.Name, Aliases: spec.Aliases, Usage: spec.Usage, Description: spec.Description}) - } - return connect.NewResponse(response), nil + return connect.NewResponse(&chatpb.ListCommandsResponse{Commands: cloneCommandSpecs(specs)}), nil } -func (s *connectSessionServer) ExecuteCommand(ctx context.Context, req *connect.Request[chatpb.ExecuteCommandRequest]) (*connect.Response[chatpb.ExecuteCommandResponse], error) { - request := req.Msg - if request == nil || strings.TrimSpace(request.RequestId) == "" { - return connect.NewResponse(rejectedCommand(request, codes.InvalidArgument, "request_id is required")), nil - } - s.mu.Lock() - defer s.mu.Unlock() - replayed := new(chatpb.ExecuteCommandResponse) - hash, found, conflict, err := s.beginRequest(ctx, "ExecuteCommand", request.RequestId, request, replayed) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - if found { - return connect.NewResponse(replayed), nil - } - if conflict { - return connect.NewResponse(rejectedCommand(request, codes.AlreadyExists, "request_id conflicts with another request")), nil - } - finish := func(response *chatpb.ExecuteCommandResponse) (*connect.Response[chatpb.ExecuteCommandResponse], error) { - if err := s.finishRequest(ctx, "ExecuteCommand", request.RequestId, hash, response); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - return connect.NewResponse(response), nil - } - operationID, err := s.service.ExecuteSessionCommand(request.SessionId, request.Line) - if err != nil { - code := codes.FailedPrecondition - if errors.Is(err, ErrSessionNotFound) { - code = codes.NotFound - } - return finish(rejectedCommand(request, code, err.Error())) - } - return finish(&chatpb.ExecuteCommandResponse{RequestId: request.RequestId, Outcome: &chatpb.ExecuteCommandResponse_Accepted{Accepted: &chatpb.CommandReceipt{ - OperationId: operationID, SessionId: request.SessionId, State: "running", - }}}) -} - -func (s *connectSessionServer) UploadSessionFile(ctx context.Context, req *connect.Request[chatpb.UploadSessionFileRequest]) (*connect.Response[chatpb.UploadSessionFileResponse], error) { - request := req.Msg - if request == nil || strings.TrimSpace(request.RequestId) == "" { - return connect.NewResponse(rejectedUpload(request, codes.InvalidArgument, "request_id is required")), nil - } - if len(request.Data) > maxUploadSize { - return connect.NewResponse(rejectedUpload(request, codes.ResourceExhausted, ErrUploadTooLarge.Error())), nil - } - s.mu.Lock() - defer s.mu.Unlock() - replayed := new(chatpb.UploadSessionFileResponse) - hash, found, conflict, err := s.beginRequest(ctx, "UploadSessionFile", request.RequestId, request, replayed) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - if found { - return connect.NewResponse(replayed), nil - } - if conflict { - return connect.NewResponse(rejectedUpload(request, codes.AlreadyExists, "request_id conflicts with another request")), nil - } - finish := func(response *chatpb.UploadSessionFileResponse) (*connect.Response[chatpb.UploadSessionFileResponse], error) { - if err := s.finishRequest(ctx, "UploadSessionFile", request.RequestId, hash, response); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - return connect.NewResponse(response), nil - } - result, err := s.service.HandleFileUpload(ctx, request.SessionId, request.Filename, request.Data) - if err != nil { - code := codes.FailedPrecondition - if errors.Is(err, ErrSessionNotFound) { - code = codes.NotFound - } - return finish(rejectedUpload(request, code, err.Error())) - } - mediaType := request.MediaType - return finish(&chatpb.UploadSessionFileResponse{RequestId: request.RequestId, Outcome: &chatpb.UploadSessionFileResponse_Accepted{Accepted: &chatpb.UploadedFile{ - Filename: result.Filename, Path: result.Path, Size: int64(result.Size), MediaType: mediaType, - }}}) +func (s *connectSessionServer) ListEvents(ctx context.Context, req *connect.Request[aop.ListEventsRequest]) (*connect.Response[aop.ListEventsResponse], error) { + response, err := s.chat.ListEvents(ctx, req.Msg) + return connectResponse(response, err) } func (s *connectSessionServer) beginRequest(ctx context.Context, method, requestID string, request, response proto.Message) ([]byte, bool, bool, error) { @@ -285,22 +199,7 @@ func (s *connectSessionServer) finishRequest(ctx context.Context, method, reques return s.service.store.SaveAOPRequest(ctx, requestID, method, hash, response) } -func sessionRecord(session *ChatSession) *chatpb.SessionRecord { - if session == nil { - return nil - } - state := "open" - if session.Status != SessionActive { - state = "closed" - } - return &chatpb.SessionRecord{ - Session: &aop.Session{Id: session.ID, State: state, Participant: session.AgentID, Title: session.Title}, - AgentName: session.AgentName, ScanIds: append([]string(nil), session.ScanIDs...), - CreatedAt: timestamppb.New(session.CreatedAt), UpdatedAt: timestamppb.New(session.UpdatedAt), - } -} - -func rejectedReset(req *chatpb.ResetSessionRequest, code codes.Code, message string) *chatpb.ResetSessionResponse { +func rejectedReset(req *chatpb.ResetSessionRequest, code, message string) *chatpb.ResetSessionResponse { response := &chatpb.ResetSessionResponse{Outcome: &chatpb.ResetSessionResponse_Rejected{Rejected: rejection(code, message)}} if req != nil { response.RequestId = req.RequestId @@ -308,26 +207,10 @@ func rejectedReset(req *chatpb.ResetSessionRequest, code codes.Code, message str return response } -func rejectedDelete(req *chatpb.DeleteSessionRequest, code codes.Code, message string) *chatpb.DeleteSessionResponse { +func rejectedDelete(req *chatpb.DeleteSessionRequest, code, message string) *chatpb.DeleteSessionResponse { response := &chatpb.DeleteSessionResponse{Outcome: &chatpb.DeleteSessionResponse_Rejected{Rejected: rejection(code, message)}} if req != nil { response.RequestId = req.RequestId } return response } - -func rejectedCommand(req *chatpb.ExecuteCommandRequest, code codes.Code, message string) *chatpb.ExecuteCommandResponse { - response := &chatpb.ExecuteCommandResponse{Outcome: &chatpb.ExecuteCommandResponse_Rejected{Rejected: rejection(code, message)}} - if req != nil { - response.RequestId = req.RequestId - } - return response -} - -func rejectedUpload(req *chatpb.UploadSessionFileRequest, code codes.Code, message string) *chatpb.UploadSessionFileResponse { - response := &chatpb.UploadSessionFileResponse{Outcome: &chatpb.UploadSessionFileResponse_Rejected{Rejected: rejection(code, message)}} - if req != nil { - response.RequestId = req.RequestId - } - return response -} diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go index a1b96529..178030bf 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -11,9 +11,10 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - "github.com/chainreactors/aiscan/core/output" - "google.golang.org/protobuf/encoding/protojson" + chatpb "github.com/chainreactors/aiscan/pkg/types/chat" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" protobuf "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" _ "modernc.org/sqlite" ) @@ -21,6 +22,8 @@ type SQLiteStore struct { db *sql.DB } +const sqliteSchemaVersion = 1 + func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000&_pragma=foreign_keys(1)") if err != nil { @@ -44,157 +47,97 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { } func migrate(db *sql.DB) error { - if _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS scans ( + var version int + if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { + return err + } + if version == sqliteSchemaVersion { + return nil + } + if version != 0 { + return fmt.Errorf("unsupported sqlite schema version %d; delete the database and restart", version) + } + var tables int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`).Scan(&tables); err != nil { + return err + } + if tables != 0 { + return fmt.Errorf("legacy sqlite schema is not supported; delete the database and restart") + } + + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(` + CREATE TABLE scans ( id TEXT PRIMARY KEY, target TEXT NOT NULL, - mode TEXT NOT NULL DEFAULT 'quick', - ai INTEGER NOT NULL DEFAULT 0, - verify INTEGER NOT NULL DEFAULT 0, - sniper INTEGER NOT NULL DEFAULT 0, - deep INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'queued', - progress TEXT NOT NULL DEFAULT '', - report TEXT NOT NULL DEFAULT '', - result TEXT NOT NULL DEFAULT '', - error TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + scan_proto BLOB NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS chat_sessions ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL DEFAULT '', - agent_name TEXT NOT NULL DEFAULT '', - title TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + CREATE TABLE chat_sessions ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + status TEXT NOT NULL, + session_proto BLOB NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS chat_aop_events ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, - cursor INTEGER NOT NULL DEFAULT 0, - event_json TEXT NOT NULL, - created_at TEXT NOT NULL + CREATE TABLE chat_aop_events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + cursor INTEGER NOT NULL, + event_proto BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (session_id, cursor) ); - CREATE TABLE IF NOT EXISTS session_scans ( + CREATE TABLE session_scans ( session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, - scan_id TEXT NOT NULL, + scan_id TEXT NOT NULL REFERENCES scans(id) ON DELETE CASCADE, PRIMARY KEY (session_id, scan_id) ); - CREATE TABLE IF NOT EXISTS aop_request_journal ( - request_id TEXT PRIMARY KEY, - method TEXT NOT NULL, - request_hash BLOB NOT NULL, - response_json TEXT NOT NULL, - created_at TEXT NOT NULL + CREATE TABLE aop_request_journal ( + request_id TEXT PRIMARY KEY, + method TEXT NOT NULL, + request_hash BLOB NOT NULL, + response_proto BLOB NOT NULL, + created_at TEXT NOT NULL ); - `); err != nil { - return err - } - if err := renameAOPCursorColumn(db); err != nil { - return err - } - for _, column := range []sqliteColumnMigration{ - {table: "scans", name: "mode", definition: "TEXT NOT NULL DEFAULT 'quick'"}, - {table: "scans", name: "ai", definition: "INTEGER NOT NULL DEFAULT 0"}, - {table: "scans", name: "verify", definition: "INTEGER NOT NULL DEFAULT 0"}, - {table: "scans", name: "sniper", definition: "INTEGER NOT NULL DEFAULT 0"}, - {table: "scans", name: "deep", definition: "INTEGER NOT NULL DEFAULT 0"}, - {table: "scans", name: "progress", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "scans", name: "report", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "scans", name: "result", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "scans", name: "error", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_sessions", name: "agent_id", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_sessions", name: "agent_name", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_sessions", name: "title", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_sessions", name: "status", definition: "TEXT NOT NULL DEFAULT 'active'"}, - {table: "chat_sessions", name: "topic_id", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_messages", name: "agent_id", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_messages", name: "agent_name", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_messages", name: "metadata", definition: "TEXT NOT NULL DEFAULT ''"}, - {table: "chat_aop_events", name: "cursor", definition: "INTEGER NOT NULL DEFAULT 0"}, - } { - if err := ensureSQLiteColumn(db, column); err != nil { - return err - } - } - if _, err := db.Exec(` - DROP TABLE IF EXISTS temp.aop_cursor_backfill; - CREATE TEMP TABLE aop_cursor_backfill (row_id INTEGER PRIMARY KEY, cursor INTEGER NOT NULL); - INSERT INTO aop_cursor_backfill (row_id, cursor) - SELECT target.rowid, - COALESCE(( - SELECT MAX(existing.cursor) - FROM chat_aop_events AS existing - WHERE existing.session_id = target.session_id AND existing.cursor > 0 - ), 0) + ROW_NUMBER() OVER ( - PARTITION BY target.session_id ORDER BY target.created_at, target.rowid - ) - FROM chat_aop_events AS target - WHERE target.cursor = 0; - UPDATE chat_aop_events - SET cursor = (SELECT backfill.cursor FROM aop_cursor_backfill AS backfill WHERE backfill.row_id = chat_aop_events.rowid) - WHERE rowid IN (SELECT row_id FROM aop_cursor_backfill); - DROP TABLE aop_cursor_backfill; - `); err != nil { - return err - } - - if _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS records ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL, - scan_id TEXT NOT NULL DEFAULT '', - session_id TEXT NOT NULL DEFAULT '', - agent_id TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', - target TEXT NOT NULL DEFAULT '', - turn INTEGER NOT NULL DEFAULT 0, - priority TEXT NOT NULL DEFAULT '', - summary TEXT NOT NULL DEFAULT '', - loot INTEGER NOT NULL DEFAULT 0, - tags TEXT NOT NULL DEFAULT '', - data TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - ); - `); err != nil { - return err - } - - if _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS sco_nodes ( + CREATE TABLE sco_nodes ( cstx_id TEXT PRIMARY KEY, cstx_type TEXT NOT NULL, data TEXT NOT NULL, - scan_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); - `); err != nil { - return err - } - if err := ensureSessionForeignKeys(db); err != nil { - return err - } - if _, err := db.Exec(` - CREATE INDEX IF NOT EXISTS idx_scans_created ON scans(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_sessions_updated ON chat_sessions(updated_at DESC); - CREATE INDEX IF NOT EXISTS idx_sessions_agent ON chat_sessions(agent_id); - CREATE INDEX IF NOT EXISTS idx_aop_events_session ON chat_aop_events(session_id, created_at, id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_aop_events_session_cursor ON chat_aop_events(session_id, cursor); - CREATE INDEX IF NOT EXISTS idx_sco_nodes_type ON sco_nodes(cstx_type); - CREATE INDEX IF NOT EXISTS idx_sco_nodes_scan ON sco_nodes(scan_id); + CREATE TABLE sco_observations ( + operation_id TEXT NOT NULL, + cstx_id TEXT NOT NULL REFERENCES sco_nodes(cstx_id) ON DELETE CASCADE, + observed_at TEXT NOT NULL, + PRIMARY KEY (operation_id, cstx_id) + ); + + CREATE INDEX idx_scans_created ON scans(created_at DESC); + CREATE INDEX idx_sessions_updated ON chat_sessions(updated_at DESC); + CREATE INDEX idx_sessions_agent ON chat_sessions(agent_id); + CREATE INDEX idx_aop_events_session ON chat_aop_events(session_id, cursor); + CREATE INDEX idx_sco_nodes_type ON sco_nodes(cstx_type); + CREATE INDEX idx_sco_observations_node ON sco_observations(cstx_id); + PRAGMA user_version = 1; `); err != nil { return err } - return nil + return tx.Commit() } func (s *SQLiteStore) LoadAOPRequest(ctx context.Context, requestID, method string, requestHash []byte, response protobuf.Message) (found, conflict bool, err error) { @@ -203,9 +146,9 @@ func (s *SQLiteStore) LoadAOPRequest(ctx context.Context, requestID, method stri } var storedMethod string var storedHash []byte - var raw string + var raw []byte err = s.db.QueryRowContext(ctx, - `SELECT method, request_hash, response_json FROM aop_request_journal WHERE request_id = ?`, requestID, + `SELECT method, request_hash, response_proto FROM aop_request_journal WHERE request_id = ?`, requestID, ).Scan(&storedMethod, &storedHash, &raw) if errors.Is(err, sql.ErrNoRows) { return false, false, nil @@ -216,7 +159,7 @@ func (s *SQLiteStore) LoadAOPRequest(ctx context.Context, requestID, method stri if storedMethod != method || !bytes.Equal(storedHash, requestHash) { return false, true, nil } - if err := protojson.Unmarshal([]byte(raw), response); err != nil { + if err := protobuf.Unmarshal(raw, response); err != nil { return false, false, err } return true, false, nil @@ -226,276 +169,79 @@ func (s *SQLiteStore) SaveAOPRequest(ctx context.Context, requestID, method stri if s == nil || strings.TrimSpace(requestID) == "" || response == nil { return nil } - raw, err := protojson.Marshal(response) + raw, err := protobuf.Marshal(response) if err != nil { return err } _, err = s.db.ExecContext(ctx, ` - INSERT INTO aop_request_journal (request_id, method, request_hash, response_json, created_at) + INSERT INTO aop_request_journal (request_id, method, request_hash, response_proto, created_at) VALUES (?, ?, ?, ?, ?) - `, requestID, method, requestHash, string(raw), time.Now().UTC().Format(time.RFC3339Nano)) - return err -} - -func ensureSessionForeignKeys(db *sql.DB) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - aopConstrained, err := hasCascadeForeignKey(tx, "chat_aop_events", "session_id", "chat_sessions", "id") - if err != nil { - return err - } - if aopConstrained { - if _, err := tx.Exec(` - DELETE FROM chat_aop_events - WHERE NOT EXISTS ( - SELECT 1 FROM chat_sessions WHERE chat_sessions.id = chat_aop_events.session_id - ) - `); err != nil { - return err - } - } else if err := rebuildAOPEventsWithForeignKey(tx); err != nil { - return err - } - - scansConstrained, err := hasCascadeForeignKey(tx, "session_scans", "session_id", "chat_sessions", "id") - if err != nil { - return err - } - if scansConstrained { - if _, err := tx.Exec(` - DELETE FROM session_scans - WHERE NOT EXISTS ( - SELECT 1 FROM chat_sessions WHERE chat_sessions.id = session_scans.session_id - ) - `); err != nil { - return err - } - } else if err := rebuildSessionScansWithForeignKey(tx); err != nil { - return err - } - - rows, err := tx.Query(`PRAGMA foreign_key_check`) - if err != nil { - return err - } - violated := rows.Next() - rowsErr := rows.Err() - if err := rows.Close(); err != nil { - return err - } - if rowsErr != nil { - return rowsErr - } - if violated { - return fmt.Errorf("sqlite foreign key check failed after migration") - } - return tx.Commit() -} - -func hasCascadeForeignKey(tx *sql.Tx, table, from, parent, to string) (bool, error) { - rows, err := tx.Query(`PRAGMA foreign_key_list(` + quoteSQLiteIdent(table) + `)`) - if err != nil { - return false, err - } - defer rows.Close() - for rows.Next() { - var ( - id, seq int - parentTable, fromColumn, toColumn string - onUpdate, onDelete, match string - ) - if err := rows.Scan(&id, &seq, &parentTable, &fromColumn, &toColumn, &onUpdate, &onDelete, &match); err != nil { - return false, err - } - if parentTable == parent && fromColumn == from && toColumn == to && strings.EqualFold(onDelete, "CASCADE") { - return true, nil - } - } - return false, rows.Err() -} - -func rebuildAOPEventsWithForeignKey(tx *sql.Tx) error { - _, err := tx.Exec(` - DROP TABLE IF EXISTS chat_aop_events_fk_migration; - CREATE TABLE chat_aop_events_fk_migration ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, - cursor INTEGER NOT NULL, - event_json TEXT NOT NULL, - created_at TEXT NOT NULL - ); - INSERT INTO chat_aop_events_fk_migration (rowid, id, session_id, cursor, event_json, created_at) - SELECT events.rowid, events.id, events.session_id, events.cursor, events.event_json, events.created_at - FROM chat_aop_events AS events - WHERE EXISTS ( - SELECT 1 FROM chat_sessions WHERE chat_sessions.id = events.session_id - ) - ORDER BY events.rowid; - DROP TABLE chat_aop_events; - ALTER TABLE chat_aop_events_fk_migration RENAME TO chat_aop_events; - `) - return err -} - -func rebuildSessionScansWithForeignKey(tx *sql.Tx) error { - _, err := tx.Exec(` - DROP TABLE IF EXISTS session_scans_fk_migration; - CREATE TABLE session_scans_fk_migration ( - session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, - scan_id TEXT NOT NULL, - PRIMARY KEY (session_id, scan_id) - ); - INSERT INTO session_scans_fk_migration (session_id, scan_id) - SELECT links.session_id, links.scan_id - FROM session_scans AS links - WHERE EXISTS ( - SELECT 1 FROM chat_sessions WHERE chat_sessions.id = links.session_id - ); - DROP TABLE session_scans; - ALTER TABLE session_scans_fk_migration RENAME TO session_scans; - `) + `, requestID, method, requestHash, raw, time.Now().UTC().Format(time.RFC3339Nano)) return err } -type sqliteColumnMigration struct { - table string - name string - definition string +func (s *SQLiteStore) Close() error { + return s.db.Close() } -func renameAOPCursorColumn(db *sql.DB) error { - hasOld, err := sqliteColumnExists(db, "chat_aop_events", "hub_seq") - if err != nil || !hasOld { - return err - } - hasCursor, err := sqliteColumnExists(db, "chat_aop_events", "cursor") - if err != nil { - return err - } - if hasCursor { - return fmt.Errorf("chat_aop_events contains both hub_seq and cursor") - } - _, err = db.Exec(` - DROP INDEX IF EXISTS idx_aop_events_session_seq; - ALTER TABLE chat_aop_events RENAME COLUMN hub_seq TO cursor; - `) - return err -} +// ── Scans ── +// +// scan_proto is the canonical payload. Flat columns exist only for filtering +// and ordering; they never reconstruct the protobuf message. -func ensureSQLiteColumn(db *sql.DB, column sqliteColumnMigration) error { - tableExists, err := sqliteTableExists(db, column.table) - if err != nil || !tableExists { - return err - } - exists, err := sqliteColumnExists(db, column.table, column.name) +func (s *SQLiteStore) Create(ctx context.Context, scan *scanpb.Scan) error { + raw, err := protobuf.Marshal(scan) if err != nil { return err } - if exists { - return nil - } - _, err = db.Exec(fmt.Sprintf( - "ALTER TABLE %s ADD COLUMN %s %s", - quoteSQLiteIdent(column.table), - quoteSQLiteIdent(column.name), - column.definition, - )) - return err -} - -func sqliteTableExists(db *sql.DB, table string) (bool, error) { - var count int - err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count) - return count > 0, err -} - -func sqliteColumnExists(db *sql.DB, table, column string) (bool, error) { - rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", quoteSQLiteIdent(table))) - if err != nil { - return false, err - } - defer rows.Close() - - for rows.Next() { - var ( - cid int - name string - columnType string - notNull int - defaultValue sql.NullString - pk int - ) - if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk); err != nil { - return false, err - } - if name == column { - return true, nil - } - } - return false, rows.Err() -} - -func quoteSQLiteIdent(value string) string { - return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` -} - -func (s *SQLiteStore) Close() error { - return s.db.Close() -} - -func (s *SQLiteStore) Create(ctx context.Context, job *ScanJob) error { - resultJSON := marshalResult(job) - _, err := s.db.ExecContext(ctx, - `INSERT INTO scans (id, target, mode, ai, verify, sniper, deep, status, progress, report, result, error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - job.ID, job.Target, job.Mode, boolToInt(job.Verify || job.Sniper), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep), - string(job.Status), job.Progress, job.Report, resultJSON, job.Error, - job.CreatedAt.Format(time.RFC3339Nano), job.UpdatedAt.Format(time.RFC3339Nano), + _, err = s.db.ExecContext(ctx, + `INSERT INTO scans (id, target, status, scan_proto, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + scan.Id, scan.Target, scanStatusToDB(scan.Status), raw, + formatProtoTime(scan.CreatedAt), formatProtoTime(scan.UpdatedAt), ) return err } -func (s *SQLiteStore) Get(ctx context.Context, id string) (*ScanJob, error) { +func (s *SQLiteStore) Get(ctx context.Context, id string) (*scanpb.Scan, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, target, mode, ai, verify, sniper, deep, status, progress, report, result, error, created_at, updated_at + `SELECT scan_proto FROM scans WHERE id = ?`, id) return scanRow(row) } -func (s *SQLiteStore) List(ctx context.Context, limit int) ([]*ScanJob, error) { +func (s *SQLiteStore) List(ctx context.Context, limit int) ([]*scanpb.Scan, error) { if limit <= 0 { limit = 50 } rows, err := s.db.QueryContext(ctx, - `SELECT id, target, mode, ai, verify, sniper, deep, status, progress, report, result, error, created_at, updated_at + `SELECT scan_proto FROM scans ORDER BY created_at DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() - var jobs []*ScanJob + var scans []*scanpb.Scan for rows.Next() { - job, err := scanRows(rows) + scan, err := scanRows(rows) if err != nil { return nil, err } - jobs = append(jobs, job) + scans = append(scans, scan) } - return jobs, rows.Err() + return scans, rows.Err() } -func (s *SQLiteStore) Update(ctx context.Context, job *ScanJob) error { - resultJSON := marshalResult(job) - _, err := s.db.ExecContext(ctx, - `UPDATE scans SET ai=?, verify=?, sniper=?, deep=?, status=?, progress=?, report=?, result=?, error=?, updated_at=? WHERE id=?`, - boolToInt(job.Verify || job.Sniper), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep), - string(job.Status), job.Progress, job.Report, resultJSON, job.Error, - job.UpdatedAt.Format(time.RFC3339Nano), job.ID, +func (s *SQLiteStore) Update(ctx context.Context, scan *scanpb.Scan) error { + raw, err := protobuf.Marshal(scan) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `UPDATE scans SET status=?, scan_proto=?, updated_at=? WHERE id=?`, + scanStatusToDB(scan.Status), raw, + formatProtoTime(scan.UpdatedAt), scan.Id, ) return err } @@ -503,26 +249,29 @@ func (s *SQLiteStore) Update(ctx context.Context, job *ScanJob) error { // TransitionScan updates a scan only while it is in one of the expected // states. Callers use the affected-row result to make terminal states // immutable when cancellation and completion race. -func (s *SQLiteStore) TransitionScan(ctx context.Context, job *ScanJob, expected ...ScanStatus) (bool, error) { - if job == nil { - return false, fmt.Errorf("scan job is required") +func (s *SQLiteStore) TransitionScan(ctx context.Context, scan *scanpb.Scan, expected ...scanpb.ScanStatus) (bool, error) { + if scan == nil { + return false, fmt.Errorf("scan is required") } if len(expected) == 0 { return false, fmt.Errorf("at least one expected scan status is required") } + raw, err := protobuf.Marshal(scan) + if err != nil { + return false, err + } placeholders := make([]string, len(expected)) args := []any{ - boolToInt(job.Verify || job.Sniper), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep), - string(job.Status), job.Progress, job.Report, marshalResult(job), job.Error, - job.UpdatedAt.Format(time.RFC3339Nano), job.ID, + scanStatusToDB(scan.Status), raw, + formatProtoTime(scan.UpdatedAt), scan.Id, } for i, status := range expected { placeholders[i] = "?" - args = append(args, string(status)) + args = append(args, scanStatusToDB(status)) } //nolint:gosec // only fixed "?" placeholders are concatenated; statuses remain bound arguments - query := `UPDATE scans SET ai=?, verify=?, sniper=?, deep=?, status=?, progress=?, report=?, result=?, error=?, updated_at=? + query := `UPDATE scans SET status=?, scan_proto=?, updated_at=? WHERE id=? AND status IN (` + strings.Join(placeholders, ",") + `)` result, err := s.db.ExecContext(ctx, query, args...) if err != nil { @@ -544,26 +293,16 @@ type scanner interface { Scan(dest ...any) error } -func scanFromScanner(sc scanner) (*ScanJob, error) { - var job ScanJob - var status, resultJSON, createdAt, updatedAt string - var ai, verify, sniper, deep int - err := sc.Scan(&job.ID, &job.Target, &job.Mode, &ai, &verify, &sniper, &deep, &status, - &job.Progress, &job.Report, &resultJSON, &job.Error, &createdAt, &updatedAt) - if err != nil { +func scanFromScanner(sc scanner) (*scanpb.Scan, error) { + var raw []byte + if err := sc.Scan(&raw); err != nil { return nil, err } - _ = ai - job.Verify = verify != 0 - job.Sniper = sniper != 0 - job.Deep = deep != 0 - job.Status = ScanStatus(status) - if resultJSON != "" { - _ = json.Unmarshal([]byte(resultJSON), &job.Result) - } - job.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) - job.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) - return &job, nil + scan := new(scanpb.Scan) + if err := protobuf.Unmarshal(raw, scan); err != nil { + return nil, fmt.Errorf("decode scan protobuf: %w", err) + } + return scan, nil } func boolToInt(value bool) int { @@ -573,76 +312,77 @@ func boolToInt(value bool) int { return 0 } -func marshalResult(job *ScanJob) string { - if job == nil || job.Result == nil { - return "" - } - data, err := json.Marshal(job.Result) - if err != nil { - return "" +func formatProtoTime(ts *timestamppb.Timestamp) string { + if ts == nil { + return time.Now().UTC().Format(time.RFC3339Nano) } - return string(data) + return ts.AsTime().UTC().Format(time.RFC3339Nano) } -func scanRow(row *sql.Row) (*ScanJob, error) { +func scanRow(row *sql.Row) (*scanpb.Scan, error) { return scanFromScanner(row) } -func scanRows(rows *sql.Rows) (*ScanJob, error) { +func scanRows(rows *sql.Rows) (*scanpb.Scan, error) { return scanFromScanner(rows) } // --- Chat session CRUD --- +// +// session_proto is the canonical payload. Flat columns exist only for +// filtering and ordering. -func (s *SQLiteStore) CreateSession(ctx context.Context, session *ChatSession) error { - _, err := s.db.ExecContext(ctx, - `INSERT INTO chat_sessions (id, agent_id, agent_name, title, status, topic_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - session.ID, session.AgentID, session.AgentName, session.Title, session.Status, session.TopicID, - session.CreatedAt.Format(time.RFC3339Nano), session.UpdatedAt.Format(time.RFC3339Nano), +func (s *SQLiteStore) CreateSession(ctx context.Context, session *chatpb.SessionRecord) error { + raw, err := protobuf.Marshal(session) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `INSERT INTO chat_sessions (id, agent_id, status, session_proto, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + session.GetSession().GetId(), session.GetSession().GetParticipant(), session.GetSession().GetState(), + raw, + formatProtoTime(session.CreatedAt), formatProtoTime(session.UpdatedAt), ) return err } -func (s *SQLiteStore) GetSession(ctx context.Context, id string) (*ChatSession, error) { +func (s *SQLiteStore) GetSession(ctx context.Context, id string) (*chatpb.SessionRecord, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, agent_id, agent_name, title, status, topic_id, created_at, updated_at FROM chat_sessions WHERE id = ?`, id) - var cs ChatSession - var createdAt, updatedAt string - if err := row.Scan(&cs.ID, &cs.AgentID, &cs.AgentName, &cs.Title, &cs.Status, &cs.TopicID, &createdAt, &updatedAt); err != nil { + `SELECT session_proto FROM chat_sessions WHERE id = ?`, id) + var raw []byte + if err := row.Scan(&raw); err != nil { return nil, err } - cs.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) - cs.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) - scanIDs, _ := s.SessionScanIDs(ctx, id) - cs.ScanIDs = scanIDs - return &cs, nil + session := new(chatpb.SessionRecord) + if err := protobuf.Unmarshal(raw, session); err != nil { + return nil, fmt.Errorf("decode session protobuf: %w", err) + } + session.ScanIds, _ = s.SessionScanIDs(ctx, id) + return session, nil } -func (s *SQLiteStore) ListSessions(ctx context.Context, limit int) ([]*ChatSession, error) { +func (s *SQLiteStore) ListSessions(ctx context.Context, limit int) ([]*chatpb.SessionRecord, error) { if limit <= 0 { limit = 100 } rows, err := s.db.QueryContext(ctx, - `SELECT id, agent_id, agent_name, title, status, topic_id, created_at, updated_at FROM chat_sessions ORDER BY updated_at DESC LIMIT ?`, limit) + `SELECT session_proto FROM chat_sessions ORDER BY updated_at DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() - var sessions []*ChatSession + var sessions []*chatpb.SessionRecord for rows.Next() { - var cs ChatSession - var createdAt, updatedAt string - if err := rows.Scan(&cs.ID, &cs.AgentID, &cs.AgentName, &cs.Title, &cs.Status, &cs.TopicID, &createdAt, &updatedAt); err != nil { + session, err := sessionFromRow(rows) + if err != nil { return nil, err } - cs.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) - cs.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) - sessions = append(sessions, &cs) + sessions = append(sessions, session) } return sessions, rows.Err() } -func (s *SQLiteStore) ListSessionPage(ctx context.Context, offset, limit int, includeClosed bool) ([]*ChatSession, bool, error) { +func (s *SQLiteStore) ListSessionPage(ctx context.Context, offset, limit int, includeClosed bool) ([]*chatpb.SessionRecord, bool, error) { if offset < 0 { offset = 0 } @@ -652,29 +392,26 @@ func (s *SQLiteStore) ListSessionPage(ctx context.Context, offset, limit int, in if limit > 500 { limit = 500 } - query := `SELECT id, agent_id, agent_name, title, status, topic_id, created_at, updated_at + query := `SELECT session_proto FROM chat_sessions ORDER BY updated_at DESC LIMIT ? OFFSET ?` args := []any{limit + 1, offset} if !includeClosed { - query = `SELECT id, agent_id, agent_name, title, status, topic_id, created_at, updated_at + query = `SELECT session_proto FROM chat_sessions WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?` - args = []any{SessionActive, limit + 1, offset} + args = []any{SessionStateOpen, limit + 1, offset} } rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, false, err } - sessions := make([]*ChatSession, 0, limit+1) + sessions := make([]*chatpb.SessionRecord, 0, limit+1) for rows.Next() { - var cs ChatSession - var createdAt, updatedAt string - if err := rows.Scan(&cs.ID, &cs.AgentID, &cs.AgentName, &cs.Title, &cs.Status, &cs.TopicID, &createdAt, &updatedAt); err != nil { + session, err := sessionFromRow(rows) + if err != nil { _ = rows.Close() return nil, false, err } - cs.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) - cs.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) - sessions = append(sessions, &cs) + sessions = append(sessions, session) } if err := rows.Err(); err != nil { _ = rows.Close() @@ -687,7 +424,8 @@ func (s *SQLiteStore) ListSessionPage(ctx context.Context, offset, limit int, in // the session row set; querying SessionScanIDs inside rows.Next would wait on // the connection held by the outer query and deadlock every non-empty page. for _, session := range sessions { - session.ScanIDs, _ = s.SessionScanIDs(ctx, session.ID) + scanIDs, _ := s.SessionScanIDs(ctx, session.GetSession().GetId()) + session.ScanIds = scanIDs } hasMore := len(sessions) > limit if hasMore { @@ -696,10 +434,31 @@ func (s *SQLiteStore) ListSessionPage(ctx context.Context, offset, limit int, in return sessions, hasMore, nil } -func (s *SQLiteStore) UpdateSession(ctx context.Context, session *ChatSession) error { - _, err := s.db.ExecContext(ctx, - `UPDATE chat_sessions SET title=?, status=?, topic_id=?, updated_at=? WHERE id=?`, - session.Title, session.Status, session.TopicID, session.UpdatedAt.Format(time.RFC3339Nano), session.ID, +func sessionFromRow(rows *sql.Rows) (*chatpb.SessionRecord, error) { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, err + } + session := new(chatpb.SessionRecord) + if err := protobuf.Unmarshal(raw, session); err != nil { + return nil, fmt.Errorf("decode session protobuf: %w", err) + } + return session, nil +} + +func (s *SQLiteStore) UpdateSession(ctx context.Context, session *chatpb.SessionRecord) error { + scanIDs, _ := s.SessionScanIDs(ctx, session.GetSession().GetId()) + if len(scanIDs) > 0 { + session.ScanIds = scanIDs + } + raw, err := protobuf.Marshal(session) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `UPDATE chat_sessions SET status=?, session_proto=?, updated_at=? WHERE id=?`, + session.GetSession().GetState(), raw, + formatProtoTime(session.UpdatedAt), session.GetSession().GetId(), ) return err } @@ -723,7 +482,7 @@ func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, even if event == nil || event.GetMessageDelta() != nil || event.GetToolCallDelta() != nil { return 0, false, nil } - raw, err := protojson.Marshal(event) + raw, err := protobuf.Marshal(event) if err != nil { return 0, false, err } @@ -742,8 +501,8 @@ func (s *SQLiteStore) AppendAOPEvent(ctx context.Context, sessionID string, even return 0, false, err } if _, err := tx.ExecContext(ctx, - `INSERT INTO chat_aop_events (id, session_id, cursor, event_json, created_at) VALUES (?, ?, ?, ?, ?)`, - generateID(), sessionID, cursor, string(raw), createdAt, + `INSERT INTO chat_aop_events (id, session_id, cursor, event_proto, created_at) VALUES (?, ?, ?, ?, ?)`, + generateID(), sessionID, cursor, raw, createdAt, ); err != nil { return 0, false, err } @@ -766,19 +525,19 @@ func (s *SQLiteStore) ListAOPEvents(ctx context.Context, sessionID string, limit } func (s *SQLiteStore) MaxAOPEventSeq(ctx context.Context, sessionID string) (uint64, error) { - rows, err := s.db.QueryContext(ctx, `SELECT event_json FROM chat_aop_events WHERE session_id = ?`, sessionID) + rows, err := s.db.QueryContext(ctx, `SELECT event_proto FROM chat_aop_events WHERE session_id = ?`, sessionID) if err != nil { return 0, err } defer rows.Close() var maximum uint64 for rows.Next() { - var raw string + var raw []byte if err := rows.Scan(&raw); err != nil { return 0, err } event := new(aop.Event) - if protojson.Unmarshal([]byte(raw), event) == nil && event.Seq > maximum { + if protobuf.Unmarshal(raw, event) == nil && event.Seq > maximum { maximum = event.Seq } } @@ -792,14 +551,14 @@ func (s *SQLiteStore) ListAOPEventPage(ctx context.Context, sessionID string, be if limit > 10000 { limit = 10000 } - query := `SELECT cursor, event_json FROM ( - SELECT cursor, event_json FROM chat_aop_events + query := `SELECT cursor, event_proto FROM ( + SELECT cursor, event_proto FROM chat_aop_events WHERE session_id = ? ORDER BY cursor DESC LIMIT ? ) ORDER BY cursor ASC` args := []any{sessionID, limit + 1} if before > 0 { - query = `SELECT cursor, event_json FROM ( - SELECT cursor, event_json FROM chat_aop_events + query = `SELECT cursor, event_proto FROM ( + SELECT cursor, event_proto FROM chat_aop_events WHERE session_id = ? AND cursor < ? ORDER BY cursor DESC LIMIT ? ) ORDER BY cursor ASC` args = []any{sessionID, before, limit + 1} @@ -811,13 +570,13 @@ func (s *SQLiteStore) ListAOPEventPage(ctx context.Context, sessionID string, be defer rows.Close() events := make([]persistedAOPEvent, 0, limit+1) for rows.Next() { - var raw string + var raw []byte var cursor int64 if err := rows.Scan(&cursor, &raw); err != nil { return nil, 0, err } event := new(aop.Event) - if protojson.Unmarshal([]byte(raw), event) == nil && event.SessionId != "" && event.Payload != nil { + if protobuf.Unmarshal(raw, event) == nil && event.SessionId != "" && event.Payload != nil { events = append(events, persistedAOPEvent{Cursor: cursor, Event: event}) } } @@ -839,7 +598,7 @@ func (s *SQLiteStore) ListAOPEventsAfter(ctx context.Context, sessionID string, events, _, err := s.ListAOPEventPage(ctx, sessionID, 0, limit) return events, err } - query := `SELECT cursor, event_json FROM chat_aop_events WHERE session_id = ? AND cursor > ? ORDER BY cursor ASC` + query := `SELECT cursor, event_proto FROM chat_aop_events WHERE session_id = ? AND cursor > ? ORDER BY cursor ASC` args := []any{sessionID, after} if limit > 0 { if limit > 10000 { @@ -856,12 +615,12 @@ func (s *SQLiteStore) ListAOPEventsAfter(ctx context.Context, sessionID string, var events []persistedAOPEvent for rows.Next() { var stored persistedAOPEvent - var raw string + var raw []byte if err := rows.Scan(&stored.Cursor, &raw); err != nil { return nil, err } stored.Event = new(aop.Event) - if protojson.Unmarshal([]byte(raw), stored.Event) == nil && stored.Event.SessionId != "" && stored.Event.Payload != nil { + if protobuf.Unmarshal(raw, stored.Event) == nil && stored.Event.SessionId != "" && stored.Event.Payload != nil { events = append(events, stored) } } @@ -896,58 +655,32 @@ func (s *SQLiteStore) SessionScanIDs(ctx context.Context, sessionID string) ([]s return ids, rows.Err() } -// --- Records --- - -func (s *SQLiteStore) InsertRecord(ctx context.Context, rec *output.Record) error { - return s.InsertRecords(ctx, []*output.Record{rec}) -} +// ── SCO Nodes ── -func (s *SQLiteStore) InsertRecords(ctx context.Context, recs []*output.Record) error { - if len(recs) == 0 { - return nil - } +func (s *SQLiteStore) UpsertSCONodes(ctx context.Context, operationID string, nodes []json.RawMessage) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } - stmt, err := tx.PrepareContext(ctx, - `INSERT OR IGNORE INTO records (id, type, scan_id, session_id, agent_id, source, target, turn, priority, summary, loot, tags, data, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + nodeStmt, err := tx.PrepareContext(ctx, + `INSERT INTO sco_nodes (cstx_id, cstx_type, data, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(cstx_id) DO UPDATE SET + cstx_type = excluded.cstx_type, + data = excluded.data, + updated_at = excluded.updated_at`) if err != nil { _ = tx.Rollback() return err } - defer stmt.Close() - for _, rec := range recs { - tagsJSON, _ := json.Marshal(rec.Tags) - if _, err := stmt.ExecContext(ctx, - rec.ID, string(rec.Type), rec.ScanID, rec.SessionID, rec.AgentID, - rec.Source, rec.Target, rec.Turn, rec.Priority, rec.Summary, - boolToInt(rec.Loot), string(tagsJSON), string(rec.Data), - rec.Timestamp.Format(time.RFC3339Nano), - ); err != nil { - _ = tx.Rollback() - return err - } - } - return tx.Commit() -} - -// ── SCO Nodes ── - -func (s *SQLiteStore) UpsertSCONodes(ctx context.Context, scanID string, nodes []json.RawMessage) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - stmt, err := tx.PrepareContext(ctx, - `INSERT OR REPLACE INTO sco_nodes (cstx_id, cstx_type, data, scan_id, created_at, updated_at) - VALUES (?, ?, ?, ?, COALESCE((SELECT created_at FROM sco_nodes WHERE cstx_id = ?), ?), ?)`) + defer nodeStmt.Close() + observationStmt, err := tx.PrepareContext(ctx, + `INSERT OR IGNORE INTO sco_observations (operation_id, cstx_id, observed_at) VALUES (?, ?, ?)`) if err != nil { _ = tx.Rollback() return err } - defer stmt.Close() + defer observationStmt.Close() now := time.Now().Format(time.RFC3339Nano) for _, raw := range nodes { var header struct { @@ -957,12 +690,16 @@ func (s *SQLiteStore) UpsertSCONodes(ctx context.Context, scanID string, nodes [ if json.Unmarshal(raw, &header) != nil || header.ID == "" { continue } - if _, err := stmt.ExecContext(ctx, - header.ID, header.Type, string(raw), scanID, header.ID, now, now, - ); err != nil { + if _, err := nodeStmt.ExecContext(ctx, header.ID, header.Type, string(raw), now, now); err != nil { _ = tx.Rollback() return err } + if operationID != "" { + if _, err := observationStmt.ExecContext(ctx, operationID, header.ID, now); err != nil { + _ = tx.Rollback() + return err + } + } } return tx.Commit() } @@ -974,21 +711,24 @@ func (s *SQLiteStore) ListSCONodes(ctx context.Context, nodeType string, limit i func (s *SQLiteStore) ListSCONodesByScanID(ctx context.Context, scanID, nodeType string, limit int) ([]json.RawMessage, error) { var where []string var args []any + from := "sco_nodes AS nodes" if scanID != "" { - where = append(where, "scan_id = ?") + from += " JOIN sco_observations AS observations ON observations.cstx_id = nodes.cstx_id" + where = append(where, "observations.operation_id = ?") args = append(args, scanID) } if nodeType != "" { - where = append(where, "cstx_type = ?") + where = append(where, "nodes.cstx_type = ?") args = append(args, nodeType) } var qb strings.Builder - qb.WriteString("SELECT data FROM sco_nodes") + qb.WriteString("SELECT nodes.data FROM ") + qb.WriteString(from) if len(where) > 0 { qb.WriteString(" WHERE ") qb.WriteString(strings.Join(where, " AND ")) } - qb.WriteString(" ORDER BY updated_at DESC LIMIT ?") + qb.WriteString(" ORDER BY nodes.updated_at DESC LIMIT ?") args = append(args, limit) rows, err := s.db.QueryContext(ctx, qb.String(), args...) if err != nil { @@ -1016,7 +756,7 @@ func (s *SQLiteStore) GetSCONode(ctx context.Context, cstxID string) (json.RawMe } func (s *SQLiteStore) DeleteSCONodesByScan(ctx context.Context, scanID string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM sco_nodes WHERE scan_id = ?`, scanID) + _, err := s.db.ExecContext(ctx, `DELETE FROM sco_observations WHERE operation_id = ?`, scanID) return err } diff --git a/pkg/web/store_sqlite_test.go b/pkg/web/store_sqlite_test.go index a496c610..e3d5c2a3 100644 --- a/pkg/web/store_sqlite_test.go +++ b/pkg/web/store_sqlite_test.go @@ -9,16 +9,16 @@ import ( "time" aop "github.com/chainreactors/aiscan/aop" - ext "github.com/chainreactors/aiscan/aop/aiscan/extensions" - "github.com/chainreactors/aiscan/core/output" + chatpb "github.com/chainreactors/aiscan/pkg/types/chat" + ext "github.com/chainreactors/aiscan/pkg/types/extensions" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" "google.golang.org/protobuf/types/known/timestamppb" ) func createStoredSession(t *testing.T, store *SQLiteStore, id string) { t.Helper() - now := time.Now() - if err := store.CreateSession(context.Background(), &ChatSession{ - ID: id, Status: SessionActive, CreatedAt: now, UpdatedAt: now, + if err := store.CreateSession(context.Background(), &chatpb.SessionRecord{ + Session: &aop.Session{Id: id, State: SessionStateOpen}, CreatedAt: nowProto(), UpdatedAt: nowProto(), }); err != nil { t.Fatalf("CreateSession(%q): %v", id, err) } @@ -37,12 +37,12 @@ func TestListSessionPageDoesNotDeadlockOnNonEmptyStore(t *testing.T) { if err != nil { t.Fatal(err) } - if more || len(sessions) != 1 || sessions[0].ID != "session-1" { + if more || len(sessions) != 1 || sessions[0].GetSession().GetId() != "session-1" { t.Fatalf("ListSessionPage = %+v more=%v", sessions, more) } } -func TestSQLiteStoreIgnoresNonProtoJSONEventsWithoutDeletingHistory(t *testing.T) { +func TestSQLiteStoreRejectsLegacySchema(t *testing.T) { path := filepath.Join(t.TempDir(), "legacy.db") db, err := sql.Open("sqlite", path) if err != nil { @@ -50,9 +50,7 @@ func TestSQLiteStoreIgnoresNonProtoJSONEventsWithoutDeletingHistory(t *testing.T } _, err = db.Exec(` CREATE TABLE chat_sessions (id TEXT PRIMARY KEY, agent_id TEXT, agent_name TEXT, title TEXT, status TEXT, created_at TEXT, updated_at TEXT); - CREATE TABLE chat_aop_events (id TEXT PRIMARY KEY, session_id TEXT, event_json TEXT, created_at TEXT); INSERT INTO chat_sessions VALUES ('s1','','','','active','2026-07-19T00:00:00Z','2026-07-19T00:00:00Z'); - INSERT INTO chat_aop_events VALUES ('e1','s1','{"type":"text","ts":"2026-07-19T00:00:01Z","session_id":"s1","agent":"aiscan","data":"{}"}','2026-07-19T00:00:01Z'); `) if err != nil { db.Close() @@ -60,76 +58,8 @@ func TestSQLiteStoreIgnoresNonProtoJSONEventsWithoutDeletingHistory(t *testing.T } _ = db.Close() - store, err := NewSQLiteStore(path) - if err != nil { - t.Fatal(err) - } - defer store.Close() - events, err := store.ListAOPEvents(context.Background(), "s1", 10) - if err != nil { - t.Fatal(err) - } - if len(events) != 0 { - t.Fatalf("non-protobuf JSON event was decoded: %+v", events) - } - var rows int - if err := store.db.QueryRow(`SELECT COUNT(*) FROM chat_aop_events WHERE session_id = 's1'`).Scan(&rows); err != nil || rows != 1 { - t.Fatalf("stored history rows = %d, err=%v; want preserved row", rows, err) - } -} - -func TestSQLiteStoreBackfillsDurableEventSequence(t *testing.T) { - path := filepath.Join(t.TempDir(), "legacy-sequence.db") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - _, err = db.Exec(` - CREATE TABLE chat_sessions (id TEXT PRIMARY KEY, agent_id TEXT, agent_name TEXT, title TEXT, status TEXT, created_at TEXT, updated_at TEXT); - CREATE TABLE chat_aop_events (id TEXT PRIMARY KEY, session_id TEXT, event_json TEXT, created_at TEXT); - INSERT INTO chat_sessions VALUES ('s1','','','','active','2026-07-19T00:00:00Z','2026-07-19T00:00:00Z'); - INSERT INTO chat_aop_events VALUES - ('e2','s1','{"type":"status","ts":"2026-07-19T00:00:02Z","session_id":"s1","agent":"aiscan","data":{}}','2026-07-19T00:00:02Z'), - ('e1','s1','{"type":"status","ts":"2026-07-19T00:00:01Z","session_id":"s1","agent":"aiscan","data":{}}','2026-07-19T00:00:01Z'); - PRAGMA user_version = 2; - `) - if err != nil { - db.Close() - t.Fatal(err) - } - _ = db.Close() - - store, err := NewSQLiteStore(path) - if err != nil { - t.Fatal(err) - } - defer store.Close() - rows, err := store.db.Query(`SELECT cursor, id FROM chat_aop_events WHERE session_id = 's1' ORDER BY cursor`) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - var got []string - for rows.Next() { - var seq int - var id string - if err := rows.Scan(&seq, &id); err != nil { - t.Fatal(err) - } - got = append(got, id) - if seq != len(got) { - t.Fatalf("cursor for %s = %d, want %d", id, seq, len(got)) - } - } - if len(got) != 2 || got[0] != "e1" || got[1] != "e2" { - t.Fatalf("backfilled order = %v, want [e1 e2]", got) - } - cursor, persisted, err := store.AppendAOPEvent(context.Background(), "s1", &aop.Event{ - Id: "e3", EmittedAt: timestamppb.New(time.Date(2026, 7, 19, 0, 0, 3, 0, time.UTC)), SessionId: "s1", Emitter: "aiscan", - Payload: &aop.Event_Status{Status: &aop.Status{State: "running"}}, - }) - if err != nil || !persisted || cursor != 3 { - t.Fatalf("AppendAOPEvent cursor = %d, persisted = %v, err = %v; want 3, true, nil", cursor, persisted, err) + if _, err := NewSQLiteStore(path); err == nil { + t.Fatal("NewSQLiteStore() accepted a legacy schema") } } @@ -147,7 +77,7 @@ func TestSQLiteStoreAOPMessageRoundTrip(t *testing.T) { Id: "e-user", EmittedAt: timestamppb.New(created), SessionId: "s1", Emitter: "operator", Payload: &aop.Event_Message{Message: &aop.Message{Id: "m1", Role: "user", Content: []*aop.Content{aop.Text("hello")}}}, } - _ = ext.SetWebMessage(user, ext.WebMessageExtension{Metadata: []byte(`{"code":"x"}`)}) + _ = ext.SetWebMessage(user, ext.WebMessageExtension{Code: "x"}) if err := store.AddAOPEvent(ctx, "s1", user); err != nil { t.Fatal(err) } @@ -181,13 +111,12 @@ func TestSQLiteStoreAOPMessageRoundTrip(t *testing.T) { if message := events[0].GetMessage(); message.GetId() != "m1" || message.GetRole() != "user" || message.GetContent()[0].GetText().GetText() != "hello" { t.Fatalf("user event = %+v", events[0]) } - var meta map[string]any webExtension, ok, err := ext.GetWebMessage(events[0]) if err != nil || !ok { t.Fatalf("web extension = %+v, ok = %v, err = %v", webExtension, ok, err) } - if err := json.Unmarshal(webExtension.Metadata, &meta); err != nil || meta["code"] != "x" { - t.Fatalf("user metadata = %s, err = %v", webExtension.Metadata, err) + if webExtension.GetCode() != "x" { + t.Fatalf("user metadata = %+v", webExtension) } if message := events[1].GetMessage(); message.GetId() != "m-1" || message.GetRole() != "assistant" || message.GetContent()[0].GetText().GetText() != "hi there" { t.Fatalf("assistant event = %+v", events[1]) @@ -206,27 +135,51 @@ func TestSQLiteStorePersistsAnalysisOptions(t *testing.T) { } defer store.Close() - now := time.Now() - job := &ScanJob{ - ID: "scan-1", + scan := &scanpb.Scan{ + Id: "scan-1", Target: "127.0.0.1", Mode: "quick", - Verify: true, - Deep: true, - Status: StatusQueued, - CreatedAt: now, - UpdatedAt: now, + Options: &scanpb.ScanOptions{Verify: true, Deep: true}, + Status: scanpb.ScanStatus_SCAN_STATUS_QUEUED, + CreatedAt: nowProto(), + UpdatedAt: nowProto(), } - if err := store.Create(context.Background(), job); err != nil { + if err := store.Create(context.Background(), scan); err != nil { t.Fatalf("Create() error = %v", err) } - got, err := store.Get(context.Background(), job.ID) + got, err := store.Get(context.Background(), scan.Id) if err != nil { t.Fatalf("Get() error = %v", err) } - if !got.Verify || got.Sniper || !got.Deep { - t.Fatalf("stored options = verify:%v sniper:%v deep:%v", got.Verify, got.Sniper, got.Deep) + options := got.GetOptions() + if !options.GetVerify() || options.GetSniper() || !options.GetDeep() { + t.Fatalf("stored options = verify:%v sniper:%v deep:%v", options.GetVerify(), options.GetSniper(), options.GetDeep()) + } +} + +func TestSQLiteStoreKeepsSCOObservationForEveryOperation(t *testing.T) { + store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "sco.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + node := json.RawMessage(`{"cstx_id":"ip:127.0.0.1","cstx_type":"ip","ip":"127.0.0.1"}`) + for _, operationID := range []string{"scan-1", "scan-2"} { + if err := store.UpsertSCONodes(context.Background(), operationID, []json.RawMessage{node}); err != nil { + t.Fatal(err) + } + } + for _, operationID := range []string{"scan-1", "scan-2"} { + nodes, err := store.ListSCONodesByScanID(context.Background(), operationID, "", 10) + if err != nil || len(nodes) != 1 { + t.Fatalf("operation %s nodes = %d, err = %v; want 1", operationID, len(nodes), err) + } + } + var nodeCount int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM sco_nodes`).Scan(&nodeCount); err != nil || nodeCount != 1 { + t.Fatalf("global SCO node count = %d, err = %v; want 1", nodeCount, err) } } @@ -237,33 +190,31 @@ func TestSQLiteStoreTransitionScanRequiresExpectedStatus(t *testing.T) { } defer store.Close() - now := time.Now() - job := &ScanJob{ - ID: "scan-transition", Target: "127.0.0.1", Mode: "quick", - Status: StatusQueued, CreatedAt: now, UpdatedAt: now, + scan := &scanpb.Scan{ + Id: "scan-transition", Target: "127.0.0.1", Mode: "quick", + Status: scanpb.ScanStatus_SCAN_STATUS_QUEUED, CreatedAt: nowProto(), UpdatedAt: nowProto(), } - if err := store.Create(context.Background(), job); err != nil { + if err := store.Create(context.Background(), scan); err != nil { t.Fatal(err) } - job.Status = StatusCanceled - job.UpdatedAt = time.Now() - changed, err := store.TransitionScan(context.Background(), job, StatusQueued, StatusRunning) + scan.Status = scanpb.ScanStatus_SCAN_STATUS_CANCELED + scan.UpdatedAt = nowProto() + changed, err := store.TransitionScan(context.Background(), scan, scanpb.ScanStatus_SCAN_STATUS_QUEUED, scanpb.ScanStatus_SCAN_STATUS_RUNNING) if err != nil || !changed { t.Fatalf("queued -> canceled = %v, %v; want true, nil", changed, err) } - job.Status = StatusCompleted - job.Result = &output.Result{} - changed, err = store.TransitionScan(context.Background(), job, StatusRunning) + scan.Status = scanpb.ScanStatus_SCAN_STATUS_COMPLETED + changed, err = store.TransitionScan(context.Background(), scan, scanpb.ScanStatus_SCAN_STATUS_RUNNING) if err != nil { t.Fatal(err) } if changed { t.Fatal("terminal canceled status was overwritten") } - stored, err := store.Get(context.Background(), job.ID) - if err != nil || stored.Status != StatusCanceled { + stored, err := store.Get(context.Background(), scan.Id) + if err != nil || stored.Status != scanpb.ScanStatus_SCAN_STATUS_CANCELED { t.Fatalf("stored scan = %+v, %v", stored, err) } } @@ -285,29 +236,35 @@ func TestSQLiteStoreEnablesForeignKeysAndCascadesSessionData(t *testing.T) { ctx := context.Background() now := time.Now() - session := &ChatSession{ - ID: "session-cascade", Status: SessionActive, - CreatedAt: now, UpdatedAt: now, + session := &chatpb.SessionRecord{ + Session: &aop.Session{Id: "session-cascade", State: SessionStateOpen}, + CreatedAt: nowProto(), UpdatedAt: nowProto(), } if err := store.CreateSession(ctx, session); err != nil { t.Fatal(err) } - if err := store.AddAOPEvent(ctx, session.ID, &aop.Event{ - Id: "event-cascade", EmittedAt: timestamppb.New(now), SessionId: session.ID, Emitter: "operator", + if err := store.AddAOPEvent(ctx, session.GetSession().GetId(), &aop.Event{ + Id: "event-cascade", EmittedAt: timestamppb.New(now), SessionId: session.GetSession().GetId(), Emitter: "operator", Payload: &aop.Event_Message{Message: &aop.Message{Id: "message-cascade", Role: "user", Content: []*aop.Content{aop.Text("hello")}}}, }); err != nil { t.Fatal(err) } - if err := store.LinkScanToSession(ctx, session.ID, "scan-cascade"); err != nil { + if err := store.Create(ctx, &scanpb.Scan{ + Id: "scan-cascade", Target: "127.0.0.1", Mode: "quick", + Status: scanpb.ScanStatus_SCAN_STATUS_COMPLETED, CreatedAt: nowProto(), UpdatedAt: nowProto(), + }); err != nil { + t.Fatal(err) + } + if err := store.LinkScanToSession(ctx, session.GetSession().GetId(), "scan-cascade"); err != nil { t.Fatal(err) } - if err := store.DeleteSession(ctx, session.ID); err != nil { + if err := store.DeleteSession(ctx, session.GetSession().GetId()); err != nil { t.Fatal(err) } for _, table := range []string{"chat_aop_events", "session_scans"} { var count int - if err := store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE session_id = ?`, session.ID).Scan(&count); err != nil { + if err := store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE session_id = ?`, session.GetSession().GetId()).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { @@ -331,84 +288,3 @@ func TestSQLiteStoreRejectsAOPEventForMissingSession(t *testing.T) { t.Fatal("AddAOPEvent() created an orphan event") } } - -func TestSQLiteStoreMigratesLegacySessionForeignKeys(t *testing.T) { - path := filepath.Join(t.TempDir(), "legacy-foreign-keys.db") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - _, err = db.Exec(` - CREATE TABLE chat_sessions ( - id TEXT PRIMARY KEY, agent_id TEXT, agent_name TEXT, title TEXT, - status TEXT, created_at TEXT, updated_at TEXT - ); - CREATE TABLE chat_aop_events ( - id TEXT PRIMARY KEY, session_id TEXT NOT NULL, - event_json TEXT NOT NULL, created_at TEXT NOT NULL - ); - CREATE TABLE session_scans ( - session_id TEXT NOT NULL, scan_id TEXT NOT NULL, - PRIMARY KEY (session_id, scan_id) - ); - INSERT INTO chat_sessions VALUES ( - 'kept-session','','','','active','2026-07-27T00:00:00Z','2026-07-27T00:00:00Z' - ); - INSERT INTO chat_aop_events VALUES - ('kept-event','kept-session','{}','2026-07-27T00:00:01Z'), - ('orphan-event','missing-session','{}','2026-07-27T00:00:02Z'); - INSERT INTO session_scans VALUES - ('kept-session','kept-scan'), - ('missing-session','orphan-scan'); - PRAGMA user_version = 2; - `) - if err != nil { - _ = db.Close() - t.Fatal(err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - - store, err := NewSQLiteStore(path) - if err != nil { - t.Fatal(err) - } - defer store.Close() - - for _, table := range []string{"chat_aop_events", "session_scans"} { - var keptCount int - if err := store.db.QueryRow( - `SELECT COUNT(*) FROM ` + table + ` WHERE session_id = 'kept-session'`, - ).Scan(&keptCount); err != nil { - t.Fatal(err) - } - if keptCount != 1 { - t.Fatalf("%s retained %d valid legacy rows, want 1", table, keptCount) - } - var orphanCount int - if err := store.db.QueryRow( - `SELECT COUNT(*) FROM ` + table + ` WHERE session_id = 'missing-session'`, - ).Scan(&orphanCount); err != nil { - t.Fatal(err) - } - if orphanCount != 0 { - t.Fatalf("%s retained %d legacy orphan rows", table, orphanCount) - } - } - - if err := store.DeleteSession(context.Background(), "kept-session"); err != nil { - t.Fatal(err) - } - for _, table := range []string{"chat_aop_events", "session_scans"} { - var count int - if err := store.db.QueryRow( - `SELECT COUNT(*) FROM ` + table + ` WHERE session_id = 'kept-session'`, - ).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("%s did not cascade after legacy migration", table) - } - } -} diff --git a/pkg/web/system_connect.go b/pkg/web/system_connect.go new file mode 100644 index 00000000..5d575aaf --- /dev/null +++ b/pkg/web/system_connect.go @@ -0,0 +1,29 @@ +package web + +import ( + "context" + + "connectrpc.com/connect" + "github.com/chainreactors/aiscan/pkg/rpc/system/systemconnect" + systempb "github.com/chainreactors/aiscan/pkg/types/system" +) + +type connectSystemServer struct { + systemconnect.UnimplementedSystemServiceHandler + service *Service + pool *AgentPool + serverURL string +} + +func (s *connectSystemServer) GetStatus(context.Context, *connect.Request[systempb.GetStatusRequest]) (*connect.Response[systempb.GetStatusResponse], error) { + status := s.service.Status() + if s.pool != nil { + status.Agents = uint32(s.pool.Count()) + } + if s.serverURL != "" { + status.ServerUrl = s.serverURL + } + return connect.NewResponse(&systempb.GetStatusResponse{Status: status}), nil +} + +var _ systemconnect.SystemServiceHandler = (*connectSystemServer)(nil) diff --git a/pkg/web/terminal/codec.go b/pkg/web/terminal/codec.go index a511fbc8..381241b9 100644 --- a/pkg/web/terminal/codec.go +++ b/pkg/web/terminal/codec.go @@ -1,48 +1,104 @@ -// Package terminal is the single adapter between AIScan's protobuf terminal -// transport and the internal PTY runtime model. +// Package terminal adapts the AOP PTY extension to the internal PTY runtime. package terminal import ( - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + ptyproto "github.com/chainreactors/aiscan/aop/pty" "github.com/chainreactors/utils/pty" - "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/timestamppb" ) -func FromProto(value *transport.TerminalFrame) pty.Frame { +func FromProto(value *ptyproto.ProtocolMessage) pty.Frame { if value == nil { return pty.Frame{} } - frame := pty.Frame{ - Type: pty.FrameType(value.Type), StreamID: value.StreamId, SessionID: value.SessionId, - Kind: value.Kind, Name: value.Name, Command: value.Command, Args: value.Args, Data: value.Data, - Cols: int(value.Cols), Rows: int(value.Rows), Bytes: int(value.Bytes), Offset: value.Offset, - Singleton: value.Singleton, Error: value.Error, State: pty.State(value.State), ExitCode: int(value.ExitCode), - } - frame.Session = InfoFromProto(value.Session) - for _, session := range value.Sessions { - if info := InfoFromProto(session); info != nil { - frame.Sessions = append(frame.Sessions, *info) + switch payload := value.Message.(type) { + case *ptyproto.ProtocolMessage_Open: + v := payload.Open + return pty.Frame{Type: pty.FrameOpen, StreamID: v.StreamId, Kind: v.Kind, Name: v.Name, Command: v.Command, Args: v.Args, Cols: int(v.Cols), Rows: int(v.Rows), Singleton: v.Singleton} + case *ptyproto.ProtocolMessage_Opened: + return pty.Frame{Type: pty.FrameOpened, StreamID: payload.Opened.StreamId, Session: InfoFromProto(payload.Opened.Session)} + case *ptyproto.ProtocolMessage_Input: + return pty.Frame{Type: pty.FrameInput, StreamID: payload.Input.StreamId, Data: payload.Input.Data} + case *ptyproto.ProtocolMessage_Output: + return pty.Frame{Type: pty.FrameOutput, StreamID: payload.Output.StreamId, Data: payload.Output.Data, Offset: payload.Output.Offset} + case *ptyproto.ProtocolMessage_Resize: + return pty.Frame{Type: pty.FrameResize, StreamID: payload.Resize.StreamId, Cols: int(payload.Resize.Cols), Rows: int(payload.Resize.Rows)} + case *ptyproto.ProtocolMessage_List: + return pty.Frame{Type: pty.FrameList, StreamID: payload.List.StreamId} + case *ptyproto.ProtocolMessage_Sessions: + frame := pty.Frame{Type: pty.FrameSessions, StreamID: payload.Sessions.StreamId} + for _, session := range payload.Sessions.Sessions { + if info := InfoFromProto(session); info != nil { + frame.Sessions = append(frame.Sessions, *info) + } } + return frame + case *ptyproto.ProtocolMessage_Attach: + v := payload.Attach + return pty.Frame{Type: pty.FrameAttach, StreamID: v.StreamId, SessionID: v.SessionId, Cols: int(v.Cols), Rows: int(v.Rows)} + case *ptyproto.ProtocolMessage_Attached: + return pty.Frame{Type: pty.FrameAttached, StreamID: payload.Attached.StreamId, Session: InfoFromProto(payload.Attached.Session)} + case *ptyproto.ProtocolMessage_Detach: + return pty.Frame{Type: pty.FrameDetach, StreamID: payload.Detach.StreamId} + case *ptyproto.ProtocolMessage_Detached: + return pty.Frame{Type: pty.FrameDetached, StreamID: payload.Detached.StreamId} + case *ptyproto.ProtocolMessage_Kill: + return pty.Frame{Type: pty.FrameKill, StreamID: payload.Kill.StreamId} + case *ptyproto.ProtocolMessage_Close: + return pty.Frame{Type: pty.FrameKill, StreamID: payload.Close.StreamId} + case *ptyproto.ProtocolMessage_Closed: + return pty.Frame{Type: pty.FrameClosed, StreamID: payload.Closed.StreamId, Session: InfoFromProto(payload.Closed.Session)} + case *ptyproto.ProtocolMessage_State: + return pty.Frame{Type: pty.FrameOpened, StreamID: payload.State.StreamId, Session: InfoFromProto(payload.State.Session)} + case *ptyproto.ProtocolMessage_Error: + return pty.Frame{Type: pty.FrameError, StreamID: payload.Error.StreamId, Error: payload.Error.Message} + default: + return pty.Frame{} } - return frame } -func ToProto(frame pty.Frame) *transport.TerminalFrame { - value := &transport.TerminalFrame{ - Type: string(frame.Type), StreamId: frame.StreamID, SessionId: frame.SessionID, - Kind: frame.Kind, Name: frame.Name, Command: frame.Command, Args: frame.Args, Data: frame.Data, - Cols: int32(frame.Cols), Rows: int32(frame.Rows), Bytes: int32(frame.Bytes), Offset: frame.Offset, - Singleton: frame.Singleton, Error: frame.Error, State: string(frame.State), ExitCode: int32(frame.ExitCode), - } - value.Session = InfoToProto(frame.Session) - for index := range frame.Sessions { - value.Sessions = append(value.Sessions, InfoToProto(&frame.Sessions[index])) - } - return value +func ToProto(frame pty.Frame) *ptyproto.ProtocolMessage { + message := &ptyproto.ProtocolMessage{} + switch frame.Type { + case pty.FrameOpen: + message.Message = &ptyproto.ProtocolMessage_Open{Open: &ptyproto.Open{StreamId: frame.StreamID, Kind: frame.Kind, Name: frame.Name, Command: frame.Command, Args: frame.Args, Cols: int32(frame.Cols), Rows: int32(frame.Rows), Singleton: frame.Singleton}} + case pty.FrameOpened: + message.Message = &ptyproto.ProtocolMessage_Opened{Opened: &ptyproto.Opened{StreamId: frame.StreamID, Session: InfoToProto(frame.Session)}} + case pty.FrameInput: + message.Message = &ptyproto.ProtocolMessage_Input{Input: &ptyproto.Input{StreamId: frame.StreamID, Data: frame.Data}} + case pty.FrameOutput: + message.Message = &ptyproto.ProtocolMessage_Output{Output: &ptyproto.Output{StreamId: frame.StreamID, Data: frame.Data, Offset: frame.Offset}} + case pty.FrameResize: + message.Message = &ptyproto.ProtocolMessage_Resize{Resize: &ptyproto.Resize{StreamId: frame.StreamID, Cols: int32(frame.Cols), Rows: int32(frame.Rows)}} + case pty.FrameList: + message.Message = &ptyproto.ProtocolMessage_List{List: &ptyproto.List{StreamId: frame.StreamID}} + case pty.FrameSessions: + value := &ptyproto.Sessions{StreamId: frame.StreamID} + for index := range frame.Sessions { + value.Sessions = append(value.Sessions, InfoToProto(&frame.Sessions[index])) + } + message.Message = &ptyproto.ProtocolMessage_Sessions{Sessions: value} + case pty.FrameAttach: + message.Message = &ptyproto.ProtocolMessage_Attach{Attach: &ptyproto.Attach{StreamId: frame.StreamID, SessionId: frame.SessionID, Cols: int32(frame.Cols), Rows: int32(frame.Rows)}} + case pty.FrameAttached: + message.Message = &ptyproto.ProtocolMessage_Attached{Attached: &ptyproto.Attached{StreamId: frame.StreamID, Session: InfoToProto(frame.Session)}} + case pty.FrameDetach: + message.Message = &ptyproto.ProtocolMessage_Detach{Detach: &ptyproto.Detach{StreamId: frame.StreamID}} + case pty.FrameDetached: + message.Message = &ptyproto.ProtocolMessage_Detached{Detached: &ptyproto.Detached{StreamId: frame.StreamID}} + case pty.FrameKill: + message.Message = &ptyproto.ProtocolMessage_Kill{Kill: &ptyproto.Kill{StreamId: frame.StreamID}} + case pty.FrameClosed: + message.Message = &ptyproto.ProtocolMessage_Closed{Closed: &ptyproto.Closed{StreamId: frame.StreamID, Session: InfoToProto(frame.Session)}} + case pty.FrameError: + message.Message = &ptyproto.ProtocolMessage_Error{Error: &ptyproto.Error{StreamId: frame.StreamID, Message: frame.Error}} + default: + message.Message = &ptyproto.ProtocolMessage_Error{Error: &ptyproto.Error{StreamId: frame.StreamID, Message: "unsupported PTY frame"}} + } + return message } -func InfoFromProto(value *transport.TerminalInfo) *pty.Info { +func InfoFromProto(value *ptyproto.Session) *pty.Info { if value == nil { return nil } @@ -63,11 +119,11 @@ func InfoFromProto(value *transport.TerminalInfo) *pty.Info { return info } -func InfoToProto(value *pty.Info) *transport.TerminalInfo { +func InfoToProto(value *pty.Info) *ptyproto.Session { if value == nil { return nil } - info := &transport.TerminalInfo{ + info := &ptyproto.Session{ Id: value.ID, Kind: value.Kind, Name: value.Name, Command: value.Command, Pid: int32(value.PID), ActivitySeq: value.ActivitySeq, OutputBytes: value.OutputBytes, ExitCode: int32(value.ExitCode), State: string(value.State), KillCause: value.KillCause, @@ -83,18 +139,3 @@ func InfoToProto(value *pty.Info) *transport.TerminalInfo { } return info } - -// Marshal emits canonical protobuf JSON for the browser terminal WebSocket. -func Marshal(frame pty.Frame) ([]byte, error) { - return protojson.Marshal(ToProto(frame)) -} - -// Unmarshal accepts canonical protobuf JSON from the browser terminal -// WebSocket and returns the runtime PTY frame. -func Unmarshal(data []byte) (pty.Frame, error) { - value := new(transport.TerminalFrame) - if err := protojson.Unmarshal(data, value); err != nil { - return pty.Frame{}, err - } - return FromProto(value), nil -} diff --git a/pkg/web/terminal/codec_test.go b/pkg/web/terminal/codec_test.go deleted file mode 100644 index 5c1d175d..00000000 --- a/pkg/web/terminal/codec_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package terminal - -import ( - "testing" - "time" - - "github.com/chainreactors/utils/pty" -) - -func TestProtoJSONRoundTrip(t *testing.T) { - started := time.Date(2026, 8, 1, 1, 2, 3, 0, time.UTC) - want := pty.Frame{ - Type: pty.FrameSessions, StreamID: "stream-1", SessionID: "session-1", Data: []byte("hello"), - Sessions: []pty.Info{{ID: "session-1", Kind: "repl", StartedAt: started, ActivitySeq: 7}}, - } - raw, err := Marshal(want) - if err != nil { - t.Fatal(err) - } - got, err := Unmarshal(raw) - if err != nil { - t.Fatal(err) - } - if got.Type != want.Type || got.StreamID != want.StreamID || got.SessionID != want.SessionID || string(got.Data) != "hello" { - t.Fatalf("frame = %+v, want %+v", got, want) - } - if len(got.Sessions) != 1 || got.Sessions[0].ID != "session-1" || !got.Sessions[0].StartedAt.Equal(started) { - t.Fatalf("sessions = %+v", got.Sessions) - } -} diff --git a/pkg/web/types.go b/pkg/web/types.go index 9f18e2e4..d5e06977 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -6,7 +6,9 @@ import ( aop "github.com/chainreactors/aiscan/aop" config "github.com/chainreactors/aiscan/core/config" - "github.com/chainreactors/aiscan/core/output" + configpb "github.com/chainreactors/aiscan/pkg/types/config" + scanpb "github.com/chainreactors/aiscan/pkg/types/scan" + "google.golang.org/protobuf/types/known/timestamppb" ) var ( @@ -16,169 +18,91 @@ var ( ErrTurnNotFound = errors.New("turn not found") ) -type ScanStatus string - +// Session states stored in aop.Session.State. The SQLite status column uses +// the same values; migrate() rewrites the legacy active/archived rows. const ( - StatusQueued ScanStatus = "queued" - StatusRunning ScanStatus = "running" - StatusCompleted ScanStatus = "completed" - StatusFailed ScanStatus = "failed" - StatusCanceled ScanStatus = "canceled" + SessionStateOpen = "open" + SessionStateClosed = "closed" ) -type ScanJob struct { - ID string `json:"id"` - Target string `json:"target"` - Mode string `json:"mode"` - Verify bool `json:"verify,omitempty"` - Sniper bool `json:"sniper,omitempty"` - Deep bool `json:"deep,omitempty"` - Status ScanStatus `json:"status"` - Progress string `json:"progress,omitempty"` - Report string `json:"report,omitempty"` - Result *output.Result `json:"result,omitempty"` - Error string `json:"error,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ServiceStatus struct { - Version string `json:"version"` - LLMAvailable bool `json:"llm_available"` - LLMProvider string `json:"llm_provider,omitempty"` - LLMModel string `json:"llm_model,omitempty"` - LLMAPIKeyConfigured bool `json:"llm_api_key_configured,omitempty"` - ConfigPath string `json:"config_path,omitempty"` - ConfigLoaded bool `json:"config_loaded"` - Agents int `json:"agents"` - IOAURL string `json:"ioa_url,omitempty"` +// scanStatusToDB maps the proto enum to the string stored in the scans.status +// column. UNSPECIFIED and unknown values round-trip as "queued". +func scanStatusToDB(value scanpb.ScanStatus) string { + switch value { + case scanpb.ScanStatus_SCAN_STATUS_RUNNING: + return "running" + case scanpb.ScanStatus_SCAN_STATUS_COMPLETED: + return "completed" + case scanpb.ScanStatus_SCAN_STATUS_FAILED: + return "failed" + case scanpb.ScanStatus_SCAN_STATUS_CANCELED: + return "canceled" + default: + return "queued" + } } -// ConfigStatus is the response for GET /api/config — secrets masked, -// *_configured booleans indicate whether a secret is set. -type ConfigStatus struct { - ConfigPath string `json:"config_path,omitempty"` - ConfigLoaded bool `json:"config_loaded"` - LLM struct { - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKeyConfigured bool `json:"api_key_configured"` - Model string `json:"model"` - Proxy string `json:"proxy"` - MaxTokens int `json:"max_tokens,omitempty"` - ContextWindow int `json:"context_window,omitempty"` - ActiveProfile string `json:"active_profile,omitempty"` - Profiles []LLMProfileStatus `json:"profiles,omitempty"` - } `json:"llm"` - Cyberhub struct { - URL string `json:"url"` - KeyConfigured bool `json:"key_configured"` - Mode string `json:"mode"` - Proxy string `json:"proxy"` - } `json:"cyberhub"` - Recon struct { - FofaEmail string `json:"fofa_email"` - FofaKeyConfigured bool `json:"fofa_key_configured"` - HunterTokenConfigured bool `json:"hunter_token_configured"` - HunterAPIKeyConfigured bool `json:"hunter_api_key_configured"` - Proxy string `json:"proxy"` - Limit *int `json:"limit,omitempty"` - } `json:"recon"` - Scan struct { - Verify string `json:"verify"` - } `json:"scan"` - Search struct { - TavilyKeysConfigured bool `json:"tavily_keys_configured"` - } `json:"search"` - IOA struct { - URL string `json:"url"` - TokenConfigured bool `json:"token_configured"` - NodeName string `json:"node_name"` - Space string `json:"space"` - } `json:"ioa"` - Agent struct { - Tools []string `json:"tools,omitempty"` - Timeout int `json:"timeout"` - SaveSession bool `json:"save_session"` - } `json:"agent"` +func scanTerminal(value scanpb.ScanStatus) bool { + return value == scanpb.ScanStatus_SCAN_STATUS_COMPLETED || + value == scanpb.ScanStatus_SCAN_STATUS_FAILED || + value == scanpb.ScanStatus_SCAN_STATUS_CANCELED } -type LLMProfileStatus struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKeyConfigured bool `json:"api_key_configured"` - Model string `json:"model"` - Proxy string `json:"proxy"` - MaxTokens int `json:"max_tokens,omitempty"` - ContextWindow int `json:"context_window,omitempty"` -} +func nowProto() *timestamppb.Timestamp { return timestamppb.New(time.Now()) } -// ConfigStatusFromDistribute builds a masked ConfigStatus from raw config. -func ConfigStatusFromDistribute(d *config.DistributeConfig, path string, loaded bool) ConfigStatus { - var cs ConfigStatus - cs.ConfigPath = path - cs.ConfigLoaded = loaded - active := d.LLM.Active() - cs.LLM.Provider = active.Provider - cs.LLM.BaseURL = active.BaseURL - cs.LLM.APIKeyConfigured = active.APIKey != "" - cs.LLM.Model = active.Model - cs.LLM.Proxy = active.Proxy - cs.LLM.MaxTokens = active.MaxTokens - cs.LLM.ContextWindow = active.ContextWindow - cs.LLM.ActiveProfile = d.LLM.ActiveProfile - for _, profile := range d.LLM.Providers { - profile = config.NormalizeLLMProvider(profile) - cs.LLM.Profiles = append(cs.LLM.Profiles, LLMProfileStatus{ - ID: profile.ID, Name: profile.Name, Provider: profile.Provider, - BaseURL: profile.BaseURL, APIKeyConfigured: profile.APIKey != "", +// ConfigViewFromDistribute builds the secret-masked product view directly in +// the schema owned by aiscan.config. +func ConfigViewFromDistribute(d *configpb.DistributeConfig, path string, loaded bool) *configpb.ConfigView { + view := &configpb.ConfigView{Path: path, Loaded: loaded} + if d == nil { + return view + } + view.Llm = &configpb.LLMView{ActiveProfile: d.GetLlm().GetActiveProfile()} + for _, raw := range d.GetLlm().GetProviders() { + profile := config.NormalizeLLMProvider(raw) + if profile == nil { + continue + } + item := &configpb.LLMProviderView{ + Id: profile.Id, Name: profile.Name, Provider: profile.Provider, + BaseUrl: profile.BaseUrl, ApiKeyConfigured: profile.ApiKey != "", Model: profile.Model, Proxy: profile.Proxy, MaxTokens: profile.MaxTokens, ContextWindow: profile.ContextWindow, - }) + } + view.Llm.Providers = append(view.Llm.Providers, item) + if profile.Id == view.Llm.ActiveProfile { + view.Llm.Active = item + } + } + if view.Llm.Active == nil && len(view.Llm.Providers) > 0 { + view.Llm.Active = view.Llm.Providers[0] + view.Llm.ActiveProfile = view.Llm.Active.Id + } + view.Cyberhub = &configpb.CyberhubView{ + Url: d.GetCyberhub().GetUrl(), KeyConfigured: d.GetCyberhub().GetKey() != "", + Mode: d.GetCyberhub().GetMode(), Proxy: d.GetCyberhub().GetProxy(), } - cs.Cyberhub.URL = d.Cyberhub.URL - cs.Cyberhub.KeyConfigured = d.Cyberhub.Key != "" - cs.Cyberhub.Mode = d.Cyberhub.Mode - cs.Cyberhub.Proxy = d.Cyberhub.Proxy - cs.Recon.FofaEmail = d.Recon.FofaEmail - cs.Recon.FofaKeyConfigured = d.Recon.FofaKey != "" - cs.Recon.HunterTokenConfigured = d.Recon.HunterToken != "" - cs.Recon.HunterAPIKeyConfigured = d.Recon.HunterAPIKey != "" - cs.Recon.Proxy = d.Recon.Proxy - cs.Recon.Limit = d.Recon.Limit - cs.Scan.Verify = d.Scan.Verify - cs.Search.TavilyKeysConfigured = d.Search.TavilyKeys != "" - cs.IOA.URL = d.IOA.URL - cs.IOA.TokenConfigured = d.IOA.Token != "" - cs.IOA.NodeName = d.IOA.NodeName - cs.IOA.Space = d.IOA.Space - cs.Agent.Tools = d.Agent.Tools - cs.Agent.Timeout = d.Agent.Timeout - cs.Agent.SaveSession = d.Agent.SaveSession - return cs + view.Recon = &configpb.ReconView{ + FofaEmail: d.GetRecon().GetFofaEmail(), FofaKeyConfigured: d.GetRecon().GetFofaKey() != "", + HunterTokenConfigured: d.GetRecon().GetHunterToken() != "", + HunterApiKeyConfigured: d.GetRecon().GetHunterApiKey() != "", + Proxy: d.GetRecon().GetProxy(), Limit: d.GetRecon().GetLimit(), + } + view.Scan = &configpb.ScanConfig{Verify: d.GetScan().GetVerify()} + view.Search = &configpb.SearchView{TavilyKeysConfigured: d.GetSearch().GetTavilyKeys() != ""} + view.Ioa = &configpb.IOAView{ + Url: d.GetIoa().GetUrl(), TokenConfigured: d.GetIoa().GetToken() != "", + NodeName: d.GetIoa().GetNodeName(), Space: d.GetIoa().GetSpace(), + } + view.Agent = &configpb.AgentConfig{ + Tools: append([]string(nil), d.GetAgent().GetTools()...), + Timeout: d.GetAgent().GetTimeout(), SaveSession: d.GetAgent().GetSaveSession(), + } + return view } // --- Chat types --- -const ( - SessionActive = "active" - SessionArchived = "archived" -) - -type ChatSession struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - AgentName string `json:"agent_name,omitempty"` - Title string `json:"title"` - Status string `json:"status"` - TopicID string `json:"topic_id,omitempty"` - ScanIDs []string `json:"scan_ids,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - type persistedAOPEvent struct { Cursor int64 Event *aop.Event diff --git a/pkg/web/upload_test.go b/pkg/web/upload_test.go index 351b40d4..14eded04 100644 --- a/pkg/web/upload_test.go +++ b/pkg/web/upload_test.go @@ -3,35 +3,14 @@ package web import ( "context" "errors" - "net/http/httptest" "path/filepath" "testing" "time" - "connectrpc.com/connect" - chatpb "github.com/chainreactors/aiscan/aop/aiscan/chat" - "github.com/chainreactors/aiscan/aop/aiscan/chat/chatconnect" - transport "github.com/chainreactors/aiscan/aop/aiscan/transport" + aop "github.com/chainreactors/aiscan/aop" + filepb "github.com/chainreactors/aiscan/aop/file" ) -func TestUploadConnectRPCRejectsMissingSession(t *testing.T) { - store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "upload.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - - server := httptest.NewServer(NewHandler(NewService(ServiceConfig{Store: store}), nil, nil, nil, nil, "")) - defer server.Close() - client := chatconnect.NewSessionServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) - response, err := client.UploadSessionFile(context.Background(), connect.NewRequest(&chatpb.UploadSessionFileRequest{ - RequestId: "upload-1", SessionId: "missing", Filename: "note.txt", Data: []byte("hello"), - })) - if err != nil || response.Msg.GetRejected().GetCode() != "NOT_FOUND" { - t.Fatalf("UploadSessionFile = %v, %v", response, err) - } -} - func TestHandleFileUploadCancellationRemovesPendingAgentTask(t *testing.T) { store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "upload.db")) if err != nil { @@ -43,24 +22,24 @@ func TestHandleFileUploadCancellationRemovesPendingAgentTask(t *testing.T) { if err != nil { t.Fatal(err) } - session.AgentID = "upload-agent" - if _, err := store.db.Exec(`UPDATE chat_sessions SET agent_id = ? WHERE id = ?`, session.AgentID, session.ID); err != nil { + session.Session.Participant = "upload-agent" + if err := store.UpdateSession(context.Background(), session); err != nil { t.Fatal(err) } pool := NewAgentPool(NewHub()) - remote := newFakeAgent(session.AgentID, 1) + remote := newFakeAgent(session.GetSession().GetParticipant(), 1) pool.register(remote) svc := NewService(ServiceConfig{Store: store, AgentPool: pool}) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { - _, err := svc.HandleFileUpload(ctx, session.ID, "note.txt", []byte("hello")) + _, err := svc.HandleFileUpload(ctx, session.GetSession().GetId(), "note.txt", []byte("hello")) done <- err }() - var upload *transport.ServerFrame + var upload *aop.Envelope select { case upload = <-remote.sendCh: case <-time.After(time.Second): @@ -76,17 +55,29 @@ func TestHandleFileUploadCancellationRemovesPendingAgentTask(t *testing.T) { t.Fatal("upload did not return after request cancellation") } + message, err := aop.Unwrap(upload) + if err != nil { + t.Fatal(err) + } + if _, ok := message.(*filepb.ProtocolMessage); !ok { + t.Fatalf("upload dispatch = %T, want file protocol message", message) + } + taskID := upload.GetId() remote.mu.Lock() - taskID := upload.GetFileUpload().GetTaskId() _, pending := remote.tasks[taskID] remote.mu.Unlock() if pending { t.Fatal("canceled upload remained in the agent task map") } select { - case msg := <-remote.controlCh: - if msg.GetCancelTurn().GetTurnId() != taskID { - t.Fatalf("upload cancel frame = %+v", msg) + case envelope := <-remote.sendCh: + message, err := aop.Unwrap(envelope) + if err != nil { + t.Fatal(err) + } + core, ok := message.(*aop.ProtocolMessage) + if !ok || core.GetCancelTurnRequest().GetTurnId() != taskID { + t.Fatalf("upload cancel envelope = %+v", message) } default: t.Fatal("upload cancellation was not sent to the agent") diff --git a/pkg/web/validation.go b/pkg/web/validation.go index 01644f1a..9f634959 100644 --- a/pkg/web/validation.go +++ b/pkg/web/validation.go @@ -8,11 +8,15 @@ import ( agentprovider "github.com/chainreactors/aiscan/agent/provider" config "github.com/chainreactors/aiscan/core/config" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) // ValidateLLMConfig accepts zero limits as "use the model default" and rejects // incomplete profiles before an invalid configuration can be persisted. -func ValidateLLMConfig(cfg config.LLMConfig) error { +func ValidateLLMConfig(cfg *configpb.LLMConfig) error { + if cfg == nil { + return nil + } for i, profile := range cfg.Providers { profile = config.NormalizeLLMProvider(profile) if !agentprovider.IsSupportedProvider(profile.Provider) { @@ -21,7 +25,7 @@ func ValidateLLMConfig(cfg config.LLMConfig) error { if strings.TrimSpace(profile.Model) == "" { name := strings.TrimSpace(profile.Name) if name == "" { - name = strings.TrimSpace(profile.ID) + name = strings.TrimSpace(profile.Id) } if name == "" { name = fmt.Sprintf("#%d", i+1) diff --git a/pkg/web/validation_test.go b/pkg/web/validation_test.go index 5249106f..eba53257 100644 --- a/pkg/web/validation_test.go +++ b/pkg/web/validation_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - config "github.com/chainreactors/aiscan/core/config" + configpb "github.com/chainreactors/aiscan/pkg/types/config" ) func TestValidateLLMConfigRejectsUnsupportedProvider(t *testing.T) { - cfg := config.LLMConfig{Providers: []config.LLMProviderConfig{{ + cfg := &configpb.LLMConfig{Providers: []*configpb.LLMProviderConfig{{ Provider: "deepseek", Model: "deepseek-chat", }}} From a7ec16492b8ba03f70596395bfef13780324d81e Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sun, 2 Aug 2026 12:02:34 +0800 Subject: [PATCH 161/348] refactor(frontend): consume generated AIScan protobuf clients --- web/frontend/e2e/aiscan-web.spec.ts | 763 ++---------- web/frontend/e2e/start-server.mjs | 122 +- web/frontend/src/App.tsx | 24 +- web/frontend/src/__preview_sidebar.tsx | 65 -- web/frontend/src/aiscan-proto.ts | 73 ++ web/frontend/src/api.ts | 948 ++++----------- web/frontend/src/components/AgentPanel.tsx | 67 +- .../src/components/AssetResultView.tsx | 1027 +++-------------- web/frontend/src/components/ChatPanel.tsx | 33 +- web/frontend/src/components/ConfigPanel.tsx | 199 ++-- web/frontend/src/components/FindingsPanel.tsx | 8 +- web/frontend/src/components/LLMHealth.tsx | 17 +- web/frontend/src/components/QuickConnect.tsx | 39 +- web/frontend/src/components/SessionList.tsx | 80 +- .../src/components/chat/ScanSummaryCard.tsx | 34 +- .../src/components/terminal/AgentTerminal.tsx | 401 ++----- .../components/terminal/TerminalDetails.tsx | 46 +- web/frontend/src/gen/aiscan/rpc/agent_pb.ts | 54 + web/frontend/src/gen/aiscan/rpc/chat_pb.ts | 72 ++ web/frontend/src/gen/aiscan/rpc/config_pb.ts | 72 ++ web/frontend/src/gen/aiscan/rpc/scan_pb.ts | 62 + web/frontend/src/gen/aiscan/rpc/sco_pb.ts | 70 ++ web/frontend/src/gen/aiscan/rpc/system_pb.ts | 30 + web/frontend/src/gen/aiscan/types/agent_pb.ts | 477 ++++++++ web/frontend/src/gen/aiscan/types/chat_pb.ts | 318 +++++ .../src/gen/aiscan/types/command_pb.ts | 175 +++ .../src/gen/aiscan/types/config_pb.ts | 848 ++++++++++++++ .../src/gen/aiscan/types/reload_pb.ts | 94 ++ web/frontend/src/gen/aiscan/types/scan_pb.ts | 608 ++++++++++ web/frontend/src/gen/aiscan/types/sco_pb.ts | 243 ++++ .../src/gen/aiscan/types/system_pb.ts | 101 ++ web/frontend/src/hooks/useChatSession.ts | 194 ++-- web/frontend/src/i18n/locales/en/app.ts | 2 +- web/frontend/src/i18n/locales/zh/app.ts | 2 +- web/frontend/src/lib/agentActivity.ts | 15 +- web/frontend/src/lib/chat-extensions.tsx | 23 +- web/frontend/src/lib/scan-result.ts | 669 +---------- web/frontend/src/lib/session-agent.ts | 20 +- 38 files changed, 4446 insertions(+), 3649 deletions(-) delete mode 100644 web/frontend/src/__preview_sidebar.tsx create mode 100644 web/frontend/src/aiscan-proto.ts create mode 100644 web/frontend/src/gen/aiscan/rpc/agent_pb.ts create mode 100644 web/frontend/src/gen/aiscan/rpc/chat_pb.ts create mode 100644 web/frontend/src/gen/aiscan/rpc/config_pb.ts create mode 100644 web/frontend/src/gen/aiscan/rpc/scan_pb.ts create mode 100644 web/frontend/src/gen/aiscan/rpc/sco_pb.ts create mode 100644 web/frontend/src/gen/aiscan/rpc/system_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/agent_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/chat_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/command_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/config_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/reload_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/scan_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/sco_pb.ts create mode 100644 web/frontend/src/gen/aiscan/types/system_pb.ts diff --git a/web/frontend/e2e/aiscan-web.spec.ts b/web/frontend/e2e/aiscan-web.spec.ts index cc35152c..35963966 100644 --- a/web/frontend/e2e/aiscan-web.spec.ts +++ b/web/frontend/e2e/aiscan-web.spec.ts @@ -1,19 +1,13 @@ -import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; -import { execFile } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; +import { test, expect, type APIRequestContext, type Page } from '@playwright/test' -const API_TOKEN = process.env.ACCESS_KEY || 'test-token'; -const WEB_BASE_URL = process.env.BASE_URL || `http://127.0.0.1:${process.env.AISCAN_E2E_PORT || '38080'}`; -const execFileAsync = promisify(execFile); -const externalGoClientDir = fileURLToPath(new URL('../../../examples/external-go-client/', import.meta.url)); +const API_TOKEN = process.env.ACCESS_KEY || 'test-token' function apiHeaders() { - return { Authorization: `Bearer ${API_TOKEN}` }; + return { Authorization: `Bearer ${API_TOKEN}` } } function rpcID(prefix: string) { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` } async function connectRPC(request: APIRequestContext, procedure: string, data: Record) { @@ -24,652 +18,147 @@ async function connectRPC(request: APIRequestContext, procedure: string, data: R 'Connect-Protocol-Version': '1', }, data, - }); + }) if (!response.ok()) { - const body = await response.text(); - expect(response.ok(), `${procedure}: ${body}`).toBeTruthy(); + const body = await response.text() + expect(response.ok(), `${procedure}: ${body}`).toBeTruthy() } - return response.json(); -} - -async function openChatSession(request: APIRequestContext, participant: string) { - const sessionID = rpcID('session'); - const response = await connectRPC(request, '/aop.ChatService/OpenSession', { - requestId: rpcID('open'), sessionId: sessionID, participant, - }); - expect(response.accepted?.id).toBe(sessionID); - return response.accepted; -} - -async function deleteChatSession(request: APIRequestContext, sessionID: string) { - return connectRPC(request, '/aiscan.chat.SessionService/DeleteSession', { - requestId: rpcID('delete'), sessionId: sessionID, - }); -} - -async function runChatTurn(request: APIRequestContext, sessionID: string, content: string) { - const turnID = rpcID('turn'); - const messageID = rpcID('message'); - const response = await connectRPC(request, '/aop.ChatService/RunTurn', { - requestId: rpcID('run'), sessionId: sessionID, turnId: turnID, - input: { id: messageID, role: 'user', content: [{ text: { text: content } }] }, - }); - expect(response.accepted?.turnId).toBe(turnID); - return { turnID, messageID }; + return response.json() } async function openAuthenticatedApp(page: Page) { - const login = await page.request.post('/api/auth/login', { - data: { token: API_TOKEN }, - }); - expect(login.ok()).toBeTruthy(); - await page.goto('/'); - await expect(page.locator('button[aria-label="Open settings"]')).toBeVisible(); + const login = await page.request.post('/api/auth/login', { data: { token: API_TOKEN } }) + expect(login.ok()).toBeTruthy() + await page.goto('/') + await expect(page.locator('button[aria-label="Open settings"]')).toBeVisible() } async function requireRegisteredAgents(request: APIRequestContext) { - let agents: any[] = []; + let agents: any[] = [] await expect.poll(async () => { - const response = await request.get('/api/agents', { headers: apiHeaders() }); - expect(response.ok()).toBeTruthy(); - agents = await response.json(); - return agents.length; + const response = await connectRPC(request, '/aiscan.rpc.agent.AgentService/ListAgents', {}) + agents = response.agents ?? [] + return agents.length }, { - message: 'the E2E server must start and register its local mock-backed agent', + message: 'the E2E server must register its local mock-backed agent', timeout: 15_000, - }).toBeGreaterThan(0); - return agents; + }).toBeGreaterThan(0) + expect(agents[0].nodeUri).toBeTruthy() + return agents } -// --------------------------------------------------------------------------- -// 1. Health & Status -// --------------------------------------------------------------------------- - -test.describe('Health & Status', () => { - test('GET /health returns ok', async ({ request }) => { - const res = await request.get('/health'); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(body.status).toBe('ok'); - }); - - test('GET /api/status returns server info with LLM configured', async ({ request }) => { - const res = await request.get('/api/status', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(body.llm_available).toBe(true); - expect(body.llm_provider).toBeTruthy(); - expect(body.llm_model).toBeTruthy(); - expect(body.llm_api_key_configured).toBe(true); - expect(body.config_loaded).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// 2. Auth -// --------------------------------------------------------------------------- - -test.describe('Auth', () => { - test('rejects requests without valid token', async ({ request }) => { - const res = await request.get('/api/status', { - headers: { Authorization: 'Bearer wrong-token' }, - }); - expect(res.status()).toBe(401); - }); - - test('accepts requests with valid token', async ({ request }) => { - const res = await request.get('/api/status', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - }); - - test('does not accept tokens from URL query parameters', async ({ request }) => { - const res = await request.get(`/api/status?access_key=${API_TOKEN}`); - expect(res.status()).toBe(401); - }); -}); - -// --------------------------------------------------------------------------- -// 3. Static Assets -// --------------------------------------------------------------------------- - -test.describe('Static Assets', () => { - test('index.html never exposes the access token', async ({ request }) => { - const res = await request.get('/'); - expect(res.ok()).toBeTruthy(); - const html = await res.text(); - expect(html).not.toContain('__AISCAN_ACCESS_KEY__'); - expect(html).not.toContain(API_TOKEN); - }); - - test('JS bundle is served', async ({ request }) => { - const indexRes = await request.get('/'); - const html = await indexRes.text(); - const jsMatch = html.match(/src="(\/assets\/index-[^"]+\.js)"/); - expect(jsMatch).toBeTruthy(); - const jsRes = await request.get(jsMatch![1]); - expect(jsRes.ok()).toBeTruthy(); - }); -}); - -// --------------------------------------------------------------------------- -// 4. Login -// --------------------------------------------------------------------------- - -test.describe('Login', () => { - test('validates a token without putting it in URL or localStorage', async ({ page }) => { - await page.goto('/'); - await expect(page.getByRole('heading', { name: 'Access AIScan' })).toBeVisible(); - - const token = page.getByLabel('Access token'); - await token.fill('wrong-token'); - await page.getByRole('button', { name: 'Sign in' }).click(); - await expect(page.getByRole('alert')).toContainText('invalid'); - - await token.fill(API_TOKEN); - await page.getByRole('button', { name: 'Sign in' }).click(); - await expect(page.locator('button[aria-label="Open settings"]')).toBeVisible(); - - expect(page.url()).not.toContain(API_TOKEN); - const storedToken = await page.evaluate(() => localStorage.getItem('aiscan-access-key')); - expect(storedToken).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// 5. Page Load & UI Shell -// --------------------------------------------------------------------------- - -test.describe('Page Load', () => { - test('index page loads and renders AIScan header', async ({ page }) => { - await openAuthenticatedApp(page); - // Use a specific selector for the brand name in the header - const brand = page.locator('header').getByText('AIScan', { exact: true }); - await expect(brand).toBeVisible({ timeout: 10_000 }); - await expect(brand).toHaveText('AIScan'); - }); - - test('header shows model name', async ({ page }) => { - await openAuthenticatedApp(page); - await expect(page.locator('header')).toContainText(/deepseek/i, { timeout: 10_000 }); - }); - - test('LLM health indicator does not show offline or error', async ({ page }) => { - await openAuthenticatedApp(page); - const header = page.locator('header'); - await expect(header).toBeVisible(); - // Wait for the async health probe to complete - await page.waitForTimeout(4000); - const headerText = await header.textContent(); - expect(headerText).not.toContain('Offline'); - expect(headerText).not.toContain('unreachable'); - expect(headerText).not.toContain('not configured'); - }); - - test('settings button is visible', async ({ page }) => { - await openAuthenticatedApp(page); - const settingsBtn = page.locator('button[aria-label="Open settings"]'); - await expect(settingsBtn).toBeVisible({ timeout: 10_000 }); - }); -}); - -// --------------------------------------------------------------------------- -// 6. Config Panel -// --------------------------------------------------------------------------- - -test.describe('Config Panel', () => { - test('opens settings dialog and shows tabs', async ({ page }) => { - await openAuthenticatedApp(page); - await page.locator('button[aria-label="Open settings"]').click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible({ timeout: 5_000 }); - await expect(dialog).toContainText('Settings'); - // Should have LLM and other tabs - await expect(dialog.getByRole('button', { name: 'LLM', exact: true })).toBeVisible(); - }); - - test('closes settings dialog', async ({ page }) => { - await openAuthenticatedApp(page); - await page.locator('button[aria-label="Open settings"]').click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - // Close via button or Escape - await page.keyboard.press('Escape'); - await expect(dialog).not.toBeVisible({ timeout: 5_000 }); - }); - - test('LLM tab shows Provider and Model fields', async ({ page }) => { - await openAuthenticatedApp(page); - await page.locator('button[aria-label="Open settings"]').click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - // Click LLM tab - const llmTab = dialog.getByRole('button', { name: 'LLM', exact: true }); - if (await llmTab.isVisible()) { - await llmTab.click(); - } - await expect(dialog).toContainText('Model'); - await expect(dialog).toContainText('Provider'); - await expect(dialog).toContainText('Base URL'); - await expect(dialog).toContainText('Context window'); - await expect(dialog).toContainText('Maximum output'); - await expect(dialog).toContainText('API Key'); - }); - - test('keeps dialog geometry stable when switching tabs', async ({ page }) => { - await openAuthenticatedApp(page); - await page.locator('button[aria-label="Open settings"]').click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - await dialog.evaluate((element) => - Promise.all(element.getAnimations().map((animation) => animation.finished)), - ); - - const before = await dialog.boundingBox(); - await dialog.getByRole('button', { name: 'Cyberhub', exact: true }).click(); - const after = await dialog.boundingBox(); - - expect(before).not.toBeNull(); - expect(after).not.toBeNull(); - expect(Math.abs(after!.y - before!.y)).toBeLessThanOrEqual(1); - expect(Math.abs(after!.height - before!.height)).toBeLessThanOrEqual(1); - }); - - test('warns for a small context window and rejects an empty model', async ({ page }) => { - await openAuthenticatedApp(page); - await page.locator('button[aria-label="Open settings"]').click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - - await dialog.getByLabel('Context window (tokens)').fill('4096'); - await expect(dialog).toContainText('Below 8192 tokens'); - - await dialog.getByLabel('Model').fill(''); - await dialog.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(dialog).toBeVisible(); - await expect(dialog).toContainText('requires a model'); - }); - - test('closes after a successful save', async ({ page }) => { - let saved = false; - await page.route('**/api/config', async (route) => { - if (route.request().method() !== 'PUT') { - await route.continue(); - return; - } - saved = true; - await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); - }); - - await openAuthenticatedApp(page); - await page.locator('button[aria-label="Open settings"]').click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: 'Save', exact: true }).click(); - - await expect(dialog).not.toBeVisible(); - expect(saved).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// 6. Config API -// --------------------------------------------------------------------------- - -test.describe('Config API', () => { - test('GET /api/config returns current config status', async ({ request }) => { - const res = await request.get('/api/config', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(body.llm).toBeDefined(); - expect(body.llm.provider).toBeTruthy(); - expect(body.llm.model).toBeTruthy(); - expect(body.llm.api_key_configured).toBe(true); - }); +async function deleteSession(request: APIRequestContext, sessionID: string) { + return connectRPC(request, '/aiscan.rpc.chat.SessionService/DeleteSession', { + requestId: rpcID('delete'), + sessionId: sessionID, + }) +} - test('LLM connectivity test succeeds with explicit config', async ({ request }) => { - const configResponse = await request.get('/api/config', { headers: apiHeaders() }); - expect(configResponse.ok()).toBeTruthy(); - const config = await configResponse.json(); - const res = await request.post('/api/config/llm/test', { - headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, - data: { - profile_id: config.llm.active_profile, - provider: config.llm.provider, - base_url: config.llm.base_url, - api_key: '', - model: config.llm.model, +test.describe('HTTP shell and authentication', () => { + test('health and static assets are served', async ({ request }) => { + const health = await request.get('/health') + expect(health.ok()).toBeTruthy() + expect(await health.json()).toEqual({ status: 'ok' }) + + const index = await request.get('/') + expect(index.ok()).toBeTruthy() + const html = await index.text() + expect(html).not.toContain(API_TOKEN) + const script = html.match(/src="(\/assets\/index-[^"]+\.js)"/) + expect(script).toBeTruthy() + expect((await request.get(script![1])).ok()).toBeTruthy() + }) + + test('login uses the auth endpoint without leaking the token', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Access AIScan' })).toBeVisible() + const token = page.getByLabel('Access token') + await token.fill(API_TOKEN) + await page.getByRole('button', { name: 'Sign in' }).click() + await expect(page.locator('button[aria-label="Open settings"]')).toBeVisible() + expect(page.url()).not.toContain(API_TOKEN) + expect(await page.evaluate(() => localStorage.getItem('aiscan-access-key'))).toBeNull() + + const ioa = await page.evaluate(async () => { + const response = await fetch('/ioa/nodes') + return { status: response.status, nodes: await response.json() } + }) + expect(ioa.status).toBe(200) + expect(Array.isArray(ioa.nodes)).toBeTruthy() + expect(ioa.nodes.some((node: { name?: string }) => node.name === 'aiscan.web')).toBeTruthy() + }) + + test('management RPC rejects an invalid bearer token', async ({ request }) => { + const response = await request.post('/aiscan.rpc.system.SystemService/GetStatus', { + headers: { + Authorization: 'Bearer wrong-token', + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', }, - }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(body.ok).toBe(true); - expect(body.latency_ms).toBeGreaterThan(0); - }); -}); - -// --------------------------------------------------------------------------- -// 7. Agents API -// --------------------------------------------------------------------------- - -test.describe('Agents API', () => { - test('list agents returns array', async ({ request }) => { - const res = await request.get('/api/agents', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(Array.isArray(body)).toBeTruthy(); - }); -}); - -// --------------------------------------------------------------------------- -// 8. Chat Session CRUD -// --------------------------------------------------------------------------- - -test.describe('Chat Session CRUD', () => { - test('create, list, and delete a session', async ({ request }) => { - const agents = await requireRegisteredAgents(request); - const agentID = agents[0].id; - - const session = await openChatSession(request, agentID); - expect(session.id).toBeTruthy(); - expect(session.participant).toBe(agentID); - - const listed = await connectRPC(request, '/aiscan.chat.SessionService/ListSessions', { - limit: 100, includeClosed: true, - }); - expect(Array.isArray(listed.sessions)).toBeTruthy(); - expect(listed.sessions.some((record: any) => record.session?.id === session.id)).toBeTruthy(); - - const deleted = await deleteChatSession(request, session.id); - expect(deleted.accepted?.id).toBe(session.id); - }); - - test('legacy chat REST routes are removed', async ({ request }) => { - const response = await request.get('/api/chat/sessions', { headers: apiHeaders() }); - expect(response.status()).toBe(404); - }); -}); - -// --------------------------------------------------------------------------- -// 9. Chat LLM Round-trip -// --------------------------------------------------------------------------- - -test.describe('Chat LLM round-trip', () => { - test('send a message and receive an assistant response', async ({ request }) => { - const agents = await requireRegisteredAgents(request); - const agentID = agents[0].id; - - const session = await openChatSession(request, agentID); - const sessionID = session.id; - - try { - await runChatTurn(request, sessionID, 'Reply with exactly one word: PONG'); - - let assistantMsg: any = null; - for (let i = 0; i < 15; i++) { - await new Promise((r) => setTimeout(r, 2000)); - const listed = await connectRPC(request, '/aop.ChatService/ListEvents', { - sessionId: sessionID, limit: 500, - }); - const assistantMsgs = listed.events - .map((delivery: any) => delivery.event?.message) - .filter((message: any) => message?.role === 'assistant'); - if (assistantMsgs.length > 0) { - assistantMsg = assistantMsgs[assistantMsgs.length - 1]; - break; - } - } - - expect(assistantMsg).not.toBeNull(); - expect(assistantMsg.content?.length).toBeGreaterThan(0); - } finally { - await deleteChatSession(request, sessionID); + data: {}, + }) + expect(response.status()).toBe(401) + }) +}) + +test.describe('ConnectRPC management plane', () => { + test('system, config and agent views are protobuf-shaped', async ({ request }) => { + const system = await connectRPC(request, '/aiscan.rpc.system.SystemService/GetStatus', {}) + expect(system.status?.configLoaded).toBe(true) + expect(typeof system.status?.agents).toBe('number') + + const config = await connectRPC(request, '/aiscan.rpc.config.ConfigService/GetConfig', {}) + expect(config.config?.loaded).toBe(true) + + const agents = await requireRegisteredAgents(request) + expect(agents[0].hello?.agentId).toBeTruthy() + expect(agents[0].nodeUri).toContain('://') + }) + + test('session, scan, SCO and local-agent queries use ConnectRPC', async ({ request }) => { + const sessions = await connectRPC(request, '/aiscan.rpc.chat.SessionService/ListSessions', { includeClosed: true }) + expect(Array.isArray(sessions.sessions ?? [])).toBeTruthy() + + const scans = await connectRPC(request, '/aiscan.rpc.scan.ScanService/ListScans', {}) + expect(Array.isArray(scans.scans ?? [])).toBeTruthy() + + const nodes = await connectRPC(request, '/aiscan.rpc.sco.SCOService/ListNodes', { limit: 10 }) + expect(Array.isArray(nodes.nodes?.nodes ?? [])).toBeTruthy() + + const local = await connectRPC(request, '/aiscan.rpc.agent.AgentService/ListLocalAgents', {}) + expect(Array.isArray(local.agents ?? [])).toBeTruthy() + }) + + test('retired REST management routes stay removed', async ({ request }) => { + for (const path of ['/api/status', '/api/config', '/api/agents', '/api/scans', '/api/sco/nodes', '/api/deploy/local']) { + expect((await request.get(path, { headers: apiHeaders() })).status(), path).toBe(404) } - }); -}); - -test.describe('External Go Connect client', () => { - test('an independent Go module opens, runs, and streams a turn', async ({ request }) => { - const agents = await requireRegisteredAgents(request); - const { stdout, stderr } = await execFileAsync('go', [ - 'run', '.', - '-url', WEB_BASE_URL, - '-token', API_TOKEN, - '-agent', agents[0].id, - '-prompt', 'Reply with exactly one word: PONG', - '-timeout', '30s', - ], { - cwd: externalGoClientDir, - timeout: 45_000, - env: { ...process.env, GOWORK: 'off' }, - }); - expect(stderr).not.toContain('error:'); - expect(stdout).toContain('PONG'); - expect(stdout).toContain('stop=completed'); - }); -}); - -// --------------------------------------------------------------------------- -// 10. Connect stream reconnect and durable event cursor -// --------------------------------------------------------------------------- - -test.describe('Connect stream reconnect', () => { - test('replays missing durable events after the browser reconnects', async ({ page, request, context }) => { - const agents = await requireRegisteredAgents(request); - - const session = await openChatSession(request, agents[0].id); - - await openAuthenticatedApp(page); - await page.goto(`/sessions/${session.id}`); - await expect(page.getByRole('textbox', { name: 'Type a message... (/ for commands)' })).toBeVisible(); - const prompt = 'Reply with exactly one word: PONG'; - await runChatTurn(request, session.id, prompt); - - await context.setOffline(true); - await page.waitForTimeout(3500); - await context.setOffline(false); - - const resumed = page.getByText('PONG', { exact: true }); - await expect(resumed).toBeVisible({ timeout: 15_000 }); - await expect(resumed).toHaveCount(1); - - await deleteChatSession(request, session.id); - }); -}); - -// --------------------------------------------------------------------------- -// 11. SCO / Asset Pool API -// --------------------------------------------------------------------------- - -test.describe('Asset Pool API', () => { - test('list SCO nodes returns array', async ({ request }) => { - const res = await request.get('/api/sco/nodes', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(Array.isArray(body)).toBeTruthy(); - }); + }) +}) - test('get SCO stats returns object', async ({ request }) => { - const res = await request.get('/api/sco/stats', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(typeof body).toBe('object'); - }); -}); +test.describe('single AOP WebSocket browser plane', () => { + test('creates a session and streams a turn through the application AOP client', async ({ page, request }) => { + await requireRegisteredAgents(request) + await openAuthenticatedApp(page) -// --------------------------------------------------------------------------- -// 11. Scans API -// --------------------------------------------------------------------------- - -test.describe('Scan ConnectRPC', () => { - test('lists scans through ScanService and retires the REST route', async ({ request }) => { - const body = await connectRPC(request, '/aiscan.scan.ScanService/ListScans', {}); - expect(Array.isArray(body.scans ?? [])).toBeTruthy(); - const legacy = await request.get('/api/scans', { headers: apiHeaders() }); - expect(legacy.status()).toBe(404); - }); -}); - -// --------------------------------------------------------------------------- -// 12. Chat UI (browser) -// --------------------------------------------------------------------------- - -test.describe('Chat UI', () => { - test('sends natural language and receives the streamed answer in the browser', async ({ page, request }) => { - const agents = await requireRegisteredAgents(request); - const session = await openChatSession(request, agents[0].id); - const browserErrors: string[] = []; - page.on('console', (message) => { - if (message.type() === 'error') browserErrors.push(`console: ${message.text()}`); - }); - page.on('pageerror', (error) => browserErrors.push(`page: ${error.message}`)); - page.on('requestfailed', (failed) => { - if (!failed.failure()?.errorText.includes('ERR_ABORTED')) { - browserErrors.push(`request: ${failed.method()} ${failed.url()} ${failed.failure()?.errorText}`); - } - }); + await page.getByRole('button', { name: 'New', exact: true }).first().click() + const input = page.getByRole('textbox', { name: 'Type a message... (/ for commands)' }) + await expect(input).toBeVisible() + const sessionID = new URL(page.url()).pathname.split('/').filter(Boolean).at(-1)! try { - await openAuthenticatedApp(page); - await page.goto(`/sessions/${session.id}`); - const input = page.getByRole('textbox', { name: 'Type a message... (/ for commands)' }); - await input.fill('Reply with exactly one word: PONG'); - await page.getByRole('button', { name: 'Send message' }).click(); - await expect(page.getByText('PONG', { exact: true })).toBeVisible({ timeout: 20_000 }); - expect(browserErrors).toEqual([]); + await input.fill('Reply with exactly one word: PONG') + await page.getByRole('button', { name: 'Send message' }).click() + await expect(page.getByText('PONG', { exact: true })).toBeVisible({ timeout: 20_000 }) } finally { - await deleteChatSession(request, session.id); - } - }); - - test('terminal WebSocket exchanges TerminalFrame protobuf JSON', async ({ page, request }) => { - const agents = await requireRegisteredAgents(request); - await openAuthenticatedApp(page); - const result = await page.evaluate(async ({ agentID }) => { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${protocol}//${window.location.host}/api/agents/${encodeURIComponent(agentID)}/terminal/ws`; - return await new Promise<{ type?: string; sessions?: unknown[]; error?: string }>((resolve, reject) => { - const socket = new WebSocket(url); - const timer = window.setTimeout(() => { - socket.close(); - reject(new Error('terminal WebSocket timeout')); - }, 10_000); - socket.onopen = () => socket.send(JSON.stringify({ type: 'list' })); - socket.onerror = () => reject(new Error('terminal WebSocket error')); - socket.onmessage = (event) => { - const frame = JSON.parse(String(event.data)); - if (frame.type !== 'sessions' && frame.type !== 'error') return; - window.clearTimeout(timer); - socket.send(JSON.stringify({ type: 'detach' })); - socket.close(); - resolve(frame); - }; - }); - }, { agentID: agents[0].id }); - expect(result.error).toBeFalsy(); - expect(result.type).toBe('sessions'); - expect(Array.isArray(result.sessions)).toBeTruthy(); - }); - - test('UI renders the main chat area', async ({ page }) => { - await openAuthenticatedApp(page); - await page.waitForLoadState('networkidle'); - // The page should have a main content area - const main = page.locator('main').first(); - if (await main.isVisible().catch(() => false)) { - await expect(main).toBeVisible(); - } else { - // Fallback: just verify the page loaded - await expect(page.locator('header')).toBeVisible(); - } - }); - - test('sidebar shows session list or agent nodes', async ({ page }) => { - await openAuthenticatedApp(page); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - // The sidebar should show sessions or agent nodes - const sidebar = page.locator('aside, [class*="sidebar"], [class*="Sidebar"]').first(); - if (await sidebar.isVisible().catch(() => false)) { - await expect(sidebar).toBeVisible(); - } - }); - - test('can find and interact with chat input', async ({ page }) => { - await openAuthenticatedApp(page); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Find the chat textarea - const textarea = page.locator('textarea').last(); - if (await textarea.isVisible().catch(() => false)) { - await textarea.fill('test input'); - await expect(textarea).toHaveValue('test input'); - // Clear it - await textarea.fill(''); + if (sessionID) await deleteSession(request, sessionID) } - }); -}); - -// --------------------------------------------------------------------------- -// 13. Theme Toggle -// --------------------------------------------------------------------------- - -test.describe('Theme', () => { - test('can toggle between light and dark theme', async ({ page }) => { - await openAuthenticatedApp(page); - await page.waitForLoadState('networkidle'); - - const initialDark = await page.evaluate(() => - document.documentElement.classList.contains('dark') - ); - - const themeBtn = page.locator('[data-sidebar-theme-toggle] button'); - await themeBtn.click(); - await page.waitForTimeout(500); - - const afterDark = await page.evaluate(() => - document.documentElement.classList.contains('dark') - ); - - expect(afterDark).not.toBe(initialDark); - }); -}); - -// --------------------------------------------------------------------------- -// 14. LLM Models List -// --------------------------------------------------------------------------- - -test.describe('LLM Models', () => { - test('can fetch available models from provider', async ({ request }) => { - const configResponse = await request.get('/api/config', { headers: apiHeaders() }); - expect(configResponse.ok()).toBeTruthy(); - const config = await configResponse.json(); - const res = await request.post('/api/config/llm/models', { - headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, - data: { - profile_id: config.llm.active_profile, - provider: config.llm.provider, - base_url: config.llm.base_url, - api_key: '', - }, - }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(body.ok).toBe(true); - expect(Array.isArray(body.models)).toBeTruthy(); - expect(body.models.length).toBeGreaterThan(0); - }); -}); - -// --------------------------------------------------------------------------- -// 15. Deploy Local Agent -// --------------------------------------------------------------------------- - -test.describe('Local Agent Deploy', () => { - test('can list local agents', async ({ request }) => { - const res = await request.get('/api/deploy/local', { headers: apiHeaders() }); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(Array.isArray(body)).toBeTruthy(); - }); -}); + }) + + test('opens the PTY console without a terminal-specific socket', async ({ page, request }) => { + await requireRegisteredAgents(request) + await openAuthenticatedApp(page) + await page.getByRole('button', { name: 'Terminal', exact: true }).first().click() + await expect(page.locator('.xterm')).toBeVisible({ timeout: 15_000 }) + }) +}) diff --git a/web/frontend/e2e/start-server.mjs b/web/frontend/e2e/start-server.mjs index fa4d0fa5..14eddf50 100644 --- a/web/frontend/e2e/start-server.mjs +++ b/web/frontend/e2e/start-server.mjs @@ -11,55 +11,75 @@ const root = resolve(fileURLToPath(new URL('../../..', import.meta.url))) const workDir = await mkdtemp(join(tmpdir(), 'aiscan-web-e2e-')) const binary = join(workDir, process.platform === 'win32' ? 'aiscan-e2e.exe' : 'aiscan-e2e') -const mockLLM = createServer(async (req, res) => { - if (req.url === '/v1/models') { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ data: [{ id: 'deepseek-chat', object: 'model' }] })) - return - } - if (req.url !== '/v1/chat/completions' || req.method !== 'POST') { - res.writeHead(404) - res.end('not found') - return - } +const externalLLM = { + baseURL: process.env.AISCAN_E2E_LLM_BASE_URL?.trim() || '', + apiKey: process.env.AISCAN_E2E_LLM_API_KEY?.trim() || '', + model: process.env.AISCAN_E2E_LLM_MODEL?.trim() || '', +} +const externalLLMValues = Object.values(externalLLM).filter(Boolean).length +if (externalLLMValues > 0 && externalLLMValues < 3) { + throw new Error('AISCAN_E2E_LLM_BASE_URL, AISCAN_E2E_LLM_API_KEY and AISCAN_E2E_LLM_MODEL must be set together') +} + +let llmBaseURL = externalLLM.baseURL +let llmAPIKey = externalLLM.apiKey +let llmModel = externalLLM.model +let mockLLM = null - const chunks = [] - for await (const chunk of req) chunks.push(chunk) - const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') - const delayedReply = JSON.stringify(payload.messages || []).includes('Reply with exactly one word: PONG') - if (payload.stream) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }) - if (delayedReply) { - res.write('data: {"choices":[{"delta":{"role":"assistant","content":"P"},"index":0}]}\n\n') - await new Promise((resolveDelay) => setTimeout(resolveDelay, 3000)) - res.write('data: {"choices":[{"delta":{"content":"ONG"},"index":0}]}\n\n') - } else { - res.write('data: {"choices":[{"delta":{"role":"assistant","content":"PONG"},"index":0}]}\n\n') +if (externalLLMValues === 0) { + mockLLM = createServer(async (req, res) => { + if (req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ data: [{ id: 'deepseek-chat', object: 'model' }] })) + return + } + if (req.url !== '/v1/chat/completions' || req.method !== 'POST') { + res.writeHead(404) + res.end('not found') + return } - res.write('data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11}}\n\n') - res.end('data: [DONE]\n\n') - return - } - if (delayedReply) await new Promise((resolveDelay) => setTimeout(resolveDelay, 3000)) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ - id: 'chatcmpl-e2e', - choices: [{ message: { role: 'assistant', content: 'PONG' }, finish_reason: 'stop' }], - usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, - })) -}) + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') + const delayedReply = JSON.stringify(payload.messages || []).includes('Reply with exactly one word: PONG') + if (payload.stream) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + if (delayedReply) { + res.write('data: {"choices":[{"delta":{"role":"assistant","content":"P"},"index":0}]}\n\n') + await new Promise((resolveDelay) => setTimeout(resolveDelay, 3000)) + res.write('data: {"choices":[{"delta":{"content":"ONG"},"index":0}]}\n\n') + } else { + res.write('data: {"choices":[{"delta":{"role":"assistant","content":"PONG"},"index":0}]}\n\n') + } + res.write('data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11}}\n\n') + res.end('data: [DONE]\n\n') + return + } -await new Promise((resolveListen, reject) => { - mockLLM.once('error', reject) - mockLLM.listen(0, host, resolveListen) -}) -const llmAddress = mockLLM.address() -if (!llmAddress || typeof llmAddress === 'string') throw new Error('mock LLM did not expose a TCP address') + if (delayedReply) await new Promise((resolveDelay) => setTimeout(resolveDelay, 3000)) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + id: 'chatcmpl-e2e', + choices: [{ message: { role: 'assistant', content: 'PONG' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, + })) + }) + + await new Promise((resolveListen, reject) => { + mockLLM.once('error', reject) + mockLLM.listen(0, host, resolveListen) + }) + const llmAddress = mockLLM.address() + if (!llmAddress || typeof llmAddress === 'string') throw new Error('mock LLM did not expose a TCP address') + llmBaseURL = `http://${host}:${llmAddress.port}/v1` + llmAPIKey = 'test-key' + llmModel = 'deepseek-chat' +} const configPath = join(workDir, 'aiscan.yaml') await writeFile(configPath, `llm: @@ -68,9 +88,9 @@ await writeFile(configPath, `llm: - id: e2e name: E2E DeepSeek provider: openai - base_url: http://${host}:${llmAddress.port}/v1 - api_key: test-key - model: deepseek-chat + base_url: ${JSON.stringify(llmBaseURL)} + api_key: ${JSON.stringify(llmAPIKey)} + model: ${JSON.stringify(llmModel)} `, { mode: 0o600 }) const npmCommand = process.platform === 'win32' ? 'cmd.exe' : 'npm' @@ -81,7 +101,7 @@ const frontendBuild = spawnSync(npmCommand, npmArgs, { }) if (frontendBuild.status !== 0) { if (frontendBuild.error) console.error(frontendBuild.error) - mockLLM.close() + mockLLM?.close() await rm(workDir, { recursive: true, force: true }) process.exit(frontendBuild.status ?? 1) } @@ -91,7 +111,7 @@ const build = spawnSync('go', ['build', '-tags', 'full', '-o', binary, './cmd/ai stdio: 'inherit', }) if (build.status !== 0) { - mockLLM.close() + mockLLM?.close() await rm(workDir, { recursive: true, force: true }) process.exit(build.status ?? 1) } @@ -113,7 +133,7 @@ async function shutdown(code) { if (shuttingDown) return shuttingDown = true if (child.exitCode === null) child.kill() - await new Promise((resolveClose) => mockLLM.close(resolveClose)) + if (mockLLM) await new Promise((resolveClose) => mockLLM.close(resolveClose)) await rm(workDir, { recursive: true, force: true }) process.exit(code) } diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 998b0558..d1033c75 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -14,7 +14,7 @@ const IOAConsole = lazy(() => import('./components/IOAConsole')) import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, useConfirm } from '@cyber/ui' import { ThemeProvider } from '@cyber/theme' import { activateLLMProfile, getConfigStatus, getIOAOverview, getStatus, listSCONodes, logout } from './api' -import type { IOAMessage, IOANode, LLMProfileStatus, ServerStatus } from './api' +import type { IOAMessage, IOANode, LLMProviderView, ServerStatus } from './api' import type { SCONode } from '@cyber/cstx-easm' import type { MentionPopupApi } from './viewer' import { useChatSession } from './hooks/useChatSession' @@ -51,7 +51,7 @@ export default function App() { const confirm = useConfirm() const chat = useChatSession() const [serverStatus, setServerStatus] = useState(null) - const [llmProfiles, setLLMProfiles] = useState([]) + const [llmProfiles, setLLMProfiles] = useState([]) const [activeLLMProfile, setActiveLLMProfile] = useState('') const [switchingLLM, setSwitchingLLM] = useState(false) const [configOpen, setConfigOpen] = useState(false) @@ -73,9 +73,9 @@ export default function App() { const [statusResult, configResult] = await Promise.allSettled([getStatus(), getConfigStatus()]) if (statusResult.status === 'fulfilled') setServerStatus(statusResult.value) if (configResult.status === 'fulfilled') { - const profiles = configResult.value.llm.profiles ?? [] + const profiles = configResult.value.llm?.providers ?? [] setLLMProfiles(profiles) - setActiveLLMProfile(configResult.value.llm.active_profile || profiles[0]?.id || '') + setActiveLLMProfile(configResult.value.llm?.activeProfile || profiles[0]?.id || '') } }, []) @@ -133,15 +133,15 @@ export default function App() { [scoNodes, ioaNodes, ioaMessages], ) - const model = serverStatus?.llm_model || chat.agents.find((a) => a.status?.model)?.status?.model || 'cortex' + const model = serverStatus?.llmModel || chat.agents.find((a) => a.status?.model)?.status?.model || 'cortex' const handleSwitchLLM = useCallback(async (profileID: string) => { if (!profileID || profileID === activeLLMProfile) return setSwitchingLLM(true) try { const next = await activateLLMProfile(profileID) - setLLMProfiles(next.llm.profiles ?? []) - setActiveLLMProfile(next.llm.active_profile || profileID) + setLLMProfiles(next.llm?.providers ?? []) + setActiveLLMProfile(next.llm?.activeProfile || profileID) await refreshStatus() setHealthNonce((nonce) => nonce + 1) } catch { @@ -150,7 +150,7 @@ export default function App() { setSwitchingLLM(false) } }, [activeLLMProfile, refreshStatus]) - const activeSession = chat.sessions.find((s) => s.id === chat.activeSessionID) || null + const activeSession = chat.sessions.find((s) => s.session?.id === chat.activeSessionID) || null // The open session's bound agent has dropped off the live roster (its node // exited / the hub restarted). The transcript still shows, but a new turn // can't be dispatched until it reconnects — surface that in the chat panel. @@ -235,7 +235,7 @@ export default function App() { setAssetPanelOpen(true)} /> openIOAConsole()} /> - + {/* Separate workspace nav (assets / IOA / agents / connect) from the account utilities (settings / logout) so the row reads as two groups. */}