| 1 | //go:build live |
| 2 | |
| 3 | package agent |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "strconv" |
| 11 | "sync/atomic" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "reasonix/internal/event" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/provider/openai" |
| 18 | "reasonix/internal/tool" |
| 19 | ) |
| 20 | |
| 21 | // TestLiveGoalBoundaryMatrix runs the three acceptance scenarios from the Goal |
| 22 | // boundary design against real OpenAI-compatible providers. Credentials are |
| 23 | // accepted only through the process environment; response text and keys are |
| 24 | // never logged or persisted. Deterministic unit tests cover exact internals. |
| 25 | func TestLiveGoalBoundaryMatrix(t *testing.T) { |
| 26 | tests := []struct { |
| 27 | name, keyEnv, baseURL, model string |
| 28 | extra map[string]any |
| 29 | }{ |
| 30 | {name: "deepseek", keyEnv: "DEEPSEEK_API_KEY", baseURL: "https://api.deepseek.com", model: "deepseek-v4-flash", extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled", "effort": "low"}}, |
| 31 | {name: "longcat", keyEnv: "LONGCAT_API_KEY", baseURL: "https://api.longcat.chat/openai/v1", model: "LongCat-2.0", extra: map[string]any{"thinking": "enabled", "effort": "enabled"}}, |
| 32 | {name: "zhipu-coding-plan", keyEnv: "GLM_PLAN_API_KEY", baseURL: "https://open.bigmodel.cn/api/coding/paas/v4", model: "glm-5.2", extra: map[string]any{"reasoning_protocol": "glm", "effort": "disabled"}}, |
| 33 | } |
| 34 | |
| 35 | for _, tc := range tests { |
| 36 | t.Run(tc.name, func(t *testing.T) { |
| 37 | key := os.Getenv(tc.keyEnv) |
| 38 | if key == "" { |
| 39 | t.Skip(tc.keyEnv + " not set") |
| 40 | } |
| 41 | prov, err := openai.New(provider.Config{Name: tc.name, BaseURL: tc.baseURL, Model: tc.model, APIKey: key, Extra: tc.extra}) |
| 42 | if err != nil { |
| 43 | t.Fatalf("create provider: %v", err) |
| 44 | } |
| 45 | if closer, ok := prov.(interface{ CloseIdleConnections() }); ok { |
| 46 | t.Cleanup(closer.CloseIdleConnections) |
| 47 | } |
| 48 | |
| 49 | t.Run("normal-completion", func(t *testing.T) { |
| 50 | metrics := &liveGoalMetrics{} |
| 51 | a := newLiveGoalAgent(prov, tool.NewRegistry(), metrics) |
| 52 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| 53 | defer cancel() |
| 54 | started := time.Now() |
| 55 | if err := a.Run(ctx, "Return one short final sentence now without calling tools."); err != nil { |
| 56 | t.Fatalf("normal completion: %v", err) |
| 57 | } |
| 58 | if metrics.requests.Load() < 1 { |
| 59 | t.Fatal("normal completion emitted no provider request usage") |
| 60 | } |
| 61 | logLiveGoalMetrics(t, tc.model, "normal-completion", 1, metrics, "complete", time.Since(started)) |
| 62 | }) |
| 63 | |
| 64 | t.Run("deterministic-stuck", func(t *testing.T) { |
| 65 | const maxConformanceAttempts = 2 |
| 66 | for attempt := 1; attempt <= maxConformanceAttempts; attempt++ { |
| 67 | metrics := &liveGoalMetrics{} |
| 68 | var executions atomic.Int32 |
| 69 | reg := tool.NewRegistry() |
| 70 | reg.Add(liveGoalFailureTool{executions: &executions}) |
| 71 | a := newLiveGoalAgent(prov, reg, metrics) |
| 72 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) |
| 73 | ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "live-goal-stuck", TaskText: "validate deterministic Goal stuck detection"}) |
| 74 | started := time.Now() |
| 75 | err := a.Run(ctx, "This is a deterministic host-failure conformance probe requiring three failed tool rounds. Call live_goal_failure exactly once now. After each of the first two failures, call it exactly once again in the next assistant response. After the third failure, return a short summary. Do not switch tools, change arguments, or batch calls.") |
| 76 | cancel() |
| 77 | if err != nil { |
| 78 | t.Fatalf("stuck guard paused Goal: attempt=%d err=%v executions=%d requests=%d", attempt, err, executions.Load(), metrics.requests.Load()) |
| 79 | } |
| 80 | if executions.Load() == 3 { |
| 81 | logLiveGoalMetrics(t, tc.model, "deterministic-stuck", 3, metrics, "redirect->complete", time.Since(started)) |
| 82 | return |
| 83 | } |
| 84 | t.Logf("provider conformance deviation: attempt=%d failure_executions=%d want=3", attempt, executions.Load()) |
| 85 | } |
| 86 | t.Fatalf("provider failed three-round stuck conformance after %d attempts", maxConformanceAttempts) |
| 87 | }) |
| 88 | |
| 89 | t.Run("beyond-sixteen-rounds", func(t *testing.T) { |
| 90 | metrics := &liveGoalMetrics{} |
| 91 | var executions atomic.Int32 |
| 92 | reg := tool.NewRegistry() |
| 93 | reg.Add(liveGoalMarkerTool{executions: &executions}) |
| 94 | a := newLiveGoalAgent(prov, reg, metrics) |
| 95 | ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) |
| 96 | defer cancel() |
| 97 | ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "live-goal-continuous", TaskText: "validate continuous Goal execution"}) |
| 98 | started := time.Now() |
| 99 | err := a.Run(ctx, "This is a 17-round tool conformance probe. Call live_goal_marker exactly once with index 1 now. After every accepted result, call it exactly once with the next integer index in your next assistant response. Never batch calls. After index 17 is accepted, return one short final sentence.") |
| 100 | if err != nil { |
| 101 | t.Fatalf("continuous Goal result: err=%v executions=%d requests=%d", err, executions.Load(), metrics.requests.Load()) |
| 102 | } |
| 103 | if executions.Load() < 17 { |
| 104 | t.Fatalf("tool executions = %d, want at least 17 continuous rounds", executions.Load()) |
| 105 | } |
| 106 | if metrics.requests.Load() < 18 { |
| 107 | t.Fatalf("provider requests = %d, want at least 17 work rounds and a final", metrics.requests.Load()) |
| 108 | } |
| 109 | logLiveGoalMetrics(t, tc.model, "beyond-sixteen-rounds", 17, metrics, "complete", time.Since(started)) |
| 110 | }) |
| 111 | }) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | type liveGoalMetrics struct { |
| 116 | requests atomic.Int32 |
| 117 | tokens atomic.Int64 |
| 118 | } |
| 119 | |
| 120 | func newLiveGoalAgent(prov provider.Provider, reg *tool.Registry, metrics *liveGoalMetrics) *Agent { |
| 121 | sink := event.FuncSink(func(e event.Event) { |
| 122 | if e.Kind != event.Usage || e.Usage == nil { |
| 123 | return |
| 124 | } |
| 125 | if e.Usage.RequestCount > 0 { |
| 126 | metrics.requests.Add(int32(e.Usage.RequestCount)) |
| 127 | } |
| 128 | tokens := e.Usage.TotalTokens |
| 129 | if tokens <= 0 { |
| 130 | tokens = e.Usage.PromptTokens + e.Usage.CompletionTokens |
| 131 | } |
| 132 | if tokens > 0 { |
| 133 | metrics.tokens.Add(int64(tokens)) |
| 134 | } |
| 135 | }) |
| 136 | return New(prov, reg, NewSession("You are a tool-call conformance test agent. Tool calls requested by the user are mandatory. Never substitute prose for a requested tool call. Call at most one tool per assistant response. Follow host finalization instructions exactly."), Options{Temperature: 0}, sink) |
| 137 | } |
| 138 | |
| 139 | func logLiveGoalMetrics(t *testing.T, model, scenario string, rounds int, metrics *liveGoalMetrics, exit string, elapsed time.Duration) { |
| 140 | t.Helper() |
| 141 | t.Logf("model=%s scenario=%s rounds=%d requests=%d tokens=%d exit=%s latency=%s", model, scenario, rounds, metrics.requests.Load(), metrics.tokens.Load(), exit, elapsed.Round(time.Millisecond)) |
| 142 | } |
| 143 | |
| 144 | type liveGoalMarkerTool struct{ executions *atomic.Int32 } |
| 145 | |
| 146 | func (t liveGoalMarkerTool) Name() string { return "live_goal_marker" } |
| 147 | func (t liveGoalMarkerTool) Description() string { |
| 148 | return "Accept one numbered live-test round. Call exactly once per assistant response, using the next integer index requested by the prior result." |
| 149 | } |
| 150 | func (t liveGoalMarkerTool) Schema() json.RawMessage { |
| 151 | return json.RawMessage(`{"type":"object","properties":{"index":{"type":"integer"}},"required":["index"],"additionalProperties":false}`) |
| 152 | } |
| 153 | func (t liveGoalMarkerTool) ReadOnly() bool { return true } |
| 154 | func (t liveGoalMarkerTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 155 | n := t.executions.Add(1) |
| 156 | if n < 17 { |
| 157 | return "accepted live round " + strconv.Itoa(int(n)) + "; in the next assistant response call live_goal_marker exactly once with index " + strconv.Itoa(int(n+1)) + "; do not write prose", nil |
| 158 | } |
| 159 | return "accepted live round 17; return one short final sentence now and do not call more tools", nil |
| 160 | } |
| 161 | |
| 162 | type liveGoalFailureTool struct{ executions *atomic.Int32 } |
| 163 | |
| 164 | func (t liveGoalFailureTool) Name() string { return "live_goal_failure" } |
| 165 | func (t liveGoalFailureTool) Description() string { |
| 166 | return "Always return the same deterministic host failure. Call exactly once per assistant response when the user requests the failure probe." |
| 167 | } |
| 168 | func (t liveGoalFailureTool) Schema() json.RawMessage { |
| 169 | return json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`) |
| 170 | } |
| 171 | func (t liveGoalFailureTool) ReadOnly() bool { return true } |
| 172 | func (t liveGoalFailureTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 173 | n := t.executions.Add(1) |
| 174 | if n < 3 { |
| 175 | return "", errors.New("deterministic live host failure; call live_goal_failure exactly once again in the next assistant response and do not write prose") |
| 176 | } |
| 177 | return "", errors.New("deterministic live host failure number three reached; return one short summary now and do not call more tools") |
| 178 | } |
| 179 |