Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,11 @@ jobs:
-run '^Test(Parse|Execute_|ResolvePath_|NameAndUsage|WithDefaultSession|Format)' \
./tools/playwright

- name: Run JEV compilation and profile regressions
run: |
go test -race -tags "$FULL_CAPS_TAGS" -count=1 -timeout 5m \
-run '^Test(ReflexV2|JEVProfile)' ./exts/jev ./cmd/aiscan

- name: Compile browser-backed full-tag suites
run: |
go test -c -tags "$FULL_CAPS_TAGS" \
Expand Down Expand Up @@ -436,12 +441,16 @@ jobs:
run: |
# Untracked output is drift too: a new proto yields a new binding file,
# which `git diff` never reports.
test -z "$(git status --porcelain -- pkg/rpc core/types exts/guardrail/guardrail.pb.go web/frontend/src/gen)"
test -z "$(git status --porcelain -- pkg/rpc core/types exts/guardrail/guardrail.pb.go exts/jev/jev.pb.go web/frontend/src/gen)"
# The AOP bindings are generated into the cyber-ui submodule, where the
# superproject tracks only the gitlink, so a regeneration inside it is
# invisible from here and the check has to run in the submodule.
test -z "$(git -C web/frontend/cyber-ui status --porcelain -- packages/aop/src/gen/aop)"

- name: Install cyber-ui workspace dependencies
working-directory: web/frontend/cyber-ui
run: corepack pnpm install --frozen-lockfile

- name: Build embedded frontend
run: |
npm --prefix web/frontend run build
Expand Down Expand Up @@ -470,9 +479,7 @@ jobs:

- name: Run cyber-ui viewer tests
working-directory: web/frontend/cyber-ui
run: |
corepack pnpm install --frozen-lockfile
corepack pnpm --filter @cyber/viewer test
run: corepack pnpm --filter @cyber/viewer test

- name: Run backend E2E tests
run: |
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,15 @@ jobs:
cache: npm
cache-dependency-path: web/frontend/package-lock.json

- name: Install frontend dependencies
run: npm --prefix web/frontend ci

- name: Install cyber-ui workspace dependencies
working-directory: web/frontend/cyber-ui
run: corepack pnpm install --frozen-lockfile

- name: Build embedded frontend
run: |
npm --prefix web/frontend ci
npm --prefix web/frontend run build
test -s web/static/index.html
test -n "$(find web/static/assets -type f -size +0c -print -quit)"
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,10 @@ web/static-stale/
cyber.yaml
aiscan.yaml
.runlogs/harness/

# Local JEV experiments and browser replay output
/output/
/exts/jev/output/
/docs/evidence/jev-reflex-20261005/
/.runlogs/
/exts/jev/.runlogs/
25 changes: 25 additions & 0 deletions agent/hooks/points.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ var BeforeModel = corehooks.NewPoint[ContextEvent, []*Msg]("before_model").WithR
// and cannot replace the output or dispatch its tool calls.
var AfterModel = corehooks.NewPoint[ContextEvent, struct{}]("after_model")

// ModelRequestPolicy is applied to every request, including retries and streams.
// Restrictions combine monotonically: a handler cannot restore denied tools.
type ModelRequestEvent struct {
ContextEvent
Purpose string
}
type ModelPolicy struct {
Purpose string
DisableTools bool
Deny error
}

var ModelRequestPolicy = corehooks.NewPoint[ModelRequestEvent, ModelPolicy]("model_request_policy").WithReducer(
corehooks.Fold(func(acc *ModelPolicy, ev *ModelRequestEvent, out ModelPolicy) {
acc.DisableTools = acc.DisableTools || out.DisableTools
if out.Deny != nil {
acc.Deny = out.Deny
}
if out.Purpose != "" {
acc.Purpose = out.Purpose
ev.Purpose = out.Purpose
}
}),
)

