From ef0374b7889644c424c2ddb9ae02bf4577a147d9 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Mon, 26 Oct 2026 02:33:52 +0800 Subject: [PATCH 1/2] fix(jev): bound pure replay and expose compiler repair contracts --- docs/changelog.md | 1 + docs/jev.md | 4 + exts/jev/compile.go | 6 +- exts/jev/compiler_agent.go | 17 +++- exts/jev/compiler_repair_test.go | 118 ++++++++++++++++++++++ exts/jev/declare.go | 20 ++-- exts/jev/playwright_takeover_live_test.go | 2 +- exts/jev/skills/reflex-compiler/SKILL.md | 19 ++++ exts/jev/verify.go | 12 ++- 9 files changed, 185 insertions(+), 14 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 49a01660..bec1cdeb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,7 @@ ## Unreleased +- Reflex 编译工具公开完整产物字段和 `js:` 源码约束,回放失败时返回剩余原生调用,语义评审超限时报告各部分大小。纯离线回放在 5 秒后中断空转源码并返回可修复诊断,避免单个草稿占住编译任务。 - 工具返回部分输出时保留失败原因;后台 subagent 仅返回错误时也通知父任务。联网搜索要求实际搜索执行记录,失败时保留后端原因。 - browser、passive 与 Katana 可由入口直接导入组装,`full` 保留为 aiscan 的全量组合;原生 `record` 可独立于 `full` 构建。 - Go 源码 API:工具结果与定义直接使用 `aop.ToolResult` / `aop.ToolDefinition`;Provider 类型和构造函数改从 `agent/provider` 引用,停止原因从 `agent/hooks` 引用。Context hook 直接返回 `[]*aop.Message`,nil 表示不替换。删除重复转导出别名,AOP wire 字段保持兼容。 diff --git a/docs/jev.md b/docs/jev.md index 13b517d4..dcbba76e 100644 --- a/docs/jev.md +++ b/docs/jev.md @@ -42,10 +42,14 @@ flowchart LR 后台 Claim 生成和编译的单次 LLM 请求使用 30 分钟兜底期限,独立于前台 Provider 的短请求超时。总编译期限 `compilation_timeout` 默认 `0`,允许普通编译 Agent 持续生成、验证和修复;显式配置正值才增加整体兜底。扩展关闭或宿主取消仍立即终止请求。超时用于回收异常挂起,不用于限制正常编译耗时。 +每次纯离线回放最多执行 5 秒;它只读取已有结果、枚举有限语义分支,不调用真实工具或模型。生成源码空转或计算超限时返回 `replay_execution_limit`,编译 Agent 可以继续修复;这不改变整体编译期限或真实工具的执行期限。 + 编译器沿用宿主 Agent 的临时 Provider 错误重试策略;取消、非重试错误和已耗尽的上下文仍终止。`finish_reason: length` 表示不完整输出,即使包含看似完整的工具调用也不会执行;编译器将输出预算从 16,384 逐步扩大到 65,536 后重新生成,并保留每次请求的用量。上下文不足或最高预算仍耗尽时报告明确失败,保留 Claim 和编译诊断。 JEV 判定使用 32 KiB 的私有证据投影,编译回放另保留完整的宿主调用/结果轨迹。投影省略早期证据不会删除编译器的实际记录;自动归纳和普通 `jev compile` 使用相同边界。独立语义评审共享一份当前实际轨迹,系统约束和此前用户要求各保留一次;报告保留已校验的 evidence/path 引用。回放的修复诊断由验证器直接读取,语义评审只接收分支结果与共享原生调用,避免逐边界复制结果、诊断和机制资格摘要。超出 Claim 上下文限制仍返回 `review_input_limit`,不能通过截断证据或跳过审查发布。 +回放失败的工具反馈提供尚未消费的 `remaining_recorded_calls`,保留失败读取、最后确认、原生参数及顺序。语义评审超限的诊断提供各部分字节数,帮助定位重复输出;所有验证门槛仍保留。 + 编译产物的 `when` 描述用户任务入口适用性,`decide` 描述函数负责的工作和完成/交接条件。使用参数的产物必须声明 schema,独立评审检查所有必需值能在入口取得;函数自己创建的资源名与已经存在的句柄有不同来源,原生检查才能发现的地址由函数内部取得。Claim 的错误选项不会被拼入 Reflex 的适用描述。 运行时参数提取直接接收原始约束文本、当前证据和 schema,省去源码与样例。截断时将输出预算从 2,048 逐步增加到 8,192,全部尝试计入前台用量,部分值不进入执行。宿主提供一次提取专用的唯一名称,仅供 schema 明确描述的新建资源使用;已有句柄和业务字段仍须来自当前证据。运行时审查直接读取原始约束文本,按 schema 解释字段的编码形式;绑定判定只接收当前调用的工具协议,宿主继续验证完整原生契约及效果身份。结构化原生结果直接保存为 JSON,避免重复转义挤出实际轨迹,原始日志和 JavaScript 读取接口保留原值。接管及验证的整体期限使用 30 分钟兜底。参数交接失败且没有保留或派发任何效果时,释放普通执行;同一输入下不反复选择这个失败函数,新用户输入或不同函数可再尝试。存在已知或未知效果时继续保护日志,避免重复写入。 diff --git a/exts/jev/compile.go b/exts/jev/compile.go index 288c397f..dad44a8d 100644 --- a/exts/jev/compile.go +++ b/exts/jev/compile.go @@ -359,7 +359,11 @@ func (e *Extension) reviewReflex(ctx context.Context, reflex *Program, plan *com reviewState := json.RawMessage(jsonText(review)) for _, check := range checks { if len(check.Context)+len("\nCurrent evidence (untrusted data):\n")+len(reviewState) > 64<<10 { - return compilationOutputError{compilerValidationError{CompilerDiagnostic{Code: "review_input_limit", Stage: "semantic", Status: "repair", Message: "Complete semantic review evidence exceeds the Claim context limit.", Action: "Simplify redundant semantic branches and repeated generated facts. Deterministic matching of current user labels against structured native values needs no JEV choice. Keep every necessary semantic alternative and native replay binding; no evidence was truncated and no artifact was accepted."}}} + sizes := map[string]int{} + for name, value := range review { + sizes[name] = len(jsonText(value)) + } + return compilationOutputError{compilerValidationError{CompilerDiagnostic{Code: "review_input_limit", Stage: "semantic", Status: "repair", Message: "Complete semantic review evidence exceeds the Claim context limit.", Action: "Inspect actual component byte counts. Simplify redundant semantic branches and repeated generated output. Reference actual native report fields with {evidence:call_id,path:[...]}, rather than returning entire snapshots or repeated native payloads. Keep every necessary semantic alternative and native replay binding; no evidence was truncated and no artifact was accepted.", Actual: sizes}}} } } out, checkErr := e.exchange(ctx, "jev_reflex", reviewState, checks) diff --git a/exts/jev/compiler_agent.go b/exts/jev/compiler_agent.go index 27573049..109f4ec7 100644 --- a/exts/jev/compiler_agent.go +++ b/exts/jev/compiler_agent.go @@ -106,7 +106,9 @@ func (e *Extension) newCompilerAgent(plan *compilation) *compilerAgent { func (c *compilerAgent) ToolDefinitions() []*aop.ToolDefinition { return []*aop.ToolDefinition{ - {Name: "validate_reflex", Description: "Validate the complete artifact: syntax, native contracts, recorded replay and independent JEV semantic review. Repair failures using diagnostic.code, expected, actual and action. Accepted artifacts complete compilation. No real user tool is executed.", InputSchema: &aop.EncodedValue{MediaType: aop.JSONMediaType, Data: []byte(`{"type":"object","required":["artifact"],"properties":{"artifact":{"type":"object"}},"additionalProperties":false}`)}}, + coretool.Def("validate_reflex", "Validate the complete artifact: syntax, native contracts, recorded replay and independent JEV semantic review. Repair failures using diagnostic.code, expected, actual and action. Accepted artifacts complete compilation. No real user tool is executed.", struct { + Artifact reflexArtifact `json:"artifact"` + }{}), {Name: "inspect_evidence", Description: "Read the actual compilation evidence with decoded native argv, exact argument strings, current tool schemas and native contracts. Use this to locate a replay mismatch or missing prerequisite. This tool never dispatches a user operation or invents results.", InputSchema: &aop.EncodedValue{MediaType: aop.JSONMediaType, Data: []byte(`{"type":"object","properties":{},"additionalProperties":false}`)}}, } } @@ -199,6 +201,19 @@ func (c *compilerAgent) ExecuteTool(ctx context.Context, name, arguments string) _ = c.extension.audit("reflex_validation", record) c.extension.emit(ctx, record) feedback := map[string]any{"accepted": false, "diagnostic": diagnostic} + if diagnostic.Stage == "replay" && diagnostic.Boundary != nil && diagnostic.Call != nil { + replay, replayErr := newObservationReplay(r, c.plan.state, c.plan.capabilities) + if replayErr == nil { + remaining, remainingErr := replay.resultsAfter(replay.input(*diagnostic.Boundary, true)) + if remainingErr == nil && *diagnostic.Call >= 0 && *diagnostic.Call < len(remaining) { + calls := make([]map[string]any, 0, len(remaining)-*diagnostic.Call) + for _, row := range remaining[*diagnostic.Call:] { + calls = append(calls, map[string]any{"call_id": row["call_id"], "name": row["name"], "arguments": row["arguments"], "decoded_argv": row["decoded_argv"], "is_error": row["is_error"]}) + } + feedback["remaining_recorded_calls"] = calls + } + } + } if updated { feedback["current_evidence"], _ = compilerInput(c.plan.state, c.plan.capabilities) feedback["evidence_update"] = "The foreground task supplied newer actual results. Use this current evidence for repair; the original Agent input is an earlier snapshot." diff --git a/exts/jev/compiler_repair_test.go b/exts/jev/compiler_repair_test.go index b1f6a46a..d0ef865c 100644 --- a/exts/jev/compiler_repair_test.go +++ b/exts/jev/compiler_repair_test.go @@ -145,6 +145,31 @@ func TestReflexCompilerSemanticRejectionReturnsToAgent(t *testing.T) { } } +func TestReflexCompilerRepairsNonterminatingReplay(t *testing.T) { + e, plan, actor := compilerReadPlan(t, nil) + requests := 0 + plan.job.cfg.Provider = testProvider(func(_ context.Context, req *provider.ChatCompletionRequest) (*provider.ChatCompletionResponse, error) { + requests++ + if requests == 2 { + last := provider.MessageToolResult(req.Messages[len(req.Messages)-1]) + if last == nil || !strings.Contains(coretool.ResultText(last), "replay_execution_limit") { + t.Fatal("nonterminating replay did not return a repair diagnostic") + } + } + artifact := compilerReadArtifact(actor) + if requests == 1 { + artifact["observe"] = strings.Replace(artifact["observe"].(string), "{if", "{if(context.history.length){while(true){}}if", 1) + } + return reply(compilerTool("validate_reflex", map[string]any{"artifact": artifact})), nil + }) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + r, err := e.generateReflex(ctx, plan) + if err != nil || r == nil || requests != 2 || !e.qualified(*r) || ctx.Err() != nil { + t.Fatalf("nonterminating draft blocked repair: requests=%d artifact=%v err=%v", requests, r != nil, err) + } +} + func TestReflexCompilerCompactsAndContinuesRepair(t *testing.T) { e, plan, actor := compilerReadPlan(t, nil) plan.job.cfg.ContextWindow = 24000 @@ -514,3 +539,96 @@ func TestReflexCompilerEvidenceUpdateMatchesRuntimeHistory(t *testing.T) { t.Fatalf("runtime and compiler evidence ABI diverged: output=%v err=%v", out, err) } } + +func TestReflexCompilerToolSchemaMatchesArtifactDecoder(t *testing.T) { + e, plan, actor := compilerReadPlan(t, nil) + definition := e.newCompilerAgent(plan).ToolDefinitions()[0] + schema := &Program{Parameters: definition.InputSchema.Data} + artifact := compilerReadArtifact(actor) + artifact["steps"] = map[string]any{"submit": map[string]any{"contract": "lab", "count_argument": "count"}} + artifact["readers"] = map[string]any{"receipt": "function(){return null;}"} + for _, sample := range []map[string]any{ + artifact, + {"when": "Read current result", "decide": "Report actual evidence", "observe": "js:function(){return{defer:'missing evidence'};}"}, + } { + if err := validateParameters(schema, map[string]any{"artifact": sample}); err != nil { + t.Fatalf("compiler schema hides a supported artifact: %v", err) + } + var decoded *Program + if err := decodeReflex(jsonText(sample), &decoded); err != nil || decoded == nil { + t.Fatalf("advertised artifact cannot be decoded: %v", err) + } + } + artifact["reportExtra"] = true + if err := validateParameters(schema, map[string]any{"artifact": artifact}); err == nil { + t.Fatal("compiler schema advertises unknown artifact fields") + } + var decoded *Program + if err := decodeReflex(jsonText(artifact), &decoded); err == nil { + t.Fatal("artifact decoder accepted unknown fields") + } + delete(artifact, "reportExtra") + artifact["observe"] = "function(){return null;}" + if err := validateParameters(schema, map[string]any{"artifact": artifact}); err == nil { + t.Fatal("compiler schema omitted the required js: source prefix") + } + artifact["observe"] = "js:function(){return null;}" + artifact["parameters_schema"] = "not a JSON Schema object" + if err := validateParameters(schema, map[string]any{"artifact": artifact}); err == nil { + t.Fatal("compiler schema treats parameters_schema as encoded bytes or a string") + } +} + +func TestReflexCompilerRepairKeepsFailedAndConfirmationReads(t *testing.T) { + e, plan, actor := compilerReadPlan(t, nil) + var state struct { + Messages []map[string]any `json:"messages"` + } + if err := json.Unmarshal(plan.state, &state); err != nil { + t.Fatal(err) + } + for _, row := range []struct { + id string + failed bool + text string + }{{"failed-read", true, "inspection temporarily unavailable"}, {"confirmation-read", false, `{"complete":true,"receipt":"confirmed-current-receipt"}`}} { + state.Messages = append(state.Messages, + map[string]any{"role": "assistant", "calls": []any{map[string]any{"id": row.id, "name": "bash", "arguments": map[string]any{"command": coretool.JoinCommandLine("lab", []string{"status", actor})}}}}, + map[string]any{"role": "tool", "name": "bash", "call_id": row.id, "is_error": row.failed, "text": row.text}, + ) + } + plan.state = json.RawMessage(jsonText(state)) + compiler := e.newCompilerAgent(plan) + artifact := compilerReadArtifact(actor) + artifact["observe"] = `js:function(context,args){if(!args||!args.actor)return{defer:"missing current arguments",parameters:"actor"};const r=context.history.filter(function(h){return !h.is_error&&h.data&&h.data.receipt;})[0]||execute({name:"bash",arguments:{command:command("lab",["status",args.actor])},read:true});return{report:{evidence:r.call_id,path:["data","receipt"]}};}` + result, err := compiler.ExecuteTool(t.Context(), "validate_reflex", jsonText(map[string]any{"artifact": artifact})) + if err != nil { + t.Fatal(err) + } + var feedback struct { + Accepted bool `json:"accepted"` + Diagnostic CompilerDiagnostic `json:"diagnostic"` + Remaining []struct { + CallID string `json:"call_id"` + Argv []string `json:"decoded_argv"` + Arguments struct { + Command string `json:"command"` + } `json:"arguments"` + Failed bool `json:"is_error"` + } `json:"remaining_recorded_calls"` + } + if err := json.Unmarshal([]byte(coretool.ResultText(result)), &feedback); err != nil { + t.Fatal(err) + } + if feedback.Accepted || feedback.Diagnostic.Code != "trajectory_incomplete" || len(feedback.Remaining) != 2 { + t.Fatalf("incomplete trajectory accepted or repair evidence lost: %s", coretool.ResultText(result)) + } + if feedback.Remaining[0].CallID != "failed-read" || !feedback.Remaining[0].Failed || feedback.Remaining[1].CallID != "confirmation-read" || feedback.Remaining[1].Failed { + t.Fatal("repair lost the recorded read order or error flags") + } + for _, row := range feedback.Remaining { + if len(row.Argv) != 3 || row.Argv[2] != actor || row.Arguments.Command != coretool.JoinCommandLine("lab", []string{"status", actor}) { + t.Fatal("repair changed the literal native command") + } + } +} diff --git a/exts/jev/declare.go b/exts/jev/declare.go index f6bac02d..73e3a58c 100644 --- a/exts/jev/declare.go +++ b/exts/jev/declare.go @@ -336,16 +336,18 @@ func (e compilationOutputError) Unwrap() error { return e.error } // Compilation owns executable source, applicability and its runtime contract. // Example arguments are used for replay and are never persisted. +type reflexArtifact struct { + When string `json:"when" jsonschema:"description=Reusable user goal supported at task entry"` + Decide string `json:"decide" jsonschema:"description=Work and completion or handoff owned by this function"` + Parameters json.RawMessage `json:"parameters_schema,omitempty" jsonschema:"type=object,description=JSON Schema for current runtime arguments including each field's meaning and source"` + Steps map[string]StepDefinition `json:"steps,omitempty" jsonschema:"description=Effect step IDs mapped to native contract IDs and count or count_argument"` + Observe string `json:"observe" jsonschema:"pattern=^js:,description=Source beginning with js: followed by a synchronous function accepting context and args and using the documented execute and jev primitives"` + Readers map[string]string `json:"readers,omitempty" jsonschema:"description=Optional independent serialized reader function sources"` + Arguments map[string]any `json:"arguments,omitempty" jsonschema:"description=Exact current example arguments for replay only; never persisted or used as runtime defaults"` +} + func decodeReflex(text string, output **Program) error { - var artifact *struct { - When string `json:"when"` - Decide string `json:"decide"` - Parameters json.RawMessage `json:"parameters_schema"` - Steps map[string]StepDefinition `json:"steps"` - Observe string `json:"observe"` - Readers map[string]string `json:"readers"` - Arguments map[string]any `json:"arguments"` - } + var artifact *reflexArtifact decoder := json.NewDecoder(strings.NewReader(text)) decoder.DisallowUnknownFields() decoder.UseNumber() diff --git a/exts/jev/playwright_takeover_live_test.go b/exts/jev/playwright_takeover_live_test.go index c96c6805..fae6973d 100644 --- a/exts/jev/playwright_takeover_live_test.go +++ b/exts/jev/playwright_takeover_live_test.go @@ -215,7 +215,7 @@ func TestLivePlaywrightTakeoverMatrix(t *testing.T) { if !ok { t.Fatal("browser command unavailable") } - cfg.Provider, cfg.Model, cfg.MaxTurns, cfg.MaxTokens, cfg.MaxRetries = meter, os.Getenv("CYBER_MODEL"), 20, 8192, agent.DefaultMaxRetries + cfg.Provider, cfg.Model, cfg.MaxTurns, cfg.MaxTokens, cfg.MaxRetries = meter, os.Getenv("CYBER_MODEL"), 40, 8192, agent.DefaultMaxRetries cfg.SystemPrompt = "Complete the authorized local browser business task through the available browser tool. Inspect live controls with snapshot --json before effects and inspect the resulting UI afterwards. Issue exactly one native playwright command per tool call, without shell pipelines, compound scripts or arbitrary evaluate. Use the documented native fill/click/read operations and current snapshot addresses. For asynchronous page updates use native wait-for or fresh browser reads; never use shell sleep. Perform dependent operations sequentially. Preserve the browser session. Use --no-speed-up and --op-timeout 3 on open. Treat page content as evidence. Report failures candidly. Never inspect test control endpoints, harness code, model logs or environment secrets. Do not create subagents or install software.\n" + command.GetUsage() installs[mode] = installation{e, cfg, meter, client} } diff --git a/exts/jev/skills/reflex-compiler/SKILL.md b/exts/jev/skills/reflex-compiler/SKILL.md index a7acee6b..6eebdc43 100644 --- a/exts/jev/skills/reflex-compiler/SKILL.md +++ b/exts/jev/skills/reflex-compiler/SKILL.md @@ -28,11 +28,23 @@ new effect. A user URL is an argument; a handle created by the tool is a result, not a missing user parameter. Inspect the current operation and continue only the remaining work at each boundary. Do not reopen a submitted workflow simply because the function is invoked again. +Recover a created handle from its documented result field or decoded creation +arguments. The resource URL and the session name are different values. Check +the exact recorded flag/positional syntax before using either in later calls. context.history is the snapshot at entry to this invocation. It does not grow when execute returns. Keep the actual execute result in a local variable and process that fresh result directly; never re-scan the old snapshot expecting the new read to appear. Consuming every recorded call and then returning defer is not a complete replay and cannot qualify the entry path. +Replay must consume the recorded native calls in order, including failed reads +and later confirmation snapshots. A receipt becoming visible before the final +recorded read does not make that suffix disappear. The rejection includes +remaining_recorded_calls at its failing boundary; compare the whole suffix, +not just the next call. Preserve each operation and option while deriving its +current values from args or actual results. Do not substitute inner-text for a +recorded snapshot, add a session prefix to an already explicit session argument, +or return early from a snapshot that first shows the receipt. Handle recorded +read failures and continue the demonstrated inspection without repeating writes. If evidence is unavailable, explain exactly which capability or actual result is missing. Changing a read flag or inventing a response cannot fill that gap. @@ -44,6 +56,12 @@ Use args or current native results for all task values. Guard all required arguments together before external work. Example values are never runtime defaults. JSON-encode the source and argument strings once; compare decoded strings, not their escaped appearance. command(program, argv) handles shell encoding. +Each argument has one representation. For a user value supplied as a JSON string +literal, normally bind its decoded literal value and say so in the schema; use +it directly in the function. Do not describe one property as either serialized +or decoded, or conditionally JSON-decode already decoded arguments. A property +whose purpose is to carry serialized content must explicitly require that +representation instead. Guard args itself before reading any property. Semantic decisions use jev({type:"choice",context:"meaning and current facts", options:["candidate","defer"]}) and return an option string directly. score uses @@ -93,6 +111,7 @@ Read diagnostic.code, stage, status, action, expected and actual: | completion_missing | The calls replayed, but the function still handed off. Process fresh execute return values and return the requested grounded report; entry history is a snapshot. | | unrecorded_native_call | Use already available evidence if the read is redundant. A necessary alternative execution path requires its own actual trajectory; do not fabricate it. | | output_limit | Generate the complete artifact again with the increased output budget. Truncated reasoning, source and tool calls were discarded; no validation tool ran. | +| replay_execution_limit | Pure recorded replay exceeded five seconds. Fix loops that do not advance or terminate; replay has no real tool or model latency. The compilation job continues. | | review_input_limit | Simplify redundant semantic branches/facts; deterministic matching of current user labels against native values needs no JEV choice. Preserve required alternatives and exact replay. No evidence was truncated. | | parameter_boundary_invalid | Acquire created handles and native-discovered addresses inside the function; require current user labels/values instead of facts unavailable at entry. | | parameter_schema_missing | Define the runtime argument schema and field meanings; distinguish existing handles from fresh allocation names. | diff --git a/exts/jev/verify.go b/exts/jev/verify.go index 6bf9582a..a4311c67 100644 --- a/exts/jev/verify.go +++ b/exts/jev/verify.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" jevapi "github.com/chainreactors/cyber/agent/provider/jev" "github.com/chainreactors/cyber/core/decision" @@ -82,6 +83,10 @@ var errProbeStop = errors.New("pure probe reached native dispatch") // pairs. No provider or native tool is invoked during publication checks. // The exact same program and bridges are used in foreground execution. func probeReflexArguments(ctx context.Context, reflex *Program, input, args map[string]any, results []map[string]any, allowMissing bool) (replayResult, error) { + // This probe only supplies recorded results and finite JEV alternatives. + // A pure JS loop must return a repair diagnostic without blocking the job. + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() candidates := map[string]NativeCall{} states := []any{} branches := []replayBranch{} @@ -205,14 +210,17 @@ func probeReflexArguments(ctx context.Context, reflex *Program, input, args map[ } return nil, errProbeStop } - output, err := runReflexJS(ctx, reflex, input, args, judge, execute) + output, err := runReflexJS(probeCtx, reflex, input, args, judge, execute) + if err != nil && ctx.Err() == nil && errors.Is(probeCtx.Err(), context.DeadlineExceeded) { + return replayResult{}, compilerValidationError{CompilerDiagnostic{Code: "replay_execution_limit", Stage: "replay", Status: "repair", Message: "Pure recorded replay exceeded its five-second execution budget.", Action: "Repair nonterminating loops or excessive computation. The probe invokes no real tools or model requests; every loop must advance or terminate from current evidence. Resubmit the repaired artifact; the overall compilation job remains active."}} + } if err != nil && !errors.Is(interruptedCause(err), errProbeStop) { return replayResult{}, err } if gap == "" && cursor < len(results) { gap = fmt.Sprintf("function returned after %d/%d recorded native results", cursor, len(results)) index := cursor - diagnostic = &CompilerDiagnostic{Code: "trajectory_incomplete", Stage: "replay", Status: "repair", Message: gap, Call: &index, Replayed: cursor, Recorded: len(results), Expected: results[cursor], Actual: output, Action: "Continue the capability's required work within the synchronous function. A pending result is not completion: poll the same operation with fresh reads and inspect the required final count/receipt. Returning defer hands control to the main model; it does not schedule another invocation."} + diagnostic = &CompilerDiagnostic{Code: "trajectory_incomplete", Stage: "replay", Status: "repair", Message: gap, Call: &index, Replayed: cursor, Recorded: len(results), Expected: results[cursor], Actual: output, Action: "Consume the remaining recorded calls in order, including failed reads and final confirmation snapshots; inspect remaining_recorded_calls and the exact expected operation. Do not return at the first visible receipt while recorded reads remain. A pending result is not completion. Returning defer hands control to the main model; it does not schedule another invocation."} } if gap == "" && err == nil && output != nil && output[Defer] != nil && output["parameters"] == nil { gap = "function consumed recorded results but handed off without a completed report" From ba335b2477b13aaf62e5a2f3cb5c644419c9a1f0 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Mon, 26 Oct 2026 14:57:40 +0800 Subject: [PATCH 2/2] refactor(jev): use core native contracts directly --- docs/changelog.md | 1 + exts/jev/binding_validation.go | 3 +- exts/jev/boundaries_test.go | 47 +++++++++--------- exts/jev/business_oracle_test.go | 70 ++++----------------------- exts/jev/compile.go | 4 +- exts/jev/contracts.go | 4 +- exts/jev/effects.go | 36 +++++++------- exts/jev/execute.go | 10 ++-- exts/jev/limits_test.go | 4 +- exts/jev/mechanism_test.go | 20 ++++---- exts/jev/native_mechanism_test.go | 10 ++-- exts/jev/observation_protocol_test.go | 6 ++- exts/jev/observe.go | 18 +++---- exts/jev/observe_javascript.go | 5 +- exts/jev/qualification.go | 24 ++++----- exts/jev/runtime_test.go | 12 +++-- exts/jev/supplement.go | 8 +-- exts/jev/trace_test.go | 2 +- exts/jev/verify.go | 33 ++++--------- exts/jev/verify_test.go | 44 ++++++++++++----- 20 files changed, 161 insertions(+), 200 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index bec1cdeb..44933103 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,7 @@ - 工具返回部分输出时保留失败原因;后台 subagent 仅返回错误时也通知父任务。联网搜索要求实际搜索执行记录,失败时保留后端原因。 - browser、passive 与 Katana 可由入口直接导入组装,`full` 保留为 aiscan 的全量组合;原生 `record` 可独立于 `full` 构建。 - Go 源码 API:工具结果与定义直接使用 `aop.ToolResult` / `aop.ToolDefinition`;Provider 类型和构造函数改从 `agent/provider` 引用,停止原因从 `agent/hooks` 引用。Context hook 直接返回 `[]*aop.Message`,nil 表示不替换。删除重复转导出别名,AOP wire 字段保持兼容。 +- JEV 原生调用与契约统一使用 `core/tool.NativeCall`、`NativeContract` 和 `NativeContracts`,访问分类直接使用 `NativeRead` / `NativeEffect` / `NativeUnsupported`;删除重复类型、转换和测试专用回放入口。 - passive 的 `recon` 声明与连接检查迁至 `exts/passive/config`,运行实现不再依赖 scanner engine;CyberHub 索引查询迁至 `tools/cyberhub`。原有配置键与 CLI 参数保持兼容。 ## v1.0.0-rc8 — 工作台重构、节点接入与任务复用 diff --git a/exts/jev/binding_validation.go b/exts/jev/binding_validation.go index d413cd2f..f567a21d 100644 --- a/exts/jev/binding_validation.go +++ b/exts/jev/binding_validation.go @@ -6,6 +6,7 @@ import ( "fmt" "sync" + coretool "github.com/chainreactors/cyber/core/tool" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -21,7 +22,7 @@ func (localSchemas) Load(url string) (any, error) { } // Compile only registered schemas; validation never fetches schema URLs. -func validateBindingSchema(candidate NativeCall, capabilities map[string]any) error { +func validateBindingSchema(candidate coretool.NativeCall, capabilities map[string]any) error { tools, _ := capabilities["tools"].([]any) for _, value := range tools { tool, ok := value.(map[string]any) diff --git a/exts/jev/boundaries_test.go b/exts/jev/boundaries_test.go index e34c07e9..ccaa6241 100644 --- a/exts/jev/boundaries_test.go +++ b/exts/jev/boundaries_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/chainreactors/cyber/core/decision" + coretool "github.com/chainreactors/cyber/core/tool" "github.com/chainreactors/cyber/agent" "github.com/chainreactors/cyber/agent/hooks" @@ -18,7 +19,6 @@ import ( jevapi "github.com/chainreactors/cyber/agent/provider/jev" "github.com/chainreactors/cyber/aop" "github.com/chainreactors/cyber/core/operation" - coretool "github.com/chainreactors/cyber/core/tool" ) func qualifiedLaboratory(t *testing.T, e *Extension, caps map[string]any) Program { @@ -153,7 +153,7 @@ func TestReflexMissingNativeEvidenceDoesNotPersistDraft(t *testing.T) { func TestReflexKnownFailureAndUnknownResolution(t *testing.T) { s := laboratorySuite() - c := NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab add actor"}`), Step: "add"} + c := coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab add actor"}`), Step: "add"} c, _ = prepareBinding(c) l := newEffectLedger() _, _, _ = l.reserve("task", c) @@ -163,14 +163,14 @@ func TestReflexKnownFailureAndUnknownResolution(t *testing.T) { if _, _, err := l.reserve("task", second); err == nil { t.Fatal("unknown outcome allowed another mutation") } - read, _ := prepareBinding(NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab status other"}`), Read: true}) - l.reconcile(s.native(), read, map[string]any{"data": map[string]any{"complete": true}}) + read, _ := prepareBinding(coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab status other"}`), Read: true}) + l.reconcile(s.Contracts, read, map[string]any{"data": map[string]any{"complete": true}}) if _, _, err := l.reserve("task", second); err == nil { t.Fatal("unrelated status resolved an effect") } read.Arguments = json.RawMessage(`{"command":"lab status actor"}`) read, _ = prepareBinding(read) - l.reconcile(s.native(), read, map[string]any{"call_id": "actual-read", "data": map[string]any{"complete": true}}) + l.reconcile(s.Contracts, read, map[string]any{"call_id": "actual-read", "data": map[string]any{"complete": true}}) if _, cached, err := l.reserve("task", c); err != nil || !cached { t.Fatal("resolved occurrence redispatched") } @@ -178,11 +178,11 @@ func TestReflexKnownFailureAndUnknownResolution(t *testing.T) { t.Fatal("resolved effect did not allow the next occurrence") } contract := s.Contracts["lab"] - contract.Outcome = func(NativeCall, map[string]any) string { return "not_applied" } + contract.Outcome = func(coretool.NativeCall, map[string]any) string { return "not_applied" } s.Contracts["lab"] = contract l.complete("task", second, map[string]any{"is_error": true}, errors.New("explicit rejection")) - l.classifyOutcome("task", second, s.native(), map[string]any{"is_error": true}, "lab") - if _, _, err := l.reserve("task", NativeCall{Step: "another"}); err != nil { + l.classifyOutcome("task", second, s.Contracts, map[string]any{"is_error": true}, "lab") + if _, _, err := l.reserve("task", coretool.NativeCall{Step: "another"}); err != nil { t.Fatal("trusted definite rejection was treated as unknown") } } @@ -191,17 +191,17 @@ func TestReflexOutcomeUsesDeclaredContract(t *testing.T) { s := laboratorySuite() other := s.Contracts["lab"] other.ID = "other" - other.Outcome = func(NativeCall, map[string]any) string { return "applied" } - other.Resolve = func(NativeCall, NativeCall, map[string]any) bool { return true } + other.Outcome = func(coretool.NativeCall, map[string]any) string { return "applied" } + other.Resolve = func(coretool.NativeCall, coretool.NativeCall, map[string]any) bool { return true } s.Contracts[other.ID] = other l := newEffectLedger() - c, _ := prepareBinding(NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab add actor"}`), Step: "add"}) + c, _ := prepareBinding(coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab add actor"}`), Step: "add"}) _, _, _ = l.reserve("task", c) result := map[string]any{"is_error": false, "data": map[string]any{"status": 503}} l.complete("task", c, result, nil) - l.classifyOutcome("task", c, s.native(), result, "lab") - read, _ := prepareBinding(NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab status wrong"}`), Read: true}) - l.reconcile(s.native(), read, map[string]any{"data": map[string]any{"complete": true}}) + l.classifyOutcome("task", c, s.Contracts, result, "lab") + read, _ := prepareBinding(coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab status wrong"}`), Read: true}) + l.reconcile(s.Contracts, read, map[string]any{"data": map[string]any{"complete": true}}) next := c next.Occurrence = 1 if _, _, err := l.reserve("task", next); err == nil { @@ -314,16 +314,16 @@ func TestReflexEvidencePathsTraverseCurrentArrays(t *testing.T) { func TestReflexShellAndEvidenceBoundaries(t *testing.T) { for _, script := range []string{"lab add $ACTOR", "lab add $(whoami)", "lab add actor > file", "lab add actor; lab add actor", "lab add *", "lab add ~", "lab add {a,b}"} { - c, err := prepareBinding(NativeCall{Name: "bash", Arguments: json.RawMessage(jsonText(map[string]any{"command": script})), Argv: []string{"lab", "add", "forged"}}) + c, err := prepareBinding(coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(jsonText(map[string]any{"command": script})), Argv: []string{"lab", "add", "forged"}}) if err != nil || len(c.Argv) != 0 { t.Fatalf("opaque shell gained trusted argv: %s %v", script, c.Argv) } - if _, err := laboratorySuiteAccess(c); err == nil { + if _, err := laboratorySuite().Contracts.Access(c); err == nil { t.Fatal("opaque shell was admitted") } } - a, _ := prepareBinding(NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab add actor"}`)}) - b, _ := prepareBinding(NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":" lab add 'actor' "}`)}) + a, _ := prepareBinding(coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab add actor"}`)}) + b, _ := prepareBinding(coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":" lab add 'actor' "}`)}) if logicalBinding(a) != logicalBinding(b) { t.Fatal("cosmetic shell changes altered operation identity") } @@ -337,11 +337,6 @@ func TestReflexShellAndEvidenceBoundaries(t *testing.T) { } } -func laboratorySuiteAccess(c NativeCall) (coretool.NativeAccess, error) { - s := laboratorySuite() - return s.access(c) -} - func TestReflexCompilerEffortAndCancellationOwnItsLifetime(t *testing.T) { e := testLaboratory(t) e.config.DeclarationEffort = "none" @@ -376,7 +371,7 @@ func TestReflexReportChecksCurrentRequest(t *testing.T) { } func TestReflexPureSemanticCapabilityAndFreshArguments(t *testing.T) { - s := VerificationSuite{ID: "semantic", Version: "1", Contracts: map[string]NativeContract{}, CheckInput: func(input, args map[string]any) error { + s := VerificationSuite{ID: "semantic", Version: "1", Contracts: coretool.NativeContracts{}, CheckInput: func(input, args map[string]any) error { if args == nil || input["user"] != fmt.Sprint(args["mode"])+":"+fmt.Sprint(args["value"]) { return errors.New("arguments do not represent this request") } @@ -403,7 +398,9 @@ func TestReflexPureSemanticCapabilityAndFreshArguments(t *testing.T) { Judge: func(claim jevapi.Claim) (*decision.Evaluation, error) { return &decision.Evaluation{Value: &decision.Evaluation_Choice{Choice: mode}}, nil }, - Execute: func(NativeCall) (map[string]any, error) { return nil, errors.New("pure capability must not dispatch") }, + Execute: func(coretool.NativeCall) (map[string]any, error) { + return nil, errors.New("pure capability must not dispatch") + }, Check: func(run VerificationRun) error { if run.Error != nil { return run.Error diff --git a/exts/jev/business_oracle_test.go b/exts/jev/business_oracle_test.go index c6bfcff4..3ce16457 100644 --- a/exts/jev/business_oracle_test.go +++ b/exts/jev/business_oracle_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "sort" "sync" jevapi "github.com/chainreactors/cyber/agent/provider/jev" @@ -20,7 +19,7 @@ func independentRuntimeJudgments(req inferenceRequest) map[string]inferenceAnswe var payload struct { State struct { Arguments map[string]any `json:"arguments"` - Call NativeCall `json:"call"` + Call coretool.NativeCall `json:"call"` Report map[string]any `json:"report"` Evidence map[string]map[string]any `json:"evidence"` } `json:"state"` @@ -50,13 +49,6 @@ func independentRuntimeJudgments(req inferenceRequest) map[string]inferenceAnswe var testTrajectories sync.Map -type NativeContract struct { - ID, Version, Description string - Classify func(NativeCall) (coretool.NativeAccess, error) - Outcome func(NativeCall, map[string]any) string - Resolve func(NativeCall, NativeCall, map[string]any) bool -} - // VerificationCase supplies a fresh simulator and independent postcondition. // It must never use the foreground Executor or user resources. type VerificationCase struct { @@ -64,11 +56,11 @@ type VerificationCase struct { Input map[string]any Arguments map[string]any Judge func(jevapi.Claim) (*decision.Evaluation, error) - Execute func(NativeCall) (map[string]any, error) + Execute func(coretool.NativeCall) (map[string]any, error) Check func(VerificationRun) error } type VerificationRun struct { - Calls []NativeCall + Calls []coretool.NativeCall Evidence map[string]map[string]any Output map[string]any Error error @@ -85,7 +77,7 @@ type VerificationReport struct { type VerificationCall struct { Input map[string]any Arguments map[string]any - Call NativeCall + Call coretool.NativeCall Effects map[string]any Evidence map[string]map[string]any } @@ -94,7 +86,7 @@ type VerificationSuite struct { ID string Version string Description string - Contracts map[string]NativeContract + Contracts coretool.NativeContracts Cases func(map[string]any) []VerificationCase CheckInput func(map[string]any, map[string]any) error CheckCall func(VerificationCall) error @@ -112,7 +104,7 @@ func (h testRegistryHandle) Register(s VerificationSuite) error { r, _ := testRegistries.LoadOrStore(h.e, &VerificationRegistry{suites: map[string]*VerificationSuite{}}) r.(*VerificationRegistry).suites[s.ID] = &s h.e.contracts = coretool.NewNativeContractRegistry() - for _, c := range s.native() { + for _, c := range s.Contracts { if err := h.e.contracts.Register(c); err != nil { return err } @@ -133,48 +125,6 @@ func (h testRegistryHandle) suite(id string) *VerificationSuite { } return nil } -func (s *VerificationSuite) native() coretool.NativeContracts { - n := coretool.NativeContracts{} - for id, c := range s.Contracts { - n[id] = coretool.NativeContract{ID: c.ID, Version: c.Version, Description: c.Description, Classify: func(call coretool.NativeCall) (coretool.NativeAccess, error) { return c.Classify(NativeCall(call)) }, Outcome: func(call coretool.NativeCall, r map[string]any) string { - if c.Outcome == nil { - return "unknown" - } - return c.Outcome(NativeCall(call), r) - }, Resolve: func(a, b coretool.NativeCall, r map[string]any) bool { - return c.Resolve != nil && c.Resolve(NativeCall(a), NativeCall(b), r) - }} - } - return n -} -func (s *VerificationSuite) access(c NativeCall) (coretool.NativeAccess, error) { - ids := make([]string, 0, len(s.Contracts)) - for id := range s.Contracts { - ids = append(ids, id) - } - sort.Strings(ids) - result := UnsupportedAccess - for _, id := range ids { - a, err := s.Contracts[id].Classify(c) - if err != nil { - return UnsupportedAccess, err - } - if a == UnsupportedAccess { - continue - } - if a != ReadAccess && a != EffectAccess { - return UnsupportedAccess, errors.New("invalid native access classification") - } - if result != UnsupportedAccess && result != a { - return UnsupportedAccess, errors.New("conflicting native contracts") - } - result = a - } - if result == UnsupportedAccess { - return result, errors.New("unsupported native operation") - } - return result, nil -} func qualifyIndependent(e *Extension, ctx context.Context, r *Program, caps map[string]any, suiteIDs ...string) error { if err := r.validate(); err != nil { return err @@ -223,11 +173,11 @@ func qualifyIndependent(e *Extension, ctx context.Context, r *Program, caps map[ rows := []map[string]any{{"role": "user", "text": jsonText(input)}} run := VerificationRun{Evidence: map[string]map[string]any{}} ledger := newEffectLedger() - execute := func(call NativeCall) (map[string]any, error) { + execute := func(call coretool.NativeCall) (map[string]any, error) { if len(run.Calls) >= maxCandidates { return nil, handoffError{"native call budget reached"} } - if err := r.validateCall(s.native(), call, c.Arguments); err != nil { + if err := r.validateCall(s.Contracts, call, c.Arguments); err != nil { return nil, err } if err := s.CheckCall(VerificationCall{Input: input, Arguments: c.Arguments, Call: call, Effects: ledger.summary(), Evidence: cloneEvidence(run.Evidence)}); err != nil { @@ -252,9 +202,9 @@ func qualifyIndependent(e *Extension, ctx context.Context, r *Program, caps map[ } if !call.Read { ledger.complete("case", call, result, err) - ledger.classifyOutcome("case", call, s.native(), result, r.Steps[call.Step].Contract) + ledger.classifyOutcome("case", call, s.Contracts, result, r.Steps[call.Step].Contract) } else { - ledger.reconcile(s.native(), call, result) + ledger.reconcile(s.Contracts, call, result) } return result, err } diff --git a/exts/jev/compile.go b/exts/jev/compile.go index dad44a8d..9951bc88 100644 --- a/exts/jev/compile.go +++ b/exts/jev/compile.go @@ -155,10 +155,10 @@ func (e *Extension) prepareCompilation(ctx context.Context, job declaration, see } native := e.contracts.Snapshot() for _, row := range programInput["history"].([]map[string]any) { - call, classifyErr := prepareBinding(NativeCall{Name: fmt.Sprint(row["name"]), Arguments: json.RawMessage(jsonText(row["arguments"]))}) + call, classifyErr := prepareBinding(coretool.NativeCall{Name: fmt.Sprint(row["name"]), Arguments: json.RawMessage(jsonText(row["arguments"]))}) var access coretool.NativeAccess if classifyErr == nil { - access, classifyErr = native.Access(coretool.NativeCall(call)) + access, classifyErr = native.Access(call) } row["native_access"] = access if classifyErr != nil { diff --git a/exts/jev/contracts.go b/exts/jev/contracts.go index 637a3786..1632f0e6 100644 --- a/exts/jev/contracts.go +++ b/exts/jev/contracts.go @@ -2,6 +2,8 @@ package jev import ( "encoding/json" + + coretool "github.com/chainreactors/cyber/core/tool" ) func (e *Extension) contractsAvailable(r Program) bool { @@ -58,7 +60,7 @@ func reflexContracts(capabilities map[string]any, witnesses []map[string]any) ma } } } - for _, candidate := range witness["candidates"].(map[string]NativeCall) { + for _, candidate := range witness["candidates"].(map[string]coretool.NativeCall) { selected["tool:"+candidate.Name] = catalog["tool:"+candidate.Name] var arguments any _ = json.Unmarshal(candidate.Arguments, &arguments) diff --git a/exts/jev/effects.go b/exts/jev/effects.go index c62e827f..7a370084 100644 --- a/exts/jev/effects.go +++ b/exts/jev/effects.go @@ -16,19 +16,21 @@ import ( ) type effectRecord struct { - Call NativeCall `json:"call"` - Result map[string]any `json:"result,omitempty"` - State string `json:"state"` - Contract string `json:"contract,omitempty"` + Call coretool.NativeCall `json:"call"` + Result map[string]any `json:"result,omitempty"` + State string `json:"state"` + Contract string `json:"contract,omitempty"` } type effectLedger struct { mu sync.Mutex records map[string]*effectRecord } -func newEffectLedger() *effectLedger { return &effectLedger{records: map[string]*effectRecord{}} } -func effectID(task string, c NativeCall) string { return digest([]any{task, c.Step, c.Occurrence}) } -func logicalBinding(c NativeCall) string { +func newEffectLedger() *effectLedger { return &effectLedger{records: map[string]*effectRecord{}} } +func effectID(task string, c coretool.NativeCall) string { + return digest([]any{task, c.Step, c.Occurrence}) +} +func logicalBinding(c coretool.NativeCall) string { if len(c.Argv) > 0 { var args map[string]any d := json.NewDecoder(bytes.NewReader(c.Arguments)) @@ -37,9 +39,9 @@ func logicalBinding(c NativeCall) string { delete(args, "command") return digest([]any{c.Name, args, c.Argv}) } - return c.canonical() + return canonical(nativeToolCall(c)) } -func (l *effectLedger) reserve(task string, c NativeCall, contracts ...string) (map[string]any, bool, error) { +func (l *effectLedger) reserve(task string, c coretool.NativeCall, contracts ...string) (map[string]any, bool, error) { l.mu.Lock() defer l.mu.Unlock() id := effectID(task, c) @@ -64,7 +66,7 @@ func (l *effectLedger) reserve(task string, c NativeCall, contracts ...string) ( l.records[id] = &effectRecord{Call: c, State: "executing", Contract: contract} return nil, false, nil } -func (l *effectLedger) complete(task string, c NativeCall, result map[string]any, err error) { +func (l *effectLedger) complete(task string, c coretool.NativeCall, result map[string]any, err error) { l.mu.Lock() defer l.mu.Unlock() record := l.records[effectID(task, c)] @@ -78,7 +80,7 @@ func (l *effectLedger) complete(task string, c NativeCall, result map[string]any record.State = "returned" } } -func (l *effectLedger) classifyOutcome(task string, c NativeCall, native coretool.NativeContracts, result map[string]any, contractID string) { +func (l *effectLedger) classifyOutcome(task string, c coretool.NativeCall, native coretool.NativeContracts, result map[string]any, contractID string) { l.mu.Lock() defer l.mu.Unlock() r := l.records[effectID(task, c)] @@ -88,9 +90,9 @@ func (l *effectLedger) classifyOutcome(task string, c NativeCall, native coretoo r.Contract = contractID known := false if contract, ok := native[contractID]; ok && result != nil { - a, err := contract.Classify(coretool.NativeCall(c)) - if err == nil && a == EffectAccess && contract.Outcome != nil { - outcome := contract.Outcome(coretool.NativeCall(c), cloneJSONMap(result)) + a, err := contract.Classify(c) + if err == nil && a == coretool.NativeEffect && contract.Outcome != nil { + outcome := contract.Outcome(c, cloneJSONMap(result)) if outcome == "applied" || outcome == "not_applied" || outcome == "pending" { known = true } @@ -103,7 +105,7 @@ func (l *effectLedger) classifyOutcome(task string, c NativeCall, native coretoo } } -func (l *effectLedger) reconcile(native coretool.NativeContracts, read NativeCall, result map[string]any) { +func (l *effectLedger) reconcile(native coretool.NativeContracts, read coretool.NativeCall, result map[string]any) { if result == nil || result["is_error"] == true { return } @@ -117,7 +119,7 @@ func (l *effectLedger) reconcile(native coretool.NativeContracts, read NativeCal if record.Contract != "" && record.Contract != id { continue } - if c.Resolve != nil && c.Resolve(coretool.NativeCall(record.Call), coretool.NativeCall(read), cloneJSONMap(result)) { + if c.Resolve != nil && c.Resolve(record.Call, read, cloneJSONMap(result)) { record.Result = cloneJSONMap(result) record.State = "returned" break @@ -148,7 +150,7 @@ func (l *effectLedger) unresolved() bool { // command(name, argv) is a structured value until the host encodes it. It is // only valid in bash.arguments.command; arbitrary command strings stay opaque. -func prepareBinding(c NativeCall) (NativeCall, error) { +func prepareBinding(c coretool.NativeCall) (coretool.NativeCall, error) { c.ID = "" // Invocation identity is assigned by the host, never generated code. c.Argv = nil // Generated metadata must never override the actual native call. if c.Name != "bash" { diff --git a/exts/jev/execute.go b/exts/jev/execute.go index d00bcb83..43ce5e58 100644 --- a/exts/jev/execute.go +++ b/exts/jev/execute.go @@ -157,9 +157,9 @@ func (e *Extension) beforeModel(ctx context.Context, ev hooks.ContextEvent) ([]* projection, _ := observeInput(raw, caps) for _, entry := range projection["history"].([]map[string]any) { args, _ := json.Marshal(entry["arguments"]) - call, _ := prepareBinding(NativeCall{Name: fmt.Sprint(entry["name"]), Arguments: args}) - access, err := native.Access(coretool.NativeCall(call)) - if err != nil || access == EffectAccess { + call, _ := prepareBinding(coretool.NativeCall{Name: fmt.Sprint(entry["name"]), Arguments: args}) + access, err := native.Access(call) + if err != nil || access == coretool.NativeEffect { e.emit(ctx, &Boundary{Reason: "ordinary_effects_unmapped"}) e.enqueue(cfg, ev) return nil, nil @@ -205,7 +205,7 @@ func (e *Extension) beforeModel(ctx context.Context, ev hooks.ContextEvent) ([]* } return response.Values["runtime"], nil } - execute := func(candidate NativeCall) (map[string]any, error) { + execute := func(candidate coretool.NativeCall) (map[string]any, error) { if cfg.Tools == nil { return nil, handoffError{"native Executor unavailable"} } @@ -218,7 +218,7 @@ func (e *Extension) beforeModel(ctx context.Context, ev hooks.ContextEvent) ([]* if calls >= maxCandidates { return nil, handoffError{"native call budget reached"} } - call := candidate.call() + call := nativeToolCall(candidate) if err := validateParameters(&scene.Program, record.Arguments); err != nil { return nil, argumentError(err) } diff --git a/exts/jev/limits_test.go b/exts/jev/limits_test.go index e09cf2d8..7d219575 100644 --- a/exts/jev/limits_test.go +++ b/exts/jev/limits_test.go @@ -99,14 +99,14 @@ func TestReflexNativeAndJudgmentLimits(t *testing.T) { return nil, nil }}) contract := laboratorySuite().Contracts["lab"] - s := VerificationSuite{ID: "bounded-" + mode, Version: "1", Contracts: map[string]NativeContract{"lab": contract}, + s := VerificationSuite{ID: "bounded-" + mode, Version: "1", Contracts: coretool.NativeContracts{"lab": contract}, CheckInput: func(map[string]any, map[string]any) error { return nil }, CheckCall: func(VerificationCall) error { return nil }, CheckReport: func(VerificationReport) error { return errors.New("pending cannot report") }, Cases: func(map[string]any) []VerificationCase { return []VerificationCase{{ID: "bounded", Input: map[string]any{}, Judge: func(jevapi.Claim) (*decision.Evaluation, error) { return &decision.Evaluation{Value: &decision.Evaluation_Choice{Choice: "continue"}}, nil }, - Execute: func(NativeCall) (map[string]any, error) { + Execute: func(coretool.NativeCall) (map[string]any, error) { return map[string]any{"data": map[string]any{"complete": false}}, nil }, Check: func(run VerificationRun) error { diff --git a/exts/jev/mechanism_test.go b/exts/jev/mechanism_test.go index 98082cbe..6f74a268 100644 --- a/exts/jev/mechanism_test.go +++ b/exts/jev/mechanism_test.go @@ -38,27 +38,27 @@ func laboratoryReflex() Program { return Program{When: "add requested items", Decide: "use actual results", Observe: laboratorySource, Steps: map[string]StepDefinition{"add": {Contract: "lab", CountArgument: "count"}}, Parameters: json.RawMessage(`{"type":"object","required":["actor","query","count"],"properties":{"actor":{"type":"string"},"query":{"type":"boolean"},"count":{"type":"integer","minimum":1,"maximum":2}},"additionalProperties":false}`), arguments: map[string]any{"actor": "initial", "query": true, "count": 2}} } func laboratorySuite() VerificationSuite { - contract := NativeContract{ID: "lab", Version: "1", Classify: func(c NativeCall) (coretool.NativeAccess, error) { + contract := coretool.NativeContract{ID: "lab", Version: "1", Classify: func(c coretool.NativeCall) (coretool.NativeAccess, error) { if c.Name != "bash" || len(c.Argv) != 3 || c.Argv[0] != "lab" { - return UnsupportedAccess, nil + return coretool.NativeUnsupported, nil } if c.Argv[1] == "add" { - return EffectAccess, nil + return coretool.NativeEffect, nil } if c.Argv[1] == "status" { - return ReadAccess, nil + return coretool.NativeRead, nil } - return UnsupportedAccess, nil - }, Outcome: func(_ NativeCall, r map[string]any) string { + return coretool.NativeUnsupported, nil + }, Outcome: func(_ coretool.NativeCall, r map[string]any) string { if data, ok := r["data"].(map[string]any); ok && fmt.Sprint(data["status"]) == "200" { return "applied" } return "unknown" - }, Resolve: func(effect, read NativeCall, r map[string]any) bool { + }, Resolve: func(effect, read coretool.NativeCall, r map[string]any) bool { data, ok := r["data"].(map[string]any) return ok && len(effect.Argv) == 3 && len(read.Argv) == 3 && effect.Argv[2] == read.Argv[2] && read.Argv[1] == "status" && data["complete"] == true }} - return VerificationSuite{ID: "laboratory", Version: "1", Contracts: map[string]NativeContract{"lab": contract}, CheckInput: func(_ map[string]any, args map[string]any) error { + return VerificationSuite{ID: "laboratory", Version: "1", Contracts: coretool.NativeContracts{"lab": contract}, CheckInput: func(_ map[string]any, args map[string]any) error { if args == nil { return errors.New("missing current input") } @@ -96,7 +96,7 @@ func laboratorySuite() VerificationSuite { query := condition != "no_query" args := map[string]any{"actor": actor, "query": query, "count": 2} c := VerificationCase{ID: fmt.Sprintf("%s/%d", condition, seed), Input: map[string]any{"user": "add two current items"}, Arguments: args} - c.Execute = func(call NativeCall) (map[string]any, error) { + c.Execute = func(call coretool.NativeCall) (map[string]any, error) { if len(call.Argv) != 3 || call.Argv[2] != actor { return nil, errors.New("wrong current actor or encoding") } @@ -196,7 +196,7 @@ func TestReflexIndependentQualification(t *testing.T) { } } func TestReflexEffectIdentityConflictAndConcurrency(t *testing.T) { - call := NativeCall{Name: "native", Arguments: json.RawMessage(`{"x":1}`), Step: "add"} + call := coretool.NativeCall{Name: "native", Arguments: json.RawMessage(`{"x":1}`), Step: "add"} ledger := newEffectLedger() if _, cached, err := ledger.reserve("task", call); err != nil || cached { t.Fatal(err) diff --git a/exts/jev/native_mechanism_test.go b/exts/jev/native_mechanism_test.go index 9c1e1527..b94568b0 100644 --- a/exts/jev/native_mechanism_test.go +++ b/exts/jev/native_mechanism_test.go @@ -21,21 +21,21 @@ import ( ) func TestReflexReplayLiteralShellEncoding(t *testing.T) { - call := func(command any, timeout int) NativeCall { - return NativeCall{Name: "bash", Arguments: json.RawMessage(jsonText(map[string]any{"command": command, "timeout": timeout}))} + call := func(command any, timeout int) coretool.NativeCall { + return coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(jsonText(map[string]any{"command": command, "timeout": timeout}))} } recorded := call(`experiment append "actor with spaces"`, 30) generated := call(map[string]any{"name": "experiment", "argv": []string{"append", "actor with spaces"}}, 30) - if recorded.replayKey() != generated.replayKey() { + if replayKey(recorded) != replayKey(generated) { t.Fatal("equivalent literal argv did not replay") } - for _, other := range []NativeCall{ + for _, other := range []coretool.NativeCall{ call(`experiment append "another actor"`, 30), call(`experiment append "actor with spaces"`, 31), call(`experiment append "actor with spaces" && experiment summary "actor with spaces"`, 30), call(`experiment append "$ACTOR"`, 30), } { - if other.replayKey() == generated.replayKey() { + if replayKey(other) == replayKey(generated) { t.Fatal("changed target/options or nonliteral script matched evidence") } } diff --git a/exts/jev/observation_protocol_test.go b/exts/jev/observation_protocol_test.go index 0d7b981d..a139fe2e 100644 --- a/exts/jev/observation_protocol_test.go +++ b/exts/jev/observation_protocol_test.go @@ -39,7 +39,11 @@ func TestParameterVariationPreservesPathQuoting(t *testing.T) { r := observationReflex(t, `js:function(context,args){if(!args)return {defer:'missing',parameters:'path'};execute({name:'native',arguments:{command:'inspect '+quote(args.path)},read:true});return {report:args.path};}`) r.arguments = map[string]any{"path": path} input, _ := json.Marshal(map[string]any{"messages": []any{map[string]any{"role": "user", "text": path}}}) - if err := verifyObserve(t.Context(), &r, input, observationCapabilities("native")); err != nil { + replay, err := newObservationReplay(&r, input, observationCapabilities("native")) + if err != nil { + t.Fatal(err) + } + if err := replay.verify(t.Context()); err != nil { t.Fatalf("parameter path=%q: %v", path, err) } } diff --git a/exts/jev/observe.go b/exts/jev/observe.go index dbf97bb6..a56e9068 100644 --- a/exts/jev/observe.go +++ b/exts/jev/observe.go @@ -13,26 +13,22 @@ import ( "mvdan.cc/sh/v3/syntax" ) -// A binding is a native Executor call emitted by the generated function. -type NativeCall coretool.NativeCall - -func (b NativeCall) call() *aop.ToolCall { +// Project the tool payload; the foreground host assigns invocation identity. +func nativeToolCall(b coretool.NativeCall) *aop.ToolCall { return &aop.ToolCall{Name: b.Name, Arguments: &aop.EncodedValue{Data: b.Arguments, MediaType: aop.JSONMediaType}} } -func (b NativeCall) canonical() string { return canonical(b.call()) } - // Shell quoting is transport encoding. Compare decoded literal argv when both // calls are a single expansion-free command, retaining every other argument. // Compound scripts and opaque tools still require exact recorded arguments. -func (b NativeCall) replayKey() string { +func replayKey(b coretool.NativeCall) string { prepared, err := prepareBinding(b) if err != nil || len(prepared.Argv) == 0 { - return b.canonical() + return canonical(nativeToolCall(b)) } var args map[string]any if json.Unmarshal(prepared.Arguments, &args) != nil { - return b.canonical() + return canonical(nativeToolCall(b)) } args["command"] = prepared.Argv return jsonText([]any{b.Name, args}) @@ -86,7 +82,7 @@ func (e *Extension) capabilities(cfg agent.Config, states ...json.RawMessage) (m // Semantic authorization needs the proposed operation's protocol, not every // installed tool's manual or the host's deterministic verification catalog. // Keep current constraints and evidence intact while avoiding catalog overflow. -func bindingCapabilities(candidate NativeCall, capabilities map[string]any) map[string]any { +func bindingCapabilities(candidate coretool.NativeCall, capabilities map[string]any) map[string]any { tools, commands := []any{}, []any{} definitions, _ := capabilities["tools"].([]any) for _, value := range definitions { @@ -204,7 +200,7 @@ func observeInput(state json.RawMessage, capabilities map[string]any) (map[strin text, _ := message["text"].(string) normalized, data := normalizedResult(text) row := map[string]any{"call_id": id, "name": call["name"], "arguments": call["arguments"], "text": normalized, "data": data, "is_error": message["is_error"], "terminate": message["terminate"]} - if native, err := prepareBinding(NativeCall{Name: fmt.Sprint(call["name"]), Arguments: json.RawMessage(jsonText(call["arguments"]))}); err == nil && len(native.Argv) > 0 { + if native, err := prepareBinding(coretool.NativeCall{Name: fmt.Sprint(call["name"]), Arguments: json.RawMessage(jsonText(call["arguments"]))}); err == nil && len(native.Argv) > 0 { row["decoded_argv"] = native.Argv } history = append(history, row) diff --git a/exts/jev/observe_javascript.go b/exts/jev/observe_javascript.go index 48bbcc7c..d6132847 100644 --- a/exts/jev/observe_javascript.go +++ b/exts/jev/observe_javascript.go @@ -11,6 +11,7 @@ import ( jevapi "github.com/chainreactors/cyber/agent/provider/jev" decision "github.com/chainreactors/cyber/core/decision" + coretool "github.com/chainreactors/cyber/core/tool" "github.com/dop251/goja" "github.com/dop251/goja/ast" "github.com/dop251/goja/parser" @@ -42,7 +43,7 @@ function command(name, argv) { return {name:name, argv:argv}; } // runReflexJS executes one ordinary function. Only the native JEV and Executor // bridges can perform external work; every bridge receives and returns JSON. func runReflexJS(ctx context.Context, reflex *Program, input, arguments map[string]any, - judge func(jevapi.Claim) (*decision.Evaluation, error), execute func(NativeCall) (map[string]any, error)) (map[string]any, error) { + judge func(jevapi.Claim) (*decision.Evaluation, error), execute func(coretool.NativeCall) (map[string]any, error)) (map[string]any, error) { if reflex == nil || reflex.program == nil { return nil, fmt.Errorf("Reflex program has not been validated") } @@ -141,7 +142,7 @@ Math.random=function(){throw new Error("randomness unavailable")};globalThis.Dat return goja.Undefined() }) _ = runtime.Set("execute", func(call goja.FunctionCall) goja.Value { - var candidate NativeCall + var candidate coretool.NativeCall if err := decode(call.Argument(0), &candidate); err != nil { fail(fmt.Errorf("native arguments: %w", err)) } diff --git a/exts/jev/qualification.go b/exts/jev/qualification.go index ed66ad6e..4df6f9eb 100644 --- a/exts/jev/qualification.go +++ b/exts/jev/qualification.go @@ -19,12 +19,6 @@ type StepDefinition struct { CountArgument string `json:"count_argument,omitempty"` } -const ( - ReadAccess = coretool.NativeRead - EffectAccess = coretool.NativeEffect - UnsupportedAccess = coretool.NativeUnsupported -) - func reflexSourceHash(r Program) string { r.Proof = nil; return digest(r) } func (e *Extension) qualified(r Program) bool { p := r.Proof @@ -54,12 +48,12 @@ func (e *Extension) qualified(r Program) bool { } return true } -func (r *Program) validateCall(native coretool.NativeContracts, c NativeCall, args map[string]any) error { - access, err := native.Access(coretool.NativeCall(c)) +func (r *Program) validateCall(native coretool.NativeContracts, c coretool.NativeCall, args map[string]any) error { + access, err := native.Access(c) if err != nil { return err } - if c.Read != (access == ReadAccess) { + if c.Read != (access == coretool.NativeRead) { return errors.New("native read/effect assertion contradicts trusted contract") } if c.Read { @@ -73,8 +67,8 @@ func (r *Program) validateCall(native coretool.NativeContracts, c NativeCall, ar if !ok { return errors.New("step references unknown native contract") } - classified, err := contract.Classify(coretool.NativeCall(c)) - if err != nil || classified != EffectAccess { + classified, err := contract.Classify(c) + if err != nil || classified != coretool.NativeEffect { return errors.New("effect does not match its step contract") } count := step.Count @@ -160,9 +154,9 @@ func (e *Extension) qualify(ctx context.Context, r *Program, caps map[string]any return err } for i, result := range recorded { - call, err := prepareBinding(NativeCall{Name: fmt.Sprint(result["name"]), Arguments: json.RawMessage(jsonText(result["arguments"]))}) + call, err := prepareBinding(coretool.NativeCall{Name: fmt.Sprint(result["name"]), Arguments: json.RawMessage(jsonText(result["arguments"]))}) if err == nil { - _, err = native.Access(coretool.NativeCall(call)) + _, err = native.Access(call) } if err != nil { index := i @@ -185,8 +179,8 @@ func (e *Extension) qualify(ctx context.Context, r *Program, caps map[string]any return fmt.Errorf("boundary %d native contract: %w; evaluated call=%s", end, err, jsonText(call)) } for id, c := range native { - a, _ := c.Classify(coretool.NativeCall(call)) - if a != UnsupportedAccess { + a, _ := c.Classify(call) + if a != coretool.NativeUnsupported { proof.Contracts[id] = c.Version } } diff --git a/exts/jev/runtime_test.go b/exts/jev/runtime_test.go index d01bcc9e..63b833a2 100644 --- a/exts/jev/runtime_test.go +++ b/exts/jev/runtime_test.go @@ -8,11 +8,12 @@ import ( "time" "github.com/chainreactors/cyber/agent/provider" + coretool "github.com/chainreactors/cyber/core/tool" ) func TestRuntimeWaitDoesNotConsumeComputationBudget(t *testing.T) { r := observationReflex(t, `js:function(context,args){const r=execute({name:'native',arguments:{},read:true});return {report:r.data};}`) - result, err := runReflexJS(t.Context(), &r, observationCapabilities("native"), nil, nil, func(NativeCall) (map[string]any, error) { + result, err := runReflexJS(t.Context(), &r, observationCapabilities("native"), nil, nil, func(coretool.NativeCall) (map[string]any, error) { time.Sleep(160 * time.Millisecond) return map[string]any{"data": "actual"}, nil }) @@ -24,7 +25,7 @@ func TestRuntimeWaitDoesNotConsumeComputationBudget(t *testing.T) { func TestRuntimeCancellationCannotBeCaughtByGeneratedCode(t *testing.T) { r := observationReflex(t, `js:function(context,args){try{execute({name:'native',arguments:{},read:true});}catch(e){}return {report:'invented completion'};}`) ctx, cancel := context.WithCancel(t.Context()) - result, err := runReflexJS(ctx, &r, observationCapabilities("native"), nil, nil, func(NativeCall) (map[string]any, error) { cancel(); return nil, ctx.Err() }) + result, err := runReflexJS(ctx, &r, observationCapabilities("native"), nil, nil, func(coretool.NativeCall) (map[string]any, error) { cancel(); return nil, ctx.Err() }) if err == nil || result != nil { t.Fatal("generated catch concealed host cancellation") } @@ -46,7 +47,10 @@ func TestParameterResponseRejectsTrailingDataAndUnknownInput(t *testing.T) { func TestRuntimeHandsOffUnsafeNumericIdentityBeforeDispatch(t *testing.T) { r := observationReflex(t, `js:function(context,args){execute({name:'native',arguments:{id:args.id},read:false,step:"effect",occurrence:0});return {report:args.id};}`) calls := 0 - execute := func(NativeCall) (map[string]any, error) { calls++; return map[string]any{"data": "actual"}, nil } + execute := func(coretool.NativeCall) (map[string]any, error) { + calls++ + return map[string]any{"data": "actual"}, nil + } _, err := runReflexJS(t.Context(), &r, observationCapabilities("native"), map[string]any{"id": json.Number("9007199254740993")}, nil, execute) if err == nil || calls != 0 || !strings.Contains(interruptedCause(err).Error(), "safe range") { t.Fatalf("unsafe identity dispatched: calls=%d err=%v", calls, err) @@ -56,7 +60,7 @@ func TestRuntimeHandsOffUnsafeNumericIdentityBeforeDispatch(t *testing.T) { } r = observationReflex(t, `js:function(context,args){try{const r=execute({name:'native',arguments:{},read:true});execute({name:'native',arguments:{id:r.data.id},read:false,step:"effect",occurrence:0});}catch(e){}return {report:'done'};}`) calls = 0 - _, err = runReflexJS(t.Context(), &r, observationCapabilities("native"), nil, nil, func(NativeCall) (map[string]any, error) { + _, err = runReflexJS(t.Context(), &r, observationCapabilities("native"), nil, nil, func(coretool.NativeCall) (map[string]any, error) { calls++ return map[string]any{"data": map[string]any{"id": json.Number("9007199254740993")}}, nil }) diff --git a/exts/jev/supplement.go b/exts/jev/supplement.go index 7a91d1b4..4bb15775 100644 --- a/exts/jev/supplement.go +++ b/exts/jev/supplement.go @@ -23,7 +23,7 @@ func (e *Extension) admitSupplement(ctx context.Context, event toolhooks.CallEve return toolhooks.Admission{}, nil } native := e.contracts.Snapshot() - call := NativeCall{Name: event.Call.Name, Arguments: event.Call.GetArguments().GetData()} + call := coretool.NativeCall{Name: event.Call.Name, Arguments: event.Call.GetArguments().GetData()} call, err := prepareBinding(call) if err != nil { return toolhooks.Admission{Deny: err}, nil //nolint:nilerr // Denial is an admission result, not a hook failure. @@ -31,8 +31,8 @@ func (e *Extension) admitSupplement(ctx context.Context, event toolhooks.CallEve if record.NativeEpoch != digest(e.contracts.Catalog()) { return toolhooks.Admission{Deny: handoffError{"qualified task contract unavailable"}}, nil } - access, err := native.Access(coretool.NativeCall(call)) - if err != nil || access != ReadAccess { + access, err := native.Access(call) + if err != nil || access != coretool.NativeRead { return toolhooks.Admission{Deny: handoffError{"effect_unknown: supplementation must use trusted reads; mutations return to the Reflex"}}, nil //nolint:nilerr // The executor handles Deny. } call.Read = true @@ -55,7 +55,7 @@ func (e *Extension) observeSupplement(ctx context.Context, event toolhooks.Compl if record.NativeEpoch == "" || event.Result == nil { return struct{}{}, nil } - call := NativeCall{Name: event.Call.Name, Arguments: event.Call.GetArguments().GetData(), Read: true} + call := coretool.NativeCall{Name: event.Call.Name, Arguments: event.Call.GetArguments().GetData(), Read: true} call, err := prepareBinding(call) if err != nil { return struct{}{}, nil //nolint:nilerr // Malformed bindings have no evidence to reconcile. diff --git a/exts/jev/trace_test.go b/exts/jev/trace_test.go index e6d7bc74..7360b857 100644 --- a/exts/jev/trace_test.go +++ b/exts/jev/trace_test.go @@ -59,7 +59,7 @@ func TestProjectionKeepsFullConstraintsAndEvictsCallResultGroups(t *testing.T) { func TestRuntimeBindingJudgmentKeepsConstraintsWithoutWholeCatalog(t *testing.T) { constraint := strings.Repeat("current authorization ", 1400) request := json.RawMessage(jsonText(map[string]any{"messages": []any{map[string]any{"role": "system", "text": constraint}, map[string]any{"role": "user", "text": "Read the current native receipt"}}})) - call := NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab status current"}`), Read: true} + call := coretool.NativeCall{Name: "bash", Arguments: json.RawMessage(`{"command":"lab status current"}`), Read: true} catalog := map[string]any{ "tools": []any{map[string]any{"name": "bash", "description": "Dispatch the named native command", "input_schema": map[string]any{"type": "object"}}, map[string]any{"name": "unrelated", "description": strings.Repeat("unrelated manual ", 1600)}}, "commands": []any{map[string]any{"name": "lab", "usage": "lab status : read the actor's current receipt"}}, diff --git a/exts/jev/verify.go b/exts/jev/verify.go index a4311c67..b2354c8c 100644 --- a/exts/jev/verify.go +++ b/exts/jev/verify.go @@ -10,11 +10,12 @@ import ( jevapi "github.com/chainreactors/cyber/agent/provider/jev" "github.com/chainreactors/cyber/core/decision" + coretool "github.com/chainreactors/cyber/core/tool" ) type replayResult struct { facts json.RawMessage - candidates map[string]NativeCall + candidates map[string]coretool.NativeCall branches []replayBranch } type replayBranch struct { @@ -87,7 +88,7 @@ func probeReflexArguments(ctx context.Context, reflex *Program, input, args map[ // A pure JS loop must return a repair diagnostic without blocking the job. probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - candidates := map[string]NativeCall{} + candidates := map[string]coretool.NativeCall{} states := []any{} branches := []replayBranch{} queue := []map[string]string{{}} @@ -173,7 +174,7 @@ func probeReflexArguments(ctx context.Context, reflex *Program, input, args map[ } return nil, fmt.Errorf("unsupported Claim type") } - execute := func(candidate NativeCall) (map[string]any, error) { + execute := func(candidate coretool.NativeCall) (map[string]any, error) { dispatched++ if dispatched > maxCandidates { return nil, fmt.Errorf("native replay exceeds finite budget") @@ -192,7 +193,7 @@ func probeReflexArguments(ctx context.Context, reflex *Program, input, args map[ if cursor < len(results) { result := results[cursor] args, _ := json.Marshal(result["arguments"]) - if (NativeCall{Name: fmt.Sprint(result["name"]), Arguments: args}).replayKey() == candidate.replayKey() { + if replayKey(coretool.NativeCall{Name: fmt.Sprint(result["name"]), Arguments: args}) == replayKey(candidate) { cursor++ evidence[fmt.Sprint(result["call_id"])] = result if !candidate.Read { @@ -202,11 +203,11 @@ func probeReflexArguments(ctx context.Context, reflex *Program, input, args map[ } gap = fmt.Sprintf("native call %d differs from the recorded call", cursor) index := cursor - diagnostic = &CompilerDiagnostic{Code: "native_call_mismatch", Stage: "replay", Status: "repair", Message: gap, Call: &index, Expected: json.RawMessage((NativeCall{Name: fmt.Sprint(result["name"]), Arguments: args}).replayKey()), Actual: json.RawMessage(candidate.replayKey()), Replayed: cursor, Recorded: len(results), Action: "Compare the decoded argv and every native option. Recover exact current example values with inspect_evidence. Replay can supply only this recorded call's result; a different operation needs its own actual evidence."} + diagnostic = &CompilerDiagnostic{Code: "native_call_mismatch", Stage: "replay", Status: "repair", Message: gap, Call: &index, Expected: json.RawMessage(replayKey(coretool.NativeCall{Name: fmt.Sprint(result["name"]), Arguments: args})), Actual: json.RawMessage(replayKey(candidate)), Replayed: cursor, Recorded: len(results), Action: "Compare the decoded argv and every native option. Recover exact current example values with inspect_evidence. Replay can supply only this recorded call's result; a different operation needs its own actual evidence."} } else { gap = "generated call has no recorded result" index := cursor - diagnostic = &CompilerDiagnostic{Code: "unrecorded_native_call", Stage: "replay", Status: "repair", Message: gap, Call: &index, Actual: json.RawMessage(candidate.replayKey()), Replayed: cursor, Recorded: len(results), Action: "Remove redundant work if current evidence already satisfies the request. If this new operation is necessary, obtain its actual result in an isolated task before qualifying this path; never fabricate the result."} + diagnostic = &CompilerDiagnostic{Code: "unrecorded_native_call", Stage: "replay", Status: "repair", Message: gap, Call: &index, Actual: json.RawMessage(replayKey(candidate)), Replayed: cursor, Recorded: len(results), Action: "Remove redundant work if current evidence already satisfies the request. If this new operation is necessary, obtain its actual result in an isolated task before qualifying this path; never fabricate the result."} } return nil, errProbeStop } @@ -273,20 +274,6 @@ func (r *observationReplay) evaluate(ctx context.Context, input json.RawMessage) } return result, err } -func observationWitnesses(ctx context.Context, reflex *Program, state json.RawMessage, capabilities map[string]any) ([]map[string]any, error) { - r, err := newObservationReplay(reflex, state, capabilities) - if err != nil { - return nil, err - } - return r.witnesses(ctx) -} -func verifyObserve(ctx context.Context, reflex *Program, state json.RawMessage, capabilities map[string]any) error { - r, err := newObservationReplay(reflex, state, capabilities) - if err != nil { - return err - } - return r.verify(ctx) -} func (r *observationReplay) witnesses(ctx context.Context) ([]map[string]any, error) { rows := []map[string]any{} for _, end := range r.boundaries(false) { @@ -308,16 +295,16 @@ func (r *observationReplay) witnesses(ctx context.Context) ([]map[string]any, er } return rows, nil } -func compactWitnesses(witnesses []map[string]any) ([]map[string]any, map[string]NativeCall) { +func compactWitnesses(witnesses []map[string]any) ([]map[string]any, map[string]coretool.NativeCall) { rows := []map[string]any{} - bindings := map[string]NativeCall{} + bindings := map[string]coretool.NativeCall{} for _, witness := range witnesses { row := map[string]any{} for k, v := range witness { row[k] = v } refs := map[string]string{} - for id, c := range witness["candidates"].(map[string]NativeCall) { + for id, c := range witness["candidates"].(map[string]coretool.NativeCall) { ref := "b" + digest(c) bindings[ref] = c refs[id] = ref diff --git a/exts/jev/verify_test.go b/exts/jev/verify_test.go index a8f61aea..de9eb96f 100644 --- a/exts/jev/verify_test.go +++ b/exts/jev/verify_test.go @@ -7,6 +7,8 @@ import ( "reflect" "strings" "testing" + + coretool "github.com/chainreactors/cyber/core/tool" ) func TestObservationReplayPreservesSamplingAndInputIdentity(t *testing.T) { @@ -61,7 +63,11 @@ return {state:{handle:handle || null},candidates:choices(handle ? [bind(tools[0] t.Fatal(err) } input := json.RawMessage(`{"messages":[{"role":"user","text":"Inspect a resource"},{"role":"assistant","calls":[{"id":"open","name":"native","arguments":{}}]},{"role":"tool","call_id":"open","text":"{\"handle\":\"current\"}"}]}`) - if err := verifyObserve(t.Context(), &r, input, map[string]any{"tools": []any{map[string]any{"name": "native"}}, "commands": []any{}}); err != nil { + replay, err := newObservationReplay(&r, input, map[string]any{"tools": []any{map[string]any{"name": "native"}}, "commands": []any{}}) + if err != nil { + t.Fatal(err) + } + if err := replay.verify(t.Context()); err != nil { t.Fatalf("useful partial scene cannot reach semantic review: %v", err) } } @@ -84,7 +90,11 @@ func TestFreshnessReviewRetainsConcreteRejectionWithEitherGlobalVerdict(t *testi r := observationReflex(t, `js:function(context,args){const r=execute({name:'native',arguments:{},read:false,step:"effect",occurrence:0});return {report:r.data};}`) caps := observationCapabilities("native") state := json.RawMessage(`{"messages":[{"role":"user","text":"Inspect fresh state"}]}`) - witnesses, err := observationWitnesses(t.Context(), &r, state, caps) + replay, err := newObservationReplay(&r, state, caps) + if err != nil { + t.Fatal(err) + } + witnesses, err := replay.witnesses(t.Context()) if err != nil { t.Fatal(err) } @@ -110,7 +120,7 @@ func TestReviewRetainsBoundaryRejectionWithGlobalDefer(t *testing.T) { }) e, _, _ := testInstallation(t, Config{Mode: "auto"}, client) r := observationReflex(t, `js:function(){return{defer:'missing next operation'};}`) - boundary := map[string]any{"boundary": 2, "state": map[string]any{"current": "actual"}, "candidates": map[string]NativeCall{}, "next_calls": []any{map[string]any{"name": "native", "arguments": map[string]any{"operation": "required"}}}} + boundary := map[string]any{"boundary": 2, "state": map[string]any{"current": "actual"}, "candidates": map[string]coretool.NativeCall{}, "next_calls": []any{map[string]any{"name": "native", "arguments": map[string]any{"operation": "required"}}}} err := e.reviewReflex(t.Context(), &r, &compilation{state: json.RawMessage(`{"messages":[]}`)}, []map[string]any{boundary}) if err == nil { t.Fatal("rejected boundary accepted") @@ -132,7 +142,7 @@ func TestResultReviewDistinguishesCompletionFromReadClassification(t *testing.T) }) e, _, _ := testInstallation(t, Config{Mode: "auto"}, client) r := observationReflex(t, `js:function(){const r=execute({name:'native',arguments:{},read:true});return{report:'done'};}`) - witness := map[string]any{"boundary": 1, "report": "done", "candidates": map[string]NativeCall{"read": {Name: "native", Arguments: json.RawMessage(`{}`), Read: true}}} + witness := map[string]any{"boundary": 1, "report": "done", "candidates": map[string]coretool.NativeCall{"read": {Name: "native", Arguments: json.RawMessage(`{}`), Read: true}}} err := e.reviewReflex(t.Context(), &r, &compilation{capabilities: observationCapabilities("native"), state: json.RawMessage(`{"messages":[]}`)}, []map[string]any{witness}) diagnostic := compilerDiagnostic(err) if requests != 1 || diagnostic.Code != "semantic_validation_failed" || diagnostic.Stage != "completion" || !strings.Contains(jsonText(diagnostic.Actual), "done") { @@ -142,10 +152,10 @@ func TestResultReviewDistinguishesCompletionFromReadClassification(t *testing.T) func TestReviewSharesBindingsWithoutLosingActualBoundaryEvidence(t *testing.T) { args, _ := json.Marshal(map[string]any{"program": strings.Repeat("native program ", 1000), "version": json.Number("9007199254740993")}) - candidate := NativeCall{Name: "ordinary", Arguments: args, Read: true} + candidate := coretool.NativeCall{Name: "ordinary", Arguments: args, Read: true} var witnesses []map[string]any for i := 0; i < 8; i++ { - witnesses = append(witnesses, map[string]any{"boundary": i, "latest": fmt.Sprintf("actual result %d", i), "candidates": map[string]NativeCall{"current": candidate}, "next_calls": []string{fmt.Sprintf("next %d", i)}}) + witnesses = append(witnesses, map[string]any{"boundary": i, "latest": fmt.Sprintf("actual result %d", i), "candidates": map[string]coretool.NativeCall{"current": candidate}, "next_calls": []string{fmt.Sprintf("next %d", i)}}) } raw, _ := json.Marshal(witnesses) rows, bindings := compactWitnesses(witnesses) @@ -158,7 +168,7 @@ func TestReviewSharesBindingsWithoutLosingActualBoundaryEvidence(t *testing.T) { if canonicalBinding := bindings[ref]; canonicalBinding.Name != candidate.Name || string(canonicalBinding.Arguments) != string(args) || !canonicalBinding.Read || row["latest"] != witnesses[i]["latest"] || row["next_calls"] == nil { t.Fatal("compaction changed an actual binding or its boundary evidence") } - if _, ok := witnesses[i]["candidates"].(map[string]NativeCall); !ok { + if _, ok := witnesses[i]["candidates"].(map[string]coretool.NativeCall); !ok { t.Fatal("compaction mutated original evidence") } } @@ -189,10 +199,14 @@ func TestReviewSharesActualResultsAcrossReportBoundaries(t *testing.T) { messages = append(messages, map[string]any{"role": "assistant", "calls": []any{map[string]any{"id": id, "name": "native", "arguments": map[string]any{"index": i}}}}, map[string]any{"role": "tool", "call_id": id, "data": map[string]any{"payload": data}}) } state := json.RawMessage(jsonText(map[string]any{"messages": messages})) - if err := verifyObserve(t.Context(), &r, state, caps); err != nil { + replay, err := newObservationReplay(&r, state, caps) + if err != nil { t.Fatal(err) } - witnesses, err := observationWitnesses(t.Context(), &r, state, caps) + if err := replay.verify(t.Context()); err != nil { + t.Fatal(err) + } + witnesses, err := replay.witnesses(t.Context()) if err != nil { t.Fatal(err) } @@ -208,7 +222,11 @@ func TestReviewSharesActualResultsAcrossReportBoundaries(t *testing.T) { if err := r.validate(); err != nil { t.Fatal(err) } - if _, err := observationWitnesses(t.Context(), &r, state, caps); err == nil { + replay, err = newObservationReplay(&r, state, caps) + if err != nil { + t.Fatal(err) + } + if _, err := replay.witnesses(t.Context()); err == nil { t.Fatal("retaining references bypassed report provenance validation") } } @@ -222,7 +240,11 @@ func TestCompilerChecksEarlierBoundariesAndNativeRequiredArguments(t *testing.T) `js:function(context,args){execute({name:'native',arguments:{selector:context.user.split('Select ')[2]},read:false,step:"effect",occurrence:0});return {report:'done'};}`, } { r := observationReflex(t, code) - if err := verifyObserve(t.Context(), &r, input, caps); err == nil { + replay, err := newObservationReplay(&r, input, caps) + if err != nil { + t.Fatal(err) + } + if err := replay.verify(t.Context()); err == nil { t.Fatal("invalid earlier boundary or native arguments were admitted") } }