// ContextResult replaces the whole message list; nil means unchanged.
type ContextResult struct {
Messages []*Msg
Expand Down
8 changes: 8 additions & 0 deletions agent/hooks_emit.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import (
"google.golang.org/protobuf/proto"
)

func cloneModelMessages(messages []*aop.Message) []*aop.Message {
snapshot := make([]*aop.Message, len(messages))
for i, m := range messages {
snapshot[i] = proto.CloneOf(m)
}
return snapshot
}

func afterModelHook(ctx context.Context, cfg Config, messages []*aop.Message, turn int) {
if !hooks.AfterModel.Has(cfg.Hooks) {
return
Expand Down
66 changes: 66 additions & 0 deletions agent/model_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package agent

import (
"context"
"errors"
"strings"
"testing"

"github.com/chainreactors/cyber/agent/hooks"
"github.com/chainreactors/cyber/agent/provider"
aop "github.com/chainreactors/cyber/aop"
corehooks "github.com/chainreactors/cyber/core/hooks"
)

func TestModelRequestPolicyCannotDispatchCompositionTools(t *testing.T) {
for _, stream := range []bool{false, true} {
t.Run(map[bool]string{false: "response", true: "stream"}[stream], func(t *testing.T) {
registry := corehooks.New()
calls := 0
hooks.ModelRequestPolicy.On(registry, "composition", func(_ context.Context, ev hooks.ModelRequestEvent) (hooks.ModelPolicy, error) {
calls++
ev.Messages[0] = provider.TextMessage("user", "modified")
return hooks.ModelPolicy{Purpose: "composition", DisableTools: true}, nil
})
hooks.ModelRequestPolicy.On(registry, "later", func(context.Context, hooks.ModelRequestEvent) (hooks.ModelPolicy, error) {
return hooks.ModelPolicy{DisableTools: false}, nil
})
echo := &recordingTool{name: "echo", output: "must not execute"}
toolMessage := &aop.Message{Role: "assistant", Content: []*aop.Content{{Value: &aop.Content_ToolCall{ToolCall: &aop.ToolCall{Id: "one", Name: "echo", Arguments: &aop.EncodedValue{Data: []byte(`{"value":"x"}`)}}}}}}
llm := &scriptedProvider{responses: []*ChatCompletionResponse{{Choices: []provider.Choice{{Message: toolMessage}}}}, streamEventBatches: [][]ChatCompletionStreamEvent{{roleDelta("assistant"), toolCallDelta(0, "one", "echo", `{"value":"x"}`), {Done: true}}}}
result, err := NewAgent(Config{Loop: StandardLoop{}, Provider: llm, Tools: newTestTools(t, echo), Hooks: registry, Stream: stream, MaxRetries: -1}).Run(t.Context(), TextInput("Compose only"))
if err == nil || !strings.Contains(err.Error(), "forbids tool calls") || calls != 1 || len(echo.callsSnapshot()) != 0 {
t.Fatalf("result=%v err=%v policy=%d effects=%d", result, err, calls, len(echo.callsSnapshot()))
}
})
}
}
func TestModelRequestPolicyDenialAndRetries(t *testing.T) {
registry := corehooks.New()
policies, requests := 0, 0
hooks.ModelRequestPolicy.On(registry, "policy", func(context.Context, hooks.ModelRequestEvent) (hooks.ModelPolicy, error) {
policies++
return hooks.ModelPolicy{Purpose: "composition", DisableTools: true}, nil
})
llm := &callbackProvider{fn: func(_ context.Context, req *ChatCompletionRequest) (*ChatCompletionResponse, error) {
requests++
if req.Purpose != "composition" || len(req.Tools) != 0 {
t.Fatal("restriction was lost")
}
if requests == 1 {
return nil, errors.New("connection reset")
}
return &ChatCompletionResponse{Choices: []provider.Choice{{Message: provider.TextMessage("assistant", "answer")}}, Usage: &aop.TokenUsage{}}, nil
}}
result, err := NewAgent(Config{Loop: StandardLoop{}, Provider: llm, Hooks: registry, MaxRetries: 1}).Run(t.Context(), TextInput("Compose"))
if err != nil || result.Output != "answer" || policies != 2 || requests != 2 {
t.Fatalf("result=%v err=%v requests=%d policies=%d", result, err, requests, policies)
}
hooks.ModelRequestPolicy.On(registry, "deny", func(context.Context, hooks.ModelRequestEvent) (hooks.ModelPolicy, error) {
return hooks.ModelPolicy{Deny: errors.New("execution not permitted")}, nil
})
_, err = NewAgent(Config{Provider: llm, Hooks: registry}).Run(t.Context(), TextInput("Denied"))
if err == nil || requests != 2 {
t.Fatal("denied policy still contacted model")
}
}
3 changes: 3 additions & 0 deletions agent/provider/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ func marshalOpenAIRequest(req *ChatCompletionRequest) ([]byte, error) {
if req.MaxTokens > 0 {
body["max_tokens"] = req.MaxTokens
}
if req.JSONOutput {
body["response_format"] = map[string]string{"type": "json_object"}
}
if req.Temperature != nil {
body["temperature"] = *req.Temperature
}
Expand Down
19 changes: 19 additions & 0 deletions agent/provider/openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,22 @@ func TestMarshalOpenAIRequestAlwaysIncludesMessageContent(t *testing.T) {
}
}
}

func TestStructuredOutputIsOptIn(t *testing.T) {
for _, enabled := range []bool{false, true} {
data, err := marshalOpenAIRequest(&ChatCompletionRequest{Model: "deepseek-flash", JSONOutput: enabled})
if err != nil {
t.Fatal(err)
}
var body map[string]json.RawMessage
if err := json.Unmarshal(data, &body); err != nil {
t.Fatal(err)
}
if _, present := body["response_format"]; present != enabled {
t.Fatalf("unexpected format control: %s", data)
}
if enabled && string(body["response_format"]) != `{"type":"json_object"}` {
t.Fatalf("unsupported vendor format: %s", data)
}
}
}
2 changes: 2 additions & 0 deletions agent/provider/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
// back into aop types; nothing upstream of this package sees vendor JSON.

type ChatCompletionRequest struct {
Purpose string // Host-only request purpose; never serialized to the provider.
Model string
Messages []*aop.Message
Tools []*aop.ToolDefinition
Expand All @@ -31,6 +32,7 @@ type ChatCompletionRequest struct {
CacheRetention CacheRetention
SessionID string
ReasoningEffort string // Optional inference hint; empty uses the provider default.
JSONOutput bool // Request a JSON object from adapters that support structured output.
}

type ChatCompletionResponse struct {
Expand Down
29 changes: 27 additions & 2 deletions agent/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"time"

"github.com/chainreactors/cyber/agent/hooks"
"github.com/chainreactors/cyber/agent/inbox"
"github.com/chainreactors/cyber/agent/provider"
aop "github.com/chainreactors/cyber/aop"
Expand Down Expand Up @@ -277,7 +278,18 @@ func requestWithRetry(ctx context.Context, cfg Config, em *aopEmitter, messages
}

func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEmitter, messages []*aop.Message, tools []*aop.ToolDefinition, turn int, messageID string) (*assistantTurn, *aop.TokenUsage, bool, error) {
policy, policyErr := hooks.ModelRequestPolicy.Emit(ctx, cfg.Hooks, hooks.ModelRequestEvent{ContextEvent: hooks.ContextEvent{SessionID: cfg.SessionID, TurnID: cfg.TurnID, Turn: turn, Messages: cloneModelMessages(messages)}, Purpose: "execution"})
if policyErr != nil {
return nil, nil, false, fmt.Errorf("model request policy: %w", policyErr)
}
if policy.Deny != nil {
return nil, nil, false, policy.Deny
}
if policy.DisableTools {
tools = nil
}
req := &ChatCompletionRequest{
Purpose: policy.Purpose,
Model: cfg.Model,
Messages: messages,
Tools: tools,
Expand All @@ -286,6 +298,9 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm
CacheRetention: cfg.CacheRetention,
SessionID: cfg.SessionID,
}
if req.Purpose == "" {
req.Purpose = "execution"
}
estimatedInputTokens := estimateRequestTokens(messages, tools)
maxTokens, err := clampMaxTokens(cfg.MaxTokens, cfg.ContextWindow, estimatedInputTokens)
if err != nil {
Expand All @@ -297,7 +312,11 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm
})
if cfg.Stream {
if streaming, ok := cfg.Provider.(StreamingProvider); ok {
return streamAssistantMessageWithUsage(ctx, streaming, req, em, cfg.Logger, turn, messageID)
assistant, usage, streamed, err := streamAssistantMessageWithUsage(ctx, streaming, req, em, cfg.Logger, turn, messageID)
if err == nil && policy.DisableTools && len(provider.MessageToolCalls(assistant.message)) > 0 {
return nil, usage, streamed, fmt.Errorf("model request policy forbids tool calls for %s", req.Purpose)
}
return assistant, usage, streamed, err
}
}

Expand All @@ -315,7 +334,13 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm
choice := resp.Choices[0]
msg := choice.Message
if msg == nil {
msg = &aop.Message{Role: "assistant"}
return nil, usage, false, fmt.Errorf("LLM protocol error at turn %d: choice 0 has no message", turn)
}
if policy.DisableTools && len(provider.MessageToolCalls(msg)) > 0 {
return nil, usage, false, fmt.Errorf("model request policy forbids tool calls for %s", req.Purpose)
}
if provider.MessageText(msg) == "" && len(provider.MessageToolCalls(msg)) == 0 {
return nil, usage, false, fmt.Errorf("LLM protocol error at turn %d: response message has no final text or tool call", turn)
}
msg.Id = messageID
if len(msg.Content) > 0 {
Expand Down
6 changes: 6 additions & 0 deletions audit/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260930195847-0f92c903ca4a h1:wmUVhn2YyddEiM7bPs19Q+hSodR0agnueII6GyFvdQ4=
github.com/dop251/goja v0.0.0-20260930195847-0f92c903ca4a/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
Expand Down Expand Up @@ -259,6 +261,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
Expand Down Expand Up @@ -351,6 +355,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand Down
Loading
Loading