mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
refactor(agent): remove missing-tool-call-reasoning detection and recovery
The loop no longer classifies a tool-call turn that arrived without reasoning, retries it, or keeps a file-backed cooldown for it. Removes the watch and the cooldown state, the deferred stream sink they needed, the provider policy interfaces and their implementations, the descriptor field on the extension wire (schema and SDK regenerated), the state-dir path, the run-metrics counters and the protocol-recovery kinds. The protocol-recovery audit channel itself stays for now.
This commit is contained in:
1 parent
a87c4d1fff
commit
be5c98c744
50 files changed
+242
-2879
No files matched your search
@@ -5,7 +5,7 @@
|
||||
- Protocol ID: `reasonix.extension.v2`
|
||||
- Protocol major: `2`
|
||||
- Schema: `internal/ext/extension/protocol/schema.generated.json`
|
||||
- Schema hash: `sha256:240d875d8539373bc8e50cb8df431ba0364e58a4502b773beb0eb6a2a8120bb3`
|
||||
- Schema hash: `sha256:a0716fcfa8aab0f67a5c2299c4e6fa1573378e44c6050633a930d6d736f35fd7`
|
||||
|
||||
Within major v2 only optional fields, new enum values, and new methods may
|
||||
be added; existing required fields, directions, limits, error reasons, and
|
||||
|
||||
@@ -537,26 +537,25 @@ func (b *builder) executor() *agent.Agent {
|
||||
WriteScheduler: t.sub.scheduler,
|
||||
WriteWorkspaceRoot: b.root, ExtraWorkspaceRoots: b.workspaceExtraRoots(), WorkspaceScanLimit: b.opts.WorkspaceScanLimit, WorkspaceVCS: b.prompt.workspaceVCS, WorkspaceUnversioned: b.prompt.workspaceVCS == "", RenderRoot: renderRoot(t.browser, entry, b.root),
|
||||
ProjectChecks: b.prompt.projectChecks, ProjectSensitivePaths: b.prompt.sensitivePaths,
|
||||
EvidenceSeal: t.env.evidenceSeal,
|
||||
AgentPreset: b.model.preset,
|
||||
DeliveryProfile: b.model.delivery,
|
||||
WaiveOwedChecks: cfg.WaivesOwedChecks(b.model.delivery) && !b.opts.HoldOwedChecks,
|
||||
Ablation: b.opts.Ablation,
|
||||
WorkspaceLease: b.session.lease,
|
||||
CapabilityLedger: t.caps.ledger,
|
||||
CapabilityAudit: t.caps.audit,
|
||||
ContextWindow: entry.ContextWindow,
|
||||
CompactRatio: cfg.Agent.CompactRatio,
|
||||
ContextEditing: cfg.Agent.ContextEditing,
|
||||
RecentKeep: cfg.Agent.RecentKeep,
|
||||
CompactionBudgets: compactionBudgets(cfg),
|
||||
ArchiveDir: b.roots.ArchiveDir(),
|
||||
KeepPolicy: b.keep,
|
||||
ReasoningLanguage: cfg.ReasoningLanguage(),
|
||||
SubagentDepth: 0,
|
||||
MaxSubagentDepth: t.sub.maxDepth,
|
||||
ProviderRetries: agent.ProviderRetriesFromConfig(cfg.Agent.MaxProviderRetries),
|
||||
MissingReasoningWarnStateDir: b.roots.MissingReasoningWarnStateDir(),
|
||||
EvidenceSeal: t.env.evidenceSeal,
|
||||
AgentPreset: b.model.preset,
|
||||
DeliveryProfile: b.model.delivery,
|
||||
WaiveOwedChecks: cfg.WaivesOwedChecks(b.model.delivery) && !b.opts.HoldOwedChecks,
|
||||
Ablation: b.opts.Ablation,
|
||||
WorkspaceLease: b.session.lease,
|
||||
CapabilityLedger: t.caps.ledger,
|
||||
CapabilityAudit: t.caps.audit,
|
||||
ContextWindow: entry.ContextWindow,
|
||||
CompactRatio: cfg.Agent.CompactRatio,
|
||||
ContextEditing: cfg.Agent.ContextEditing,
|
||||
RecentKeep: cfg.Agent.RecentKeep,
|
||||
CompactionBudgets: compactionBudgets(cfg),
|
||||
ArchiveDir: b.roots.ArchiveDir(),
|
||||
KeepPolicy: b.keep,
|
||||
ReasoningLanguage: cfg.ReasoningLanguage(),
|
||||
SubagentDepth: 0,
|
||||
MaxSubagentDepth: t.sub.maxDepth,
|
||||
ProviderRetries: agent.ProviderRetriesFromConfig(cfg.Agent.MaxProviderRetries),
|
||||
}, b.sink)
|
||||
ag.SetNativeVideo(nativeVideoSource(entry, cfg))
|
||||
return ag
|
||||
|
||||
@@ -72,23 +72,22 @@ func (w roleWiring) planner(opts Options, executor *agent.Agent, executorModel,
|
||||
plannerTools = agent.PlannerToolRegistry(w.reg)
|
||||
}
|
||||
plannerOpts := agent.Options{
|
||||
MaxSteps: 0,
|
||||
TaskBudget: taskBudgetFromConfig(w.cfg),
|
||||
Gate: w.gate,
|
||||
CheckTargetAccess: w.checkTargetAccess,
|
||||
Hooks: w.hooks.ForRole("planner"),
|
||||
ModelRef: modelRefFromEntry(pe),
|
||||
ContextWindow: pe.ContextWindow,
|
||||
CompactRatio: w.cfg.Agent.CompactRatio,
|
||||
ContextEditing: w.cfg.Agent.ContextEditing,
|
||||
RecentKeep: w.cfg.Agent.RecentKeep,
|
||||
CompactionBudgets: compactionBudgets(w.cfg),
|
||||
ArchiveDir: w.roots.ArchiveDir(),
|
||||
KeepPolicy: w.keep,
|
||||
ReasoningLanguage: w.cfg.ReasoningLanguage(),
|
||||
CapabilityLedger: plannerLedger,
|
||||
CapabilityAudit: plannerAudit,
|
||||
MissingReasoningWarnStateDir: w.roots.MissingReasoningWarnStateDir(),
|
||||
MaxSteps: 0,
|
||||
TaskBudget: taskBudgetFromConfig(w.cfg),
|
||||
Gate: w.gate,
|
||||
CheckTargetAccess: w.checkTargetAccess,
|
||||
Hooks: w.hooks.ForRole("planner"),
|
||||
ModelRef: modelRefFromEntry(pe),
|
||||
ContextWindow: pe.ContextWindow,
|
||||
CompactRatio: w.cfg.Agent.CompactRatio,
|
||||
ContextEditing: w.cfg.Agent.ContextEditing,
|
||||
RecentKeep: w.cfg.Agent.RecentKeep,
|
||||
CompactionBudgets: compactionBudgets(w.cfg),
|
||||
ArchiveDir: w.roots.ArchiveDir(),
|
||||
KeepPolicy: w.keep,
|
||||
ReasoningLanguage: w.cfg.ReasoningLanguage(),
|
||||
CapabilityLedger: plannerLedger,
|
||||
CapabilityAudit: plannerAudit,
|
||||
}
|
||||
runner := coordinator.NewCoordinatorWithPlannerPolicy(plannerProv, plannerSess, pe.Price, plannerTools, plannerOpts, executor, w.cfg.Agent.Temperature, w.sink, control.NewPlannerPolicy())
|
||||
return runner, executorModel + " + planner " + pe.Model, nil
|
||||
|
||||
@@ -356,22 +356,6 @@ func RemoteKnownHostsPath() string {
|
||||
return filepath.Join(dir, "known_hosts")
|
||||
}
|
||||
|
||||
// MissingReasoningWarnStateDir is the shared directory for the rate-limited
|
||||
// missing tool-call thinking recovery gate (#7059): <Reasonix home>/state. The
|
||||
// legacy name preserves callers and the existing state-file contract. Routed
|
||||
// through the home resolver so REASONIX_HOME isolation holds.
|
||||
func MissingReasoningWarnStateDir() string { return processRoots().MissingReasoningWarnStateDir() }
|
||||
|
||||
// MissingReasoningWarnStateDir is the recovery gate's state directory under
|
||||
// this binding's home.
|
||||
func (r Roots) MissingReasoningWarnStateDir() string {
|
||||
home := r.Home()
|
||||
if strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, "state")
|
||||
}
|
||||
|
||||
// WorkspaceLeaseDir stores cross-process Delivery writer locks outside user
|
||||
// workspaces. It intentionally follows the cache root rather than project or
|
||||
// session state: taking a lease must never dirty the repository it protects.
|
||||
|
||||
@@ -139,9 +139,9 @@ func TestSyncForwardsProtocolRecoveryWithoutEmittingUIEvent(t *testing.T) {
|
||||
rec := &readinessAuditRecorder{}
|
||||
sink := Sync(rec)
|
||||
|
||||
RecordProtocolRecovery(sink, ProtocolRecoveryAudit{Kind: ProtocolRecoveryMissingReasoningRetryReplaced})
|
||||
RecordProtocolRecovery(sink, ProtocolRecoveryAudit{Kind: ProtocolRecoveryKind("test")})
|
||||
|
||||
if len(rec.recovery) != 1 || rec.recovery[0].Kind != ProtocolRecoveryMissingReasoningRetryReplaced {
|
||||
if len(rec.recovery) != 1 || rec.recovery[0].Kind != ProtocolRecoveryKind("test") {
|
||||
t.Fatalf("protocol recovery not forwarded through Sync: %+v", rec.recovery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,19 +36,6 @@ func RecordReadinessAudit(s Sink, a hostaudit.ReadinessAudit) {
|
||||
// provider implementation details.
|
||||
type ProtocolRecoveryKind string
|
||||
|
||||
const (
|
||||
ProtocolRecoveryMissingReasoningDetected ProtocolRecoveryKind = "missing_reasoning_detected"
|
||||
ProtocolRecoveryMissingReasoningRetryAttempted ProtocolRecoveryKind = "missing_reasoning_retry_attempted"
|
||||
ProtocolRecoveryMissingReasoningRetryRecovered ProtocolRecoveryKind = "missing_reasoning_retry_recovered"
|
||||
ProtocolRecoveryMissingReasoningRetryReplaced ProtocolRecoveryKind = "missing_reasoning_retry_replaced_response"
|
||||
ProtocolRecoveryMissingReasoningRetrySuppressed ProtocolRecoveryKind = "missing_reasoning_retry_suppressed"
|
||||
ProtocolRecoveryMissingReasoningFallback ProtocolRecoveryKind = "missing_reasoning_fallback_used"
|
||||
// ProtocolRecoveryMissingReasoningModelSilent is a tool-call turn the provider
|
||||
// billed no thinking tokens for. Recorded to keep the shape visible, never
|
||||
// replayed: nothing was lost in transit, so an identical request buys nothing.
|
||||
ProtocolRecoveryMissingReasoningModelSilent ProtocolRecoveryKind = "missing_reasoning_model_silent"
|
||||
)
|
||||
|
||||
type ProtocolRecoveryAudit struct {
|
||||
Kind ProtocolRecoveryKind
|
||||
// ChildID names the delegated run this repair happened inside, stamped by
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestSessionChainDeliversAudits(t *testing.T) {
|
||||
recorder := &capturingSink{}
|
||||
chain := Coalesce(Sync(NewCostQuoteSink(recorder, nil)), 5*time.Millisecond)
|
||||
|
||||
RecordProtocolRecovery(chain, ProtocolRecoveryAudit{Kind: ProtocolRecoveryMissingReasoningRetryAttempted})
|
||||
RecordProtocolRecovery(chain, ProtocolRecoveryAudit{Kind: ProtocolRecoveryKind("test")})
|
||||
|
||||
if len(recorder.recorded) != 1 || recorder.recorded[0] != "ProtocolRecovery" {
|
||||
t.Fatalf("protocol recovery never reached the recorder: %v", recorder.recorded)
|
||||
|
||||
@@ -6,8 +6,6 @@ package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -584,59 +582,6 @@ func RequiresReasoningRoundTrip(p Provider) bool {
|
||||
return ok && policy.RequiresReasoningRoundTrip()
|
||||
}
|
||||
|
||||
// MissingToolCallReasoningWarningPolicy is optionally implemented by providers
|
||||
// whose replay protocol requires reasoning_content, but whose active model may
|
||||
// not reliably emit it. The legacy Warning name is retained for source
|
||||
// compatibility; the agent now uses this policy for silent bounded recovery and
|
||||
// emits no user-visible protocol notice.
|
||||
type MissingToolCallReasoningWarningPolicy interface {
|
||||
WarnOnMissingToolCallReasoning() bool
|
||||
}
|
||||
|
||||
// MissingToolCallReasoningWarningIdentityPolicy optionally supplies the stable,
|
||||
// non-credential configuration identity used to rate-limit missing-reasoning
|
||||
// recovery attempts. The legacy name preserves adapters and persisted state.
|
||||
// Implementations may include adapter kind, endpoint, model, and thinking
|
||||
// controls; the raw identity never leaves memory and is hashed before
|
||||
// persistence.
|
||||
type MissingToolCallReasoningWarningIdentityPolicy interface {
|
||||
MissingToolCallReasoningWarningIdentity() string
|
||||
}
|
||||
|
||||
// WarnOnMissingToolCallReasoning reports whether a tool_calls turn with empty
|
||||
// reasoning_content should enter silent recovery. Its legacy name is preserved
|
||||
// for provider implementations compiled against the original diagnostic API.
|
||||
func WarnOnMissingToolCallReasoning(p Provider) bool {
|
||||
if nilutil.IsNil(p) {
|
||||
return false
|
||||
}
|
||||
policy, ok := p.(MissingToolCallReasoningWarningPolicy)
|
||||
if ok {
|
||||
return policy.WarnOnMissingToolCallReasoning()
|
||||
}
|
||||
return RequiresToolCallReasoning(p)
|
||||
}
|
||||
|
||||
// MissingToolCallReasoningWarningFingerprint returns an opaque stable key for
|
||||
// one provider configuration's recovery cooldown. Concrete adapters distinguish
|
||||
// endpoint/model/protocol changes; providers without the optional policy retain
|
||||
// a safe type-and-name fallback. The legacy name preserves the on-disk state
|
||||
// contract. The digest prevents local state from exposing raw endpoints or
|
||||
// model identifiers.
|
||||
func MissingToolCallReasoningWarningFingerprint(p Provider) string {
|
||||
if nilutil.IsNil(p) {
|
||||
return ""
|
||||
}
|
||||
identity := fmt.Sprintf("%T\x00%s", p, strings.TrimSpace(p.Name()))
|
||||
if policy, ok := p.(MissingToolCallReasoningWarningIdentityPolicy); ok {
|
||||
if configured := strings.TrimSpace(policy.MissingToolCallReasoningWarningIdentity()); configured != "" {
|
||||
identity = configured
|
||||
}
|
||||
}
|
||||
digest := sha256.Sum256([]byte(identity))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
// AuthError reports that a provider rejected the API key (HTTP 401/403). Its
|
||||
// message is already user-facing and actionable — it names the provider and,
|
||||
// when known, the environment variable the key comes from — and it carries the
|
||||
|
||||
@@ -23,22 +23,21 @@ var ErrNoProvider = errors.New("no model provider is configured")
|
||||
// process boundaries. It intentionally contains no endpoint, credential,
|
||||
// header, proxy, or environment-variable information.
|
||||
type Descriptor struct {
|
||||
Ref string `json:"ref"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ContextWindow int `json:"contextWindow,omitempty"`
|
||||
PricingCurrency string `json:"pricingCurrency,omitempty"`
|
||||
CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty"`
|
||||
InputPerMillion float64 `json:"inputPerMillion,omitempty"`
|
||||
OutputPerMillion float64 `json:"outputPerMillion,omitempty"`
|
||||
Vision bool `json:"vision,omitempty"`
|
||||
Tools bool `json:"tools,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty"`
|
||||
Efforts []string `json:"efforts,omitempty"`
|
||||
DefaultEffort string `json:"defaultEffort,omitempty"`
|
||||
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
|
||||
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
|
||||
WarnOnMissingToolCallReasoning bool `json:"warnOnMissingToolCallReasoning,omitempty"`
|
||||
Ref string `json:"ref"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ContextWindow int `json:"contextWindow,omitempty"`
|
||||
PricingCurrency string `json:"pricingCurrency,omitempty"`
|
||||
CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty"`
|
||||
InputPerMillion float64 `json:"inputPerMillion,omitempty"`
|
||||
OutputPerMillion float64 `json:"outputPerMillion,omitempty"`
|
||||
Vision bool `json:"vision,omitempty"`
|
||||
Tools bool `json:"tools,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty"`
|
||||
Efforts []string `json:"efforts,omitempty"`
|
||||
DefaultEffort string `json:"defaultEffort,omitempty"`
|
||||
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
|
||||
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
|
||||
// ForcesThinking tells frontends that even the lowest effort still reasons.
|
||||
ForcesThinking bool `json:"forcesThinking,omitempty"`
|
||||
// Default marks the model a new session starts on when nobody names one.
|
||||
|
||||
@@ -15,22 +15,21 @@ import (
|
||||
// ProviderDescriptor mirrors provider.Descriptor field-for-field as a public
|
||||
// DTO. It never carries endpoints, credentials, headers, or env names.
|
||||
type ProviderDescriptor struct {
|
||||
Ref string `json:"ref" validate:"nonempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ContextWindow int `json:"contextWindow,omitempty" validate:"min=0"`
|
||||
PricingCurrency string `json:"pricingCurrency,omitempty"`
|
||||
CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty" validate:"min=0"`
|
||||
InputPerMillion float64 `json:"inputPerMillion,omitempty" validate:"min=0"`
|
||||
OutputPerMillion float64 `json:"outputPerMillion,omitempty" validate:"min=0"`
|
||||
Vision bool `json:"vision,omitempty"`
|
||||
Tools bool `json:"tools,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty"`
|
||||
Efforts []string `json:"efforts,omitempty"`
|
||||
DefaultEffort string `json:"defaultEffort,omitempty"`
|
||||
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
|
||||
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
|
||||
WarnOnMissingToolCallReasoning bool `json:"warnOnMissingToolCallReasoning,omitempty"`
|
||||
Ref string `json:"ref" validate:"nonempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ContextWindow int `json:"contextWindow,omitempty" validate:"min=0"`
|
||||
PricingCurrency string `json:"pricingCurrency,omitempty"`
|
||||
CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty" validate:"min=0"`
|
||||
InputPerMillion float64 `json:"inputPerMillion,omitempty" validate:"min=0"`
|
||||
OutputPerMillion float64 `json:"outputPerMillion,omitempty" validate:"min=0"`
|
||||
Vision bool `json:"vision,omitempty"`
|
||||
Tools bool `json:"tools,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty"`
|
||||
Efforts []string `json:"efforts,omitempty"`
|
||||
DefaultEffort string `json:"defaultEffort,omitempty"`
|
||||
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
|
||||
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
|
||||
}
|
||||
|
||||
// PluginRefOwner extracts the plugin ID from a plugin-namespaced provider ref
|
||||
|
||||
@@ -49,7 +49,7 @@ var methodFixtures = map[Method]struct {
|
||||
PricingCurrency: "$", CacheHitPerMillion: 0.1, InputPerMillion: 1, OutputPerMillion: 2,
|
||||
Vision: true, Tools: true, Reasoning: true,
|
||||
Efforts: []string{"low", "high"}, DefaultEffort: "low",
|
||||
ToolCallReasoning: true, ReasoningRoundTrip: true, WarnOnMissingToolCallReasoning: true,
|
||||
ToolCallReasoning: true, ReasoningRoundTrip: true,
|
||||
}},
|
||||
UIActions: []UIActionDecl{{ActionID: "acme.refresh", Label: "Refresh"}},
|
||||
StateSchemaVersion: 2,
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"toolSpecs":{"items":{"$ref":"#/$defs/ToolSpec"},"type":"array"},"tools":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"answer":{"type":"string"},"decision":{"enum":["allow","ask","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/Line truncated
|
||||
{"$defs":{"CapabilityWire":{"additionalProperties":false,"properties":{"id":{"minLength":1,"type":"string"},"kind":{"minLength":1,"type":"string"},"namespace":{"minLength":1,"type":"string"},"schemaHash":{"type":"string"},"version":{"type":"string"}},"required":["id","kind","namespace"],"type":"object"},"ContentReadParams":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"offset":{"minimum":0,"type":"integer"}},"required":["contentRef","offset"],"type":"object"},"ContentReadResult":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"dataBase64":{"type":"string"},"encoding":{"enum":["utf8"],"type":"string"},"nextOffset":{"minimum":0,"type":"integer"},"offset":{"minimum":0,"type":"integer"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","dataBase64","encoding","offset","sha256","totalBytes"],"type":"object"},"EventParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true}},"required":["event","payload"],"type":"object"},"ExternalizedField":{"additionalProperties":false,"properties":{"contentRef":{"minLength":1,"type":"string"},"jsonPointer":{"minLength":1,"type":"string"},"sha256":{"type":"string"},"totalBytes":{"minimum":0,"type":"integer"}},"required":["contentRef","jsonPointer","sha256","totalBytes"],"type":"object"},"HostCapabilities":{"additionalProperties":false,"properties":{"contentRefs":{"type":"boolean"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"protocolVersion":{"minLength":1,"type":"string"},"uiHost":{"enum":["acp","desktop","headless","tui"],"type":"string"}},"required":["contentRefs","protocolVersion","uiHost"],"type":"object"},"InitializeParams":{"additionalProperties":false,"properties":{"capabilities":{"$ref":"#/$defs/HostCapabilities"},"dependencySchemaVersion":{"minimum":0,"type":"integer"},"manifest":{"$ref":"#/$defs/ManifestExpectation"},"protocolId":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"session":{"$ref":"#/$defs/SessionContext"}},"required":["capabilities","manifest","protocolId","protocolVersion","session"],"type":"object"},"InitializeResult":{"additionalProperties":false,"properties":{"componentId":{"type":"string"},"name":{"minLength":1,"type":"string"},"protocolVersion":{"minLength":1,"type":"string"},"providers":{"items":{"$ref":"#/$defs/ProviderDescriptor"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/RequirementWire"},"type":"array"},"stateSchemaVersion":{"minimum":0,"type":"integer"},"subscriptions":{"items":{"type":"string"},"type":"array"},"toolSpecs":{"items":{"$ref":"#/$defs/ToolSpec"},"type":"array"},"tools":{"items":{"type":"string"},"type":"array"},"uiActions":{"items":{"$ref":"#/$defs/UIActionDecl"},"type":"array"},"version":{"minLength":1,"type":"string"}},"required":["name","protocolVersion","stateSchemaVersion","version"],"type":"object"},"InitializedParams":{"additionalProperties":false,"properties":{},"type":"object"},"InterceptParams":{"additionalProperties":false,"properties":{"event":{"enum":["agent.before_start","compaction.complete","compaction.prepare","context.prepare","frontend.event","input.receive","permission.decision","provider.request","provider.response","session.end","session.load","session.rotate","session.save","session.start","system_prompt.build","tool.after","tool.before"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"payload":{"x-externalizable":true},"seq":{"minimum":1,"type":"integer"},"timeoutMillis":{"minimum":0,"type":"integer"}},"required":["event","payload","seq","timeoutMillis"],"type":"object"},"InterceptResult":{"additionalProperties":false,"properties":{"answer":{"type":"string"},"decision":{"enum":["allow","ask","block","continue","deny","replace"],"type":"string"},"externalized":{"items":{"$ref":"#/$defs/ExternalizedField"},"type":"array"},"reason":{"type":"string"},"replacement":{"x-externalizable":true}},"required":["decision"],"type":"object"},"ManifestExpectation":{"additionalProperties":false,"properties":{"capabilities":{"items":{"type":"string"},"type":"array"},"intercepts":{"items":{"type":"string"},"type":"array"},"providers":{"items":{"type":"string"},"type":"array"},"provides":{"items":{"$ref":"#/$defs/CapabilityWire"},"type":"array"},"replaces":{"items":{"type":"string"},"type":"array"},"requires":{"items":{"$ref":"#/$defs/Line truncated
|
||||
@@ -5,4 +5,4 @@ package protocol
|
||||
// GeneratedSchemaHash is the SHA-256 of schema.generated.json. Handshake
|
||||
// comparisons use this constant; protocol tests independently recompute it
|
||||
// from CanonicalSchemaBytes to reject stale generated artifacts.
|
||||
const GeneratedSchemaHash = "sha256:240d875d8539373bc8e50cb8df431ba0364e58a4502b773beb0eb6a2a8120bb3"
|
||||
const GeneratedSchemaHash = "sha256:a0716fcfa8aab0f67a5c2299c4e6fa1573378e44c6050633a930d6d736f35fd7"
|
||||
@@ -170,22 +170,21 @@ func UsageFromProtocol(u *protocol.ProviderUsage) *provider.Usage {
|
||||
// host's descriptor form. The DTO mirrors provider.Descriptor field-for-field.
|
||||
func DescriptorFromProtocol(d protocol.ProviderDescriptor) provider.Descriptor {
|
||||
return provider.Descriptor{
|
||||
Ref: d.Ref,
|
||||
DisplayName: d.DisplayName,
|
||||
Model: d.Model,
|
||||
ContextWindow: d.ContextWindow,
|
||||
PricingCurrency: d.PricingCurrency,
|
||||
CacheHitPerMillion: d.CacheHitPerMillion,
|
||||
InputPerMillion: d.InputPerMillion,
|
||||
OutputPerMillion: d.OutputPerMillion,
|
||||
Vision: d.Vision,
|
||||
Tools: d.Tools,
|
||||
Reasoning: d.Reasoning,
|
||||
Efforts: append([]string(nil), d.Efforts...),
|
||||
DefaultEffort: d.DefaultEffort,
|
||||
ToolCallReasoning: d.ToolCallReasoning,
|
||||
ReasoningRoundTrip: d.ReasoningRoundTrip,
|
||||
WarnOnMissingToolCallReasoning: d.WarnOnMissingToolCallReasoning,
|
||||
Ref: d.Ref,
|
||||
DisplayName: d.DisplayName,
|
||||
Model: d.Model,
|
||||
ContextWindow: d.ContextWindow,
|
||||
PricingCurrency: d.PricingCurrency,
|
||||
CacheHitPerMillion: d.CacheHitPerMillion,
|
||||
InputPerMillion: d.InputPerMillion,
|
||||
OutputPerMillion: d.OutputPerMillion,
|
||||
Vision: d.Vision,
|
||||
Tools: d.Tools,
|
||||
Reasoning: d.Reasoning,
|
||||
Efforts: append([]string(nil), d.Efforts...),
|
||||
DefaultEffort: d.DefaultEffort,
|
||||
ToolCallReasoning: d.ToolCallReasoning,
|
||||
ReasoningRoundTrip: d.ReasoningRoundTrip,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ func TestDescriptorFromProtocolCopiesFields(t *testing.T) {
|
||||
CacheHitPerMillion: 0.1, InputPerMillion: 1.0, OutputPerMillion: 2.0,
|
||||
Vision: true, Tools: true, Reasoning: true,
|
||||
Efforts: []string{"low", "high"}, DefaultEffort: "low",
|
||||
ToolCallReasoning: true, ReasoningRoundTrip: true, WarnOnMissingToolCallReasoning: true,
|
||||
ToolCallReasoning: true, ReasoningRoundTrip: true,
|
||||
}
|
||||
d := DescriptorFromProtocol(wire)
|
||||
if d.Ref != wire.Ref || d.DisplayName != wire.DisplayName || d.Model != wire.Model ||
|
||||
@@ -135,7 +135,6 @@ func TestDescriptorFromProtocolCopiesFields(t *testing.T) {
|
||||
d.OutputPerMillion != wire.OutputPerMillion || d.Vision != wire.Vision || d.Tools != wire.Tools ||
|
||||
d.Reasoning != wire.Reasoning || d.DefaultEffort != wire.DefaultEffort ||
|
||||
d.ToolCallReasoning != wire.ToolCallReasoning || d.ReasoningRoundTrip != wire.ReasoningRoundTrip ||
|
||||
d.WarnOnMissingToolCallReasoning != wire.WarnOnMissingToolCallReasoning ||
|
||||
len(d.Efforts) != 2 || d.Efforts[1] != "high" {
|
||||
t.Fatalf("descriptor = %+v", d)
|
||||
}
|
||||
|
||||
@@ -51,27 +51,6 @@ func (p *Provider) RequiresReasoningRoundTrip() bool {
|
||||
return p != nil && p.descriptor.ReasoningRoundTrip
|
||||
}
|
||||
|
||||
// WarnOnMissingToolCallReasoning reports the descriptor's warning policy.
|
||||
func (p *Provider) WarnOnMissingToolCallReasoning() bool {
|
||||
return p != nil && p.descriptor.WarnOnMissingToolCallReasoning
|
||||
}
|
||||
|
||||
// MissingToolCallReasoningWarningIdentity supplies the stable, non-credential
|
||||
// configuration identity used to rate-limit missing-reasoning diagnostics.
|
||||
func (p *Provider) MissingToolCallReasoningWarningIdentity() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
effort := ""
|
||||
if p.effort != nil {
|
||||
effort = strings.TrimSpace(*p.effort)
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"extension-sidecar", strings.TrimSpace(p.client.PluginID()), strings.TrimSpace(p.ref),
|
||||
strings.TrimSpace(p.descriptor.Model), effort,
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
// Stream opens one sidecar stream. Each call re-resolves the live backend so
|
||||
// rolling replacement keeps the same provider-visible ref (cache-stable).
|
||||
func (p *Provider) Stream(ctx context.Context, request provider.Request) (<-chan provider.Chunk, error) {
|
||||
|
||||
@@ -515,7 +515,6 @@ func TestProviderReasoningPoliciesComeFromDescriptor(t *testing.T) {
|
||||
descriptor := demoDescriptor()
|
||||
descriptor.ToolCallReasoning = true
|
||||
descriptor.ReasoningRoundTrip = true
|
||||
descriptor.WarnOnMissingToolCallReasoning = true
|
||||
fc := newFakeClient("demo", descriptor)
|
||||
r := testResolver(t, baseCatalog(), nil, fc)
|
||||
|
||||
@@ -523,12 +522,9 @@ func TestProviderReasoningPoliciesComeFromDescriptor(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !provider.RequiresToolCallReasoning(p) || !provider.RequiresReasoningRoundTrip(p) || !provider.WarnOnMissingToolCallReasoning(p) {
|
||||
if !provider.RequiresToolCallReasoning(p) || !provider.RequiresReasoningRoundTrip(p) {
|
||||
t.Fatal("descriptor reasoning policies did not propagate")
|
||||
}
|
||||
if identity := p.(interface{ MissingToolCallReasoningWarningIdentity() string }).MissingToolCallReasoningWarningIdentity(); !strings.Contains(identity, "demo") || !strings.Contains(identity, "plugin/demo/fake/x") {
|
||||
t.Fatalf("warning identity = %q", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteUnknownStreamDropped(t *testing.T) {
|
||||
|
||||
@@ -125,14 +125,8 @@ type RunMetrics struct {
|
||||
// Goal resumes that carried a frozen verification contract, and how many of
|
||||
// those found the current declaration naming different criteria. Neither
|
||||
// says which declaration should govern.
|
||||
GoalResumesWithContract int `json:"goal_resumes_with_verification_contract"`
|
||||
GoalVerificationDrifts int `json:"goal_verification_contract_drifts"`
|
||||
MissingReasoningDetected int `json:"missing_reasoning_detected,omitempty"`
|
||||
MissingReasoningRetries int `json:"missing_reasoning_retries,omitempty"`
|
||||
MissingReasoningRecovered int `json:"missing_reasoning_recovered,omitempty"`
|
||||
MissingReasoningReplaced int `json:"missing_reasoning_retry_replaced_response,omitempty"`
|
||||
MissingReasoningSuppressed int `json:"missing_reasoning_retry_suppressed,omitempty"`
|
||||
MissingReasoningFallbacks int `json:"missing_reasoning_fallbacks,omitempty"`
|
||||
GoalResumesWithContract int `json:"goal_resumes_with_verification_contract"`
|
||||
GoalVerificationDrifts int `json:"goal_verification_contract_drifts"`
|
||||
// Fanout times what the delegation counters only count: what the run waited
|
||||
// on its fan-outs, against what the same work costs one at a time.
|
||||
Fanout *FanoutMetrics `json:"fanout,omitempty"`
|
||||
@@ -620,25 +614,6 @@ func (s *metricsSink) RecordProjectCheckProbe(p event.ProjectCheckProbe) {
|
||||
}
|
||||
|
||||
func (s *metricsSink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
|
||||
s.mu.Lock()
|
||||
switch a.Kind {
|
||||
case event.ProtocolRecoveryMissingReasoningDetected:
|
||||
s.m.MissingReasoningDetected++
|
||||
case event.ProtocolRecoveryMissingReasoningRetryAttempted:
|
||||
s.m.MissingReasoningRetries++
|
||||
// Usage from the original and recovery responses is intentionally merged
|
||||
// into one invisible UI event, but Steps remains a true model-call count.
|
||||
s.m.Steps++
|
||||
case event.ProtocolRecoveryMissingReasoningRetryRecovered:
|
||||
s.m.MissingReasoningRecovered++
|
||||
case event.ProtocolRecoveryMissingReasoningRetryReplaced:
|
||||
s.m.MissingReasoningReplaced++
|
||||
case event.ProtocolRecoveryMissingReasoningRetrySuppressed:
|
||||
s.m.MissingReasoningSuppressed++
|
||||
case event.ProtocolRecoveryMissingReasoningFallback:
|
||||
s.m.MissingReasoningFallbacks++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if a.ChildID != "" {
|
||||
s.m.SubagentRecoveries++
|
||||
}
|
||||
|
||||
@@ -91,23 +91,6 @@ func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsSinkAccumulatesSilentReasoningRecovery(t *testing.T) {
|
||||
s := &metricsSink{inner: event.Discard}
|
||||
s.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningDetected})
|
||||
s.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryAttempted})
|
||||
s.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryRecovered})
|
||||
s.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryReplaced})
|
||||
s.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetrySuppressed})
|
||||
s.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningFallback})
|
||||
|
||||
if s.m.MissingReasoningDetected != 1 || s.m.MissingReasoningRetries != 1 || s.m.MissingReasoningRecovered != 1 || s.m.MissingReasoningReplaced != 1 || s.m.MissingReasoningSuppressed != 1 || s.m.MissingReasoningFallbacks != 1 {
|
||||
t.Fatalf("reasoning recovery metrics = %+v", s.m)
|
||||
}
|
||||
if s.m.Steps != 1 {
|
||||
t.Fatalf("retry model-call step = %d, want 1", s.m.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsSinkAccountsToolCallsAndRetries(t *testing.T) {
|
||||
s := &metricsSink{inner: event.Discard}
|
||||
|
||||
|
||||
@@ -217,20 +217,6 @@ func (c *client) RequiresToolCallReasoning() bool {
|
||||
return c.deepSeekThinkingEnabled()
|
||||
}
|
||||
|
||||
func (c *client) MissingToolCallReasoningWarningIdentity() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
protocol := "anthropic"
|
||||
if c.deepseek {
|
||||
protocol = "deepseek-anthropic"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"anthropic", strings.TrimSpace(c.name), strings.TrimSpace(c.requestURL),
|
||||
strings.TrimSpace(c.model), protocol, strings.TrimSpace(c.thinking), strings.TrimSpace(c.effort),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
func (c *client) sendOpts() provider.SendOptions {
|
||||
return provider.SendOptions{
|
||||
Provider: c.name,
|
||||
|
||||
@@ -688,19 +688,6 @@ func TestBuildRequestDeepSeekThinking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingToolCallReasoningWarningFingerprintTracksAnthropicConfiguration(t *testing.T) {
|
||||
first := &client{name: "deepseek", baseURL: "https://api.deepseek.com/anthropic", model: "deepseek-v4-pro", deepseek: true, thinking: "enabled", effort: "high"}
|
||||
same := &client{name: "deepseek", baseURL: "https://api.deepseek.com/anthropic", model: "deepseek-v4-pro", deepseek: true, thinking: "enabled", effort: "high"}
|
||||
flash := &client{name: "deepseek", baseURL: "https://api.deepseek.com/anthropic", model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", effort: "high"}
|
||||
got := provider.MissingToolCallReasoningWarningFingerprint(first)
|
||||
if got != provider.MissingToolCallReasoningWarningFingerprint(same) {
|
||||
t.Fatal("equivalent Anthropic configurations produced different fingerprints")
|
||||
}
|
||||
if got == provider.MissingToolCallReasoningWarningFingerprint(flash) {
|
||||
t.Fatal("Anthropic model change did not re-key the warning fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestDeepSeekReplaysOnlyToolCallReasoningFromHistory(t *testing.T) {
|
||||
toolTurn := []provider.Message{
|
||||
{Role: provider.RoleUser, Content: "weather?"},
|
||||
|
||||
@@ -314,36 +314,6 @@ func (c *client) RequiresReasoningRoundTrip() bool {
|
||||
return c != nil && (c.minimax || c.kimiK3 || c.glmThinkingEnabled())
|
||||
}
|
||||
|
||||
func (c *client) WarnOnMissingToolCallReasoning() bool {
|
||||
return c.RequiresToolCallReasoning() && expectsDeepSeekToolCallReasoning(c.model, c.thinkingType)
|
||||
}
|
||||
|
||||
func expectsDeepSeekToolCallReasoning(model, thinkingType string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(thinkingType), "enabled") {
|
||||
return true
|
||||
}
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
return strings.Contains(model, "deepseek-v4-flash") ||
|
||||
strings.Contains(model, "deepseek-v4-pro") ||
|
||||
strings.Contains(model, "deepseek-v3.2") ||
|
||||
strings.Contains(model, "deepseek-reasoner") ||
|
||||
strings.Contains(model, "deepseek-r1")
|
||||
}
|
||||
|
||||
func (c *client) MissingToolCallReasoningWarningIdentity() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
protocol := "openai"
|
||||
if c.deepseek {
|
||||
protocol = "deepseek"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"openai", strings.TrimSpace(c.name), strings.TrimSpace(c.baseURL),
|
||||
strings.TrimSpace(c.model), protocol, strings.TrimSpace(c.thinkingType), strings.TrimSpace(c.effort),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
func (c *client) sendOpts() provider.SendOptions {
|
||||
return provider.SendOptions{
|
||||
Provider: c.name,
|
||||
|
||||
@@ -1767,9 +1767,6 @@ func TestBuildRequestRoundTripsGLMReasoningHistory(t *testing.T) {
|
||||
if got := enabledReq.Messages[4].ReasoningContent; got == nil || *got != "combine the result" {
|
||||
t.Fatalf("enabled GLM plain-turn reasoning_content = %v, want complete reasoning history", got)
|
||||
}
|
||||
if provider.WarnOnMissingToolCallReasoning(enabled) {
|
||||
t.Fatal("GLM must preserve available reasoning without entering DeepSeek-specific missing-reasoning recovery")
|
||||
}
|
||||
|
||||
disabled, disabledReq := build("disabled")
|
||||
if disabled.RequiresToolCallReasoning() || disabled.RequiresReasoningRoundTrip() {
|
||||
@@ -2110,99 +2107,6 @@ func TestBuildRequestAlwaysSendsReasoningKeyOnDeepSeekToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnOnMissingToolCallReasoningFollowsDeepSeekThinkingModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{model: "deepseek-v4-flash", want: true},
|
||||
{model: "deepseek/deepseek-v4-flash", want: true},
|
||||
{model: "deepseek-v4-pro", want: true},
|
||||
{model: "deepseek/deepseek-v4-pro", want: true},
|
||||
{model: "deepseek-ai/DeepSeek-V4-Pro", want: true},
|
||||
{model: "deepseek-reasoner", want: true},
|
||||
{model: "deepseek-ai/DeepSeek-R1-0528", want: true},
|
||||
{model: "deepseek-ai/DeepSeek-V3.2", want: true},
|
||||
{model: "deepseek-chat", want: false},
|
||||
{model: "deepseek-ai/DeepSeek-Prover-V2", want: false},
|
||||
{model: "custom-model", want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.model, func(t *testing.T) {
|
||||
p, err := New(provider.Config{
|
||||
Name: "deepseek-proxy",
|
||||
BaseURL: "https://gateway.example/v1",
|
||||
Model: tc.model,
|
||||
APIKey: "k",
|
||||
Extra: map[string]any{"reasoning_protocol": "deepseek"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if !provider.RequiresToolCallReasoning(p) {
|
||||
t.Fatal("DeepSeek protocol should keep conservative reasoning_content replay for tool-call turns")
|
||||
}
|
||||
if got := provider.WarnOnMissingToolCallReasoning(p); got != tc.want {
|
||||
t.Fatalf("WarnOnMissingToolCallReasoning() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
explicitThinking, err := New(provider.Config{
|
||||
Name: "custom-thinking", BaseURL: "https://gateway.example/v1", Model: "custom-model", APIKey: "k",
|
||||
Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New explicit thinking provider: %v", err)
|
||||
}
|
||||
if !provider.WarnOnMissingToolCallReasoning(explicitThinking) {
|
||||
t.Fatal("explicit DeepSeek thinking must diagnose missing tool-call reasoning")
|
||||
}
|
||||
|
||||
p, err := New(provider.Config{
|
||||
Name: "deepseek-v4-pro-openai-protocol",
|
||||
BaseURL: "https://gateway.example/v1",
|
||||
Model: "deepseek-v4-pro",
|
||||
APIKey: "k",
|
||||
Extra: map[string]any{"reasoning_protocol": "openai"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New OpenAI protocol: %v", err)
|
||||
}
|
||||
if provider.WarnOnMissingToolCallReasoning(p) {
|
||||
t.Fatal("OpenAI protocol should not warn using DeepSeek reasoning_content policy")
|
||||
}
|
||||
if provider.WarnOnMissingToolCallReasoning(&client{deepseek: true, thinkingType: "disabled"}) {
|
||||
t.Fatal("disabled thinking must not diagnose missing tool-call reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingToolCallReasoningWarningFingerprintTracksOpenAIConfiguration(t *testing.T) {
|
||||
newProvider := func(baseURL, model string) provider.Provider {
|
||||
p, err := New(provider.Config{
|
||||
Name: "deepseek", BaseURL: baseURL, Model: model, APIKey: "secret",
|
||||
Extra: map[string]any{"reasoning_protocol": "deepseek", "effort": "high"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
first := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://gateway.example/v1", "deepseek-v4-pro"))
|
||||
same := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://gateway.example/v1", "deepseek-v4-pro"))
|
||||
changedEndpoint := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://other.example/v1", "deepseek-v4-pro"))
|
||||
changedModel := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://gateway.example/v1", "deepseek-v4-flash"))
|
||||
if first != same {
|
||||
t.Fatal("equivalent OpenAI configurations produced different fingerprints")
|
||||
}
|
||||
if first == changedEndpoint || first == changedModel {
|
||||
t.Fatal("endpoint or model change did not re-key the warning fingerprint")
|
||||
}
|
||||
if len(first) != 64 || strings.Contains(first, "gateway") || strings.Contains(first, "deepseek") {
|
||||
t.Fatalf("fingerprint is not an opaque SHA-256 digest: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRequestRoundTripsDeepSeekToolCallReasoning keeps the healthy-path
|
||||
// bytes intact: when the session has the provider-issued reasoning, it is
|
||||
// replayed verbatim on the tool_calls turn.
|
||||
|
||||
@@ -223,38 +223,6 @@ func (c *client) RequiresToolCallReasoning() bool {
|
||||
return c.caps.toolCallReasoning
|
||||
}
|
||||
|
||||
func (c *client) MissingToolCallReasoningWarningIdentity() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"responses", strings.TrimSpace(c.name), strings.TrimSpace(c.requestURL),
|
||||
strings.TrimSpace(c.model), strings.TrimSpace(c.vendor), strings.TrimSpace(c.mode), strings.TrimSpace(c.effort),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
// WarnOnMissingToolCallReasoning reports a tool_calls turn that arrived
|
||||
// without reasoning only for vendors whose endpoint reliably emits it.
|
||||
// DeepSeek's official API emits tool-call reasoning for its pro-tier models,
|
||||
// so a missing chain-of-thought there is a real degradation worth one warning.
|
||||
// MiMo documents reasoning alongside tool calls but does not guarantee it on
|
||||
// every round (observed: mimo-v2.5-pro tool-call turn with empty reasoning),
|
||||
// so a missing chain-of-thought is endpoint-conditional, not a degradation
|
||||
// signal — silence the warning. Capability-driven (review #7234):
|
||||
// toolCallReasoning=false vendors (DashScope) never warn — no round-trip
|
||||
// contract; singleSegmentReasoning=true vendors (MiMo) never warn — their
|
||||
// tool-call thinking is a single optional segment. Only multi-segment
|
||||
// thinking vendors that require replay (DeepSeek) warn, scoped to non-flash.
|
||||
func (c *client) WarnOnMissingToolCallReasoning() bool {
|
||||
if !c.caps.toolCallReasoning || c.caps.singleSegmentReasoning {
|
||||
return false
|
||||
}
|
||||
model := strings.ToLower(strings.TrimSpace(c.model))
|
||||
// Flash-tier DeepSeek models do not emit tool-call reasoning (same carve
|
||||
// as openai.go expectsDeepSeekToolCallReasoning).
|
||||
return !strings.Contains(model, "flash")
|
||||
}
|
||||
|
||||
func (c *client) sendOpts() provider.SendOptions {
|
||||
return provider.SendOptions{Provider: c.name, KeyEnv: c.keyEnv, KeySource: c.keySource, KeyPresent: c.apiKey() != "", RetryAuth: c.authed.Load(), HeaderTimeout: c.idleTimeout}
|
||||
}
|
||||
|
||||
@@ -593,12 +593,11 @@ func TestVendorCapabilityTableCoversKnownEndpoints(t *testing.T) {
|
||||
url, vendor string
|
||||
stateless, reasoning bool
|
||||
ignoresTemp bool
|
||||
singleSegment bool
|
||||
}{
|
||||
{"https://api.deepseek.com", "deepseek", true, true, false, false},
|
||||
{"https://api.xiaomimimo.com/v1", "mimo", true, true, true, true},
|
||||
{"https://dashscope.aliyuncs.com/compatible-mode/v1", "dashscope", false, false, false, false},
|
||||
{"https://example.com/v1", "", false, false, false, false},
|
||||
{"https://api.deepseek.com", "deepseek", true, true, false},
|
||||
{"https://api.xiaomimimo.com/v1", "mimo", true, true, true},
|
||||
{"https://dashscope.aliyuncs.com/compatible-mode/v1", "dashscope", false, false, false},
|
||||
{"https://example.com/v1", "", false, false, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
vendor := DetectVendor(test.url)
|
||||
@@ -615,9 +614,6 @@ func TestVendorCapabilityTableCoversKnownEndpoints(t *testing.T) {
|
||||
if caps.ignoresTemperature != test.ignoresTemp {
|
||||
t.Errorf("capabilities(%q).ignoresTemperature = %v, want %v", vendor, caps.ignoresTemperature, test.ignoresTemp)
|
||||
}
|
||||
if caps.singleSegmentReasoning != test.singleSegment {
|
||||
t.Errorf("capabilities(%q).singleSegmentReasoning = %v, want %v", vendor, caps.singleSegmentReasoning, test.singleSegment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,19 +672,6 @@ func TestRequiresToolCallReasoningForStatelessVendors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingToolCallReasoningWarningFingerprintTracksResponsesConfiguration(t *testing.T) {
|
||||
first := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: "high"})
|
||||
same := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com/", Model: "deepseek-v4-flash", Effort: "high"})
|
||||
changedEffort := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: "max"})
|
||||
got := provider.MissingToolCallReasoningWarningFingerprint(first)
|
||||
if got != provider.MissingToolCallReasoningWarningFingerprint(same) {
|
||||
t.Fatal("equivalent Responses configurations produced different fingerprints")
|
||||
}
|
||||
if got == provider.MissingToolCallReasoningWarningFingerprint(changedEffort) {
|
||||
t.Fatal("Responses effort change did not re-key the warning fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningIDRoundTripsThroughInput(t *testing.T) { // The OpenAI Responses schema marks Reasoning.id required. A message
|
||||
// carrying a captured ReasoningID must echo it back on the input
|
||||
// reasoning item (and omit it when absent).
|
||||
@@ -736,31 +719,6 @@ func TestReasoningIDRoundTripsThroughInput(t *testing.T) { // The OpenAI Respons
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnOnMissingToolCallReasoningIsModelScoped(t *testing.T) {
|
||||
// DeepSeek pro-tier: warns (endpoint reliably emits tool-call reasoning).
|
||||
pro := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro"})
|
||||
if !provider.WarnOnMissingToolCallReasoning(pro) {
|
||||
t.Fatal("DeepSeek pro must warn on missing tool-call reasoning")
|
||||
}
|
||||
// DeepSeek flash-tier: no warning (flash does not emit tool-call reasoning).
|
||||
flash := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"})
|
||||
if provider.WarnOnMissingToolCallReasoning(flash) {
|
||||
t.Fatal("DeepSeek flash must not warn on missing tool-call reasoning")
|
||||
}
|
||||
// MiMo: preserves reasoning on replay but does not guarantee it every
|
||||
// round — a missing chain-of-thought is endpoint-conditional, not a
|
||||
// degradation worth a warning (observed: mimo-v2.5-pro tool-call turn
|
||||
// with empty reasoning).
|
||||
mimo := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro"})
|
||||
if provider.WarnOnMissingToolCallReasoning(mimo) {
|
||||
t.Fatal("MiMo must not warn on missing tool-call reasoning (endpoint-conditional)")
|
||||
}
|
||||
other := New(Config{Name: "other", BaseURL: "https://example.com", Model: "m"})
|
||||
if provider.WarnOnMissingToolCallReasoning(other) {
|
||||
t.Fatal("unknown Responses endpoint must not warn on missing tool-call reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedEventSurfacesAuthenticationError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeEvents(w, `{"type":"response.failed","response":{"id":"resp","error":{"code":"invalid_api_key","message":"bad API key"}}}`)
|
||||
@@ -1085,28 +1043,6 @@ func TestConversationDigestMirrorsWireKnobs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSingleSegmentReasoningWiredIntoWarningPolicy:singleSegmentReasoning
|
||||
// capability 驱动警告策略(评审 #7234 Copilot:wire into behavior)。
|
||||
func TestSingleSegmentReasoningWiredIntoWarningPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, baseURL, model string
|
||||
want bool
|
||||
}{
|
||||
{"mimo 单段不警告", "https://api.xiaomimimo.com/v1", "mimo-v2.5-pro", false},
|
||||
{"dashscope 无回传契约不警告", "https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen3", false},
|
||||
{"deepseek pro 多段警告", "https://api.deepseek.com", "deepseek-v4-pro", true},
|
||||
{"deepseek flash 豁免", "https://api.deepseek.com", "deepseek-v4-flash", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
pro := New(Config{Name: "t", APIKey: "k", BaseURL: tc.baseURL, Model: tc.model}).(interface {
|
||||
WarnOnMissingToolCallReasoning() bool
|
||||
})
|
||||
if got := pro.WarnOnMissingToolCallReasoning(); got != tc.want {
|
||||
t.Errorf("%s: WarnOnMissingToolCallReasoning = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactoryPassesExtraThrough:newFromConfig 原样透传 cfg.Extra——
|
||||
// vision 开关经 provider factory 后仍生效(评审 #7234 第 3 点)。
|
||||
func TestFactoryPassesExtraThrough(t *testing.T) {
|
||||
|
||||
@@ -34,17 +34,6 @@ type vendorCapabilities struct {
|
||||
// calls (DeepSeek, MiMo).
|
||||
toolCallReasoning bool
|
||||
|
||||
// singleSegmentReasoning marks endpoints whose thinking is one
|
||||
// uninterruptible segment per turn: the server emits reasoning and the
|
||||
// final answer atomically, and a new reasoning segment only starts on a
|
||||
// brand-new turn — never mid-turn after a tool call. MiMo documents this
|
||||
// ("reasoning.effort: low/medium/high all enable reasoning, no strength
|
||||
// differentiation"; tool-call turns carry one segment). DeepSeek, by
|
||||
// contrast, can emit several reasoning segments across a turn's tool
|
||||
// loop. Callers must not expect a multi-segment chain-of-thought from
|
||||
// single-segment vendors.
|
||||
singleSegmentReasoning bool
|
||||
|
||||
// ignoresTemperature marks vendors that force temperature/top_p to their
|
||||
// defaults in thinking mode, so sending them is a no-op (MiMo forces
|
||||
// 1.0 / 0.95). Keeps the wire request lean for such endpoints.
|
||||
@@ -101,31 +90,28 @@ const (
|
||||
|
||||
var vendorTable = map[string]vendorCapabilities{
|
||||
"dashscope": {
|
||||
stateless: false,
|
||||
sessionCacheHeader: true,
|
||||
toolCallReasoning: false,
|
||||
singleSegmentReasoning: false,
|
||||
ignoresTemperature: false,
|
||||
summary: summaryEcho,
|
||||
stateless: false,
|
||||
sessionCacheHeader: true,
|
||||
toolCallReasoning: false,
|
||||
ignoresTemperature: false,
|
||||
summary: summaryEcho,
|
||||
},
|
||||
"deepseek": {
|
||||
stateless: true,
|
||||
sessionCacheHeader: false,
|
||||
toolCallReasoning: true,
|
||||
singleSegmentReasoning: false,
|
||||
ignoresTemperature: false,
|
||||
omitReasoningIdentity: true,
|
||||
stateless: true,
|
||||
sessionCacheHeader: false,
|
||||
toolCallReasoning: true,
|
||||
ignoresTemperature: false,
|
||||
omitReasoningIdentity: true,
|
||||
// Auto ceiling for ordinary reasoning; high/max is applied via
|
||||
// AutoOutputBudget at construction/request time (64K). Never 128K.
|
||||
defaultMaxOutputTokens: provider.DefaultReasoningOutputTokens,
|
||||
},
|
||||
"mimo": {
|
||||
stateless: true,
|
||||
sessionCacheHeader: false,
|
||||
toolCallReasoning: true,
|
||||
singleSegmentReasoning: true,
|
||||
ignoresTemperature: true,
|
||||
summary: summaryOmit,
|
||||
stateless: true,
|
||||
sessionCacheHeader: false,
|
||||
toolCallReasoning: true,
|
||||
ignoresTemperature: true,
|
||||
summary: summaryOmit,
|
||||
// Coding-agent default 32K; users may raise explicitly. Not 128K auto.
|
||||
defaultMaxOutputTokens: provider.DefaultReasoningOutputTokens,
|
||||
},
|
||||
|
||||
@@ -33,9 +33,9 @@ func TestSinkForwardsProtocolRecoveryWithoutNotification(t *testing.T) {
|
||||
sender := &recordSender{}
|
||||
sink := NewSink(inner, sender, i18n.English, NewSettings(config.NotificationsConfig{Enabled: true, TurnDone: true}))
|
||||
|
||||
event.RecordProtocolRecovery(sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningFallback})
|
||||
event.RecordProtocolRecovery(sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryKind("test")})
|
||||
|
||||
if len(inner.recovery) != 1 || inner.recovery[0].Kind != event.ProtocolRecoveryMissingReasoningFallback {
|
||||
if len(inner.recovery) != 1 || inner.recovery[0].Kind != event.ProtocolRecoveryKind("test") {
|
||||
t.Fatalf("forwarded protocol recovery = %+v", inner.recovery)
|
||||
}
|
||||
if len(sender.messages) != 0 {
|
||||
|
||||
@@ -398,7 +398,7 @@ func TestEverySignalTheSinkRecordsSurvivesTheUploadAllowList(t *testing.T) {
|
||||
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{Err: "permission denied"}})
|
||||
sink.Emit(event.Event{Kind: event.Notice, Code: event.NoticeCodeEmptyFinal})
|
||||
sink.Emit(event.Event{Kind: event.CompactionStarted, Compaction: event.Compaction{Trigger: "auto"}})
|
||||
event.RecordProtocolRecovery(sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryReplaced})
|
||||
event.RecordProtocolRecovery(sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryKind("test")})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
reporter.RecordRecovery(hostaudit.RecoveryMetrics{FailureEvents: 1, ReviewLatencyMsSum: 400, ReviewLatencyCount: 1})
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSinkWritesOnlyWhitelistedContentFreeCounters(t *testing.T) {
|
||||
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
|
||||
Name: secret, Args: secret, Output: secret, Err: "permission denied: " + secret,
|
||||
}})
|
||||
event.RecordProtocolRecovery(sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryReplaced})
|
||||
event.RecordProtocolRecovery(sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryKind("test")})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone, Err: &provider.APIError{
|
||||
Provider: secret, Status: 429, Body: secret, TraceID: secret,
|
||||
}})
|
||||
@@ -85,7 +85,7 @@ func TestSinkWritesOnlyWhitelistedContentFreeCounters(t *testing.T) {
|
||||
"tool_error": "permission",
|
||||
"provider_error": "rate_limit",
|
||||
"cli_exit": "error",
|
||||
"tool_call_reasoning_recovery": "missing_reasoning_retry_replaced_response",
|
||||
"tool_call_reasoning_recovery": "test",
|
||||
} {
|
||||
if got[signal] != bucket {
|
||||
t.Errorf("%s bucket = %q, want %q", signal, got[signal], bucket)
|
||||
|
||||
@@ -284,10 +284,6 @@ type Agent struct {
|
||||
progressWatch atomic.Pointer[ProgressWatch]
|
||||
reasoningLanguage atomic.Value // string: auto|zh|en
|
||||
|
||||
// unwrittenResolve is the resolve watermark a failed state write still owes.
|
||||
// It outlives the conversation, which is why it is not in sessionRuntime.
|
||||
unwrittenResolve unwrittenResolve
|
||||
|
||||
// planRuntime is the plan lifecycle: which phase, under which authority
|
||||
// epoch. Replaceable, so a controller can hand several agents the one
|
||||
// lifecycle they share. Never a permission or sandbox boundary.
|
||||
@@ -806,15 +802,6 @@ func usageSourceOrDefault(source, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// missingReasoningWarnStateFor returns nil when no state dir is configured, so
|
||||
// direct Agent construction keeps the historical once-per-session notice scope.
|
||||
func missingReasoningWarnStateFor(dir string) *missingReasoningWarnState {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil
|
||||
}
|
||||
return newMissingReasoningWarnState(dir)
|
||||
}
|
||||
|
||||
// reserveParentWrite holds write claims for the duration of a parent-agent
|
||||
// write tool call. Returns a no-op release when reservation is not needed
|
||||
// (subagent, read-only, no scheduler, or non-write tool).
|
||||
|
||||
@@ -113,13 +113,6 @@ type Options struct {
|
||||
// disables hook firing.
|
||||
Hooks ToolHooks
|
||||
|
||||
// MissingReasoningWarnStateDir, when non-empty, points at the shared
|
||||
// directory where missing tool-call thinking recovery retries are gated by
|
||||
// opaque provider-configuration fingerprint (#7059). The field name is kept
|
||||
// for source compatibility. Boot always supplies it; direct construction
|
||||
// with an empty value keeps in-memory gating.
|
||||
MissingReasoningWarnStateDir string
|
||||
|
||||
// Jobs is the session's background-job manager (nil disables background tools).
|
||||
Jobs *jobs.Manager
|
||||
// MemoryQueue optionally gives a child agent an explicitly owned live-memory
|
||||
|
||||
@@ -6,11 +6,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
)
|
||||
|
||||
func TestMaybeCompactIgnoresRetryAggregateBelowContextThreshold(t *testing.T) {
|
||||
@@ -66,47 +64,3 @@ func TestMaybeCompactStillTriggersAtLatestContextThreshold(t *testing.T) {
|
||||
t.Fatal("latest prompt at the configured threshold did not trigger compaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningRetryAggregateDoesNotTriggerEarlyCompaction(t *testing.T) {
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{
|
||||
ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
|
||||
Usage: &provider.Usage{PromptTokens: 4000, CompletionTokens: 2, TotalTokens: 4002, CacheMissTokens: 4000, ReasoningTokens: billedThinkingTokens, FinishReason: "tool_calls"},
|
||||
},
|
||||
testutil.Turn{
|
||||
Reasoning: "retry reasoning",
|
||||
ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
|
||||
Usage: &provider.Usage{PromptTokens: 4000, CompletionTokens: 3, TotalTokens: 4003, CacheHitTokens: 4000, ReasoningTokens: 2, FinishReason: "tool_calls"},
|
||||
},
|
||||
testutil.Turn{
|
||||
Text: "done",
|
||||
Usage: &provider.Usage{PromptTokens: 4100, CompletionTokens: 1, TotalTokens: 4101, CacheHitTokens: 4000, CacheMissTokens: 100},
|
||||
},
|
||||
)
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "original task"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("old analysis ", 100)},
|
||||
{Role: provider.RoleUser, Content: "follow-up"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("more old work ", 100)},
|
||||
}}
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), sess, Options{
|
||||
ContextWindow: 10_000,
|
||||
ArchiveDir: testenv.TempDir(t),
|
||||
}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(sink.kinds(event.CompactionStarted)); got != 0 {
|
||||
t.Fatalf("compactions = %d, want none while latest prompt is below threshold", got)
|
||||
}
|
||||
usageEvents := sink.kinds(event.Usage)
|
||||
if len(usageEvents) == 0 || usageEvents[0].Usage == nil {
|
||||
t.Fatalf("missing recovery usage event: %+v", usageEvents)
|
||||
}
|
||||
if got := usageEvents[0].Usage; got.PromptTokens != 8000 || got.ContextPromptTokens != 4000 {
|
||||
t.Fatalf("recovery usage = %+v, want aggregate prompt 8000 and latest context prompt 4000", got)
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
//go:build live
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/model/openai"
|
||||
)
|
||||
|
||||
// TestLiveDeepSeekFlashMissingReasoningRecovery exercises the production agent
|
||||
// against DeepSeek's official API while a local proxy removes reasoning_content
|
||||
// from one or two real tool-call responses. It is credential-gated and excluded
|
||||
// from ordinary CI; response text, tool arguments, and credentials are never
|
||||
// logged or written to disk.
|
||||
func TestLiveDeepSeekFlashMissingReasoningRecovery(t *testing.T) {
|
||||
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if key == "" {
|
||||
t.Skip("DEEPSEEK_API_KEY not set")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stripResponses int32
|
||||
wantRecovered int
|
||||
wantFallback int
|
||||
}{
|
||||
{name: "transient", stripResponses: 1, wantRecovered: 1},
|
||||
{name: "persistent", stripResponses: 2, wantFallback: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
result := runLiveDeepSeekRecoveryScenario(t, key, tc.stripResponses, attempt)
|
||||
t.Logf("mode=%s attempt=%d upstream_requests=%d stripped_fields=%d retry_request_identical=%t executions=%d tool_turns=%d warnings=%d retry_attempts=%d recovered=%d replaced=%d fallbacks=%d",
|
||||
tc.name, attempt, result.requests, result.strippedFields, result.identicalRetry,
|
||||
result.executions, result.toolTurns, result.warnings, result.retryAttempts,
|
||||
result.recovered, result.replaced, result.fallbacks)
|
||||
if result.strippedFields == 0 || result.replaced != 0 {
|
||||
continue // provider chose a different response shape; retry a bounded fresh scenario
|
||||
}
|
||||
if result.executions != 1 || result.toolTurns != 1 {
|
||||
t.Fatalf("tool execution/session turns = %d/%d, want 1/1", result.executions, result.toolTurns)
|
||||
}
|
||||
if result.warnings != 0 {
|
||||
t.Fatalf("user-visible protocol warnings = %d, want 0", result.warnings)
|
||||
}
|
||||
if result.retryAttempts == 0 {
|
||||
if result.recovered != 0 || result.fallbacks != 1 {
|
||||
t.Fatalf("no-retry fallback outcomes recovered/fallbacks = %d/%d, want 0/1",
|
||||
result.recovered, result.fallbacks)
|
||||
}
|
||||
continue // visible text made retry unsafe; fallback was correct, seek the requested retry shape
|
||||
}
|
||||
if !result.identicalRetry {
|
||||
t.Fatal("missing-reasoning recovery changed the provider request")
|
||||
}
|
||||
if result.retryAttempts != 1 || result.recovered != tc.wantRecovered || result.fallbacks != tc.wantFallback {
|
||||
t.Fatalf("recovery outcomes attempts/recovered/fallbacks = %d/%d/%d, want 1/%d/%d",
|
||||
result.retryAttempts, result.recovered, result.fallbacks, tc.wantRecovered, tc.wantFallback)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("official API did not produce the requested live recovery shape in three bounded attempts")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type liveRecoveryResult struct {
|
||||
requests, strippedFields int
|
||||
executions, toolTurns, warnings int
|
||||
retryAttempts, recovered, replaced int
|
||||
fallbacks int
|
||||
identicalRetry bool
|
||||
}
|
||||
|
||||
func runLiveDeepSeekRecoveryScenario(t *testing.T, key string, stripResponses int32, attempt int) liveRecoveryResult {
|
||||
t.Helper()
|
||||
proxy := &liveReasoningStripProxy{stripResponses: stripResponses}
|
||||
server := httptest.NewServer(proxy)
|
||||
defer server.Close()
|
||||
|
||||
prov, err := openai.New(provider.Config{
|
||||
Name: "deepseek-live-recovery",
|
||||
BaseURL: server.URL,
|
||||
Model: "deepseek-v4-flash",
|
||||
APIKey: key,
|
||||
Extra: map[string]any{
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"reasoning_protocol": "deepseek",
|
||||
"thinking": "enabled",
|
||||
"effort": "low",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create live provider: %v", err)
|
||||
}
|
||||
|
||||
var executions atomic.Int32
|
||||
registry := tool.NewRegistry()
|
||||
registry.Add(liveRecoveryEchoTool{executions: &executions})
|
||||
sink := &recordSink{}
|
||||
a := New(prov, registry, NewSession("You are a concise tool-using assistant."), Options{
|
||||
MaxSteps: 4,
|
||||
MissingReasoningWarnStateDir: testenv.TempDir(t),
|
||||
}, sink)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Run(ctx, fmt.Sprintf("Live recovery probe %d: call echo exactly once, then report that it completed.", attempt)); err != nil {
|
||||
t.Fatalf("live agent run: %v", err)
|
||||
}
|
||||
|
||||
result := liveRecoveryResult{executions: int(executions.Load())}
|
||||
for _, msg := range a.Session().Snapshot() {
|
||||
if msg.Role == provider.RoleAssistant && len(msg.ToolCalls) > 0 {
|
||||
result.toolTurns++
|
||||
}
|
||||
}
|
||||
for _, notice := range sink.kinds(event.Notice) {
|
||||
text := strings.ToLower(notice.Text + " " + notice.Detail)
|
||||
if strings.Contains(text, "reasoning_content") || strings.Contains(text, "replayable thinking") {
|
||||
result.warnings++
|
||||
}
|
||||
}
|
||||
result.retryAttempts = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
|
||||
result.recovered = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryRecovered)
|
||||
result.replaced = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryReplaced)
|
||||
result.fallbacks = sink.recoveryCount(event.ProtocolRecoveryMissingReasoningFallback)
|
||||
result.requests = int(proxy.requests.Load())
|
||||
result.strippedFields = int(proxy.strippedFields.Load())
|
||||
proxy.mu.Lock()
|
||||
result.identicalRetry = len(proxy.firstBody) > 0 && bytes.Equal(proxy.firstBody, proxy.retryBody)
|
||||
proxy.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
type liveRecoveryEchoTool struct{ executions *atomic.Int32 }
|
||||
|
||||
func (t liveRecoveryEchoTool) Name() string { return "echo" }
|
||||
func (t liveRecoveryEchoTool) Description() string { return "Return a fixed live-test marker." }
|
||||
func (t liveRecoveryEchoTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)
|
||||
}
|
||||
func (t liveRecoveryEchoTool) ReadOnly() bool { return true }
|
||||
func (t liveRecoveryEchoTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
t.executions.Add(1)
|
||||
return "live recovery marker", nil
|
||||
}
|
||||
|
||||
type liveReasoningStripProxy struct {
|
||||
stripResponses int32
|
||||
requests atomic.Int32
|
||||
toolResponses atomic.Int32
|
||||
strippedFields atomic.Int32
|
||||
mu sync.Mutex
|
||||
firstRequestNo int32
|
||||
firstBody []byte
|
||||
retryBody []byte
|
||||
}
|
||||
|
||||
func (p *liveReasoningStripProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
requestNo := p.requests.Add(1)
|
||||
p.mu.Lock()
|
||||
if p.firstRequestNo != 0 && requestNo == p.firstRequestNo+1 {
|
||||
p.retryBody = append([]byte(nil), body...)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
upstream, err := http.NewRequestWithContext(r.Context(), http.MethodPost,
|
||||
"https://api.deepseek.com/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "create upstream request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
upstream.Header.Set("Authorization", r.Header.Get("Authorization"))
|
||||
upstream.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(upstream)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read upstream response", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK && bytes.Contains(responseBody, []byte(`"tool_calls"`)) {
|
||||
toolResponse := p.toolResponses.Add(1)
|
||||
if toolResponse <= p.stripResponses {
|
||||
if toolResponse == 1 {
|
||||
p.mu.Lock()
|
||||
p.firstRequestNo = requestNo
|
||||
p.firstBody = append([]byte(nil), body...)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
responseBody = p.stripReasoning(responseBody)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(responseBody)
|
||||
}
|
||||
|
||||
func (p *liveReasoningStripProxy) stripReasoning(body []byte) []byte {
|
||||
lines := bytes.Split(body, []byte("\n"))
|
||||
for i, line := range lines {
|
||||
if !bytes.HasPrefix(line, []byte("data: ")) {
|
||||
continue
|
||||
}
|
||||
data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data: ")))
|
||||
if bytes.Equal(data, []byte("[DONE]")) {
|
||||
continue
|
||||
}
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(data, &payload) != nil {
|
||||
continue
|
||||
}
|
||||
choices, _ := payload["choices"].([]any)
|
||||
changed := false
|
||||
for _, rawChoice := range choices {
|
||||
choice, _ := rawChoice.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
if _, ok := delta["reasoning_content"]; ok {
|
||||
delete(delta, "reasoning_content")
|
||||
p.strippedFields.Add(1)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err == nil {
|
||||
lines[i] = append([]byte("data: "), encoded...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes.Join(lines, []byte("\n"))
|
||||
}
|
||||
@@ -4,15 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
@@ -27,59 +23,6 @@ type toolCallReasoningRequiredProvider struct {
|
||||
|
||||
func (p toolCallReasoningRequiredProvider) RequiresToolCallReasoning() bool { return true }
|
||||
|
||||
type configuredToolCallReasoningProvider struct {
|
||||
*testutil.MockProvider
|
||||
identity string
|
||||
}
|
||||
|
||||
type cancelMissingReasoningRetryProvider struct {
|
||||
calls atomic.Int32
|
||||
retryUsageSent chan struct{}
|
||||
}
|
||||
|
||||
func (p *cancelMissingReasoningRetryProvider) Name() string { return "deepseek-cancel-retry" }
|
||||
func (p *cancelMissingReasoningRetryProvider) RequiresToolCallReasoning() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *cancelMissingReasoningRetryProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
call := p.calls.Add(1)
|
||||
ch := make(chan provider.Chunk)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
send := func(chunk provider.Chunk) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case ch <- chunk:
|
||||
return true
|
||||
}
|
||||
}
|
||||
if call == 1 {
|
||||
toolCall := provider.ToolCall{ID: "discarded", Name: "echo", Arguments: `{"text":"must not run"}`}
|
||||
if !send(provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &toolCall}) {
|
||||
return
|
||||
}
|
||||
if !send(provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, ReasoningTokens: billedThinkingTokens}}) {
|
||||
return
|
||||
}
|
||||
send(provider.Chunk{Type: provider.ChunkDone})
|
||||
return
|
||||
}
|
||||
if !send(provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 1, TotalTokens: 11}}) {
|
||||
return
|
||||
}
|
||||
close(p.retryUsageSent)
|
||||
<-ctx.Done()
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p configuredToolCallReasoningProvider) RequiresToolCallReasoning() bool { return true }
|
||||
func (p configuredToolCallReasoningProvider) MissingToolCallReasoningWarningIdentity() string {
|
||||
return p.identity
|
||||
}
|
||||
|
||||
func echoRegistry() *tool.Registry {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(echoTool{})
|
||||
@@ -621,7 +564,7 @@ func TestRunRecoveryKeepsCompletedToolPairAndSummarizesChangedFile(t *testing.T)
|
||||
// cleanly (the repair is a no-op on well-formed histories).
|
||||
func TestRunWellFormedToolLoopRoundTrips(t *testing.T) {
|
||||
mp := testutil.NewMock("m",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}},
|
||||
testutil.Turn{Text: "all set"},
|
||||
)
|
||||
a := New(mp, echoRegistry(), sessionstore.NewSession(""), Options{}, event.Discard)
|
||||
@@ -640,405 +583,6 @@ func TestRunWellFormedToolLoopRoundTrips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A provider without the DeepSeek tool-call reasoning policy must keep the
|
||||
// ordinary two-call tool loop even when its tool-call turn has no reasoning.
|
||||
func TestRunNonDeepSeekMissingToolCallReasoningDoesNotRetry(t *testing.T) {
|
||||
mp := testutil.NewMock("openai",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{Text: "all set"},
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(mp, echoRegistry(), sessionstore.NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := mp.CallCount(); got != 2 {
|
||||
t.Fatalf("provider calls = %d, want tool turn + final turn without recovery retry", got)
|
||||
}
|
||||
if got := len(sink.kinds(event.ToolDispatch)); got != 1 {
|
||||
t.Fatalf("tool dispatches = %d, want one", got)
|
||||
}
|
||||
sink.mu.Lock()
|
||||
recovery := append([]event.ProtocolRecoveryAudit(nil), sink.recovery...)
|
||||
sink.mu.Unlock()
|
||||
if len(recovery) != 0 {
|
||||
t.Fatalf("non-DeepSeek provider emitted protocol recovery audits: %+v", recovery)
|
||||
}
|
||||
}
|
||||
|
||||
// A one-off missing reasoning_content response is replaced before any tool
|
||||
// executes. The retry reuses identical input, its usage is accounted for, and
|
||||
// no provider-protocol warning or duplicate tool card reaches the user.
|
||||
func TestRunSilentlyRecoversMissingToolCallReasoning(t *testing.T) {
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{
|
||||
ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
|
||||
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, CacheMissTokens: 10, ReasoningTokens: billedThinkingTokens, FinishReason: "tool_calls"},
|
||||
},
|
||||
testutil.Turn{
|
||||
Reasoning: "retry reasoning",
|
||||
ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
|
||||
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 3, TotalTokens: 13, CacheHitTokens: 10, ReasoningTokens: 2, FinishReason: "tool_calls"},
|
||||
},
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), sessionstore.NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var savedToolTurns int
|
||||
var savedReasoning string
|
||||
for _, m := range a.Session().Messages {
|
||||
if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
|
||||
savedToolTurns++
|
||||
savedReasoning = m.ReasoningContent
|
||||
}
|
||||
}
|
||||
if savedToolTurns != 1 || savedReasoning != "retry reasoning" {
|
||||
t.Fatalf("saved tool turns = %d reasoning = %q, want one recovered turn: %+v", savedToolTurns, savedReasoning, a.Session().Messages)
|
||||
}
|
||||
if mp.CallCount() != 3 {
|
||||
t.Fatalf("provider calls = %d, want malformed + retry + final", mp.CallCount())
|
||||
}
|
||||
requests := mp.Requests()
|
||||
if len(requests) < 2 || !reflect.DeepEqual(requests[0], requests[1]) {
|
||||
t.Fatalf("protocol retry changed provider-visible request:\nfirst=%+v\nretry=%+v", requests[0], requests[1])
|
||||
}
|
||||
for _, e := range sink.kinds(event.Notice) {
|
||||
if strings.Contains(e.Text, "reasoning") || strings.Contains(e.Detail, "reasoning") {
|
||||
t.Fatalf("provider protocol leaked into user notice: %+v", e)
|
||||
}
|
||||
}
|
||||
if got := len(sink.kinds(event.ToolDispatch)); got != 1 {
|
||||
t.Fatalf("tool dispatches = %d, want one adopted call", got)
|
||||
}
|
||||
usageEvents := sink.kinds(event.Usage)
|
||||
if len(usageEvents) == 0 || usageEvents[0].Usage == nil || usageEvents[0].Usage.TotalTokens != 25 || usageEvents[0].Usage.CacheHitTokens != 10 || usageEvents[0].Usage.CacheMissTokens != 10 {
|
||||
t.Fatalf("recovery usage was not merged truthfully: %+v", usageEvents)
|
||||
}
|
||||
if sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted) != 1 || sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryRecovered) != 1 {
|
||||
t.Fatalf("unexpected recovery audit: %+v", sink.recovery)
|
||||
}
|
||||
}
|
||||
|
||||
// An exact recovery replay may choose a normal final answer instead of
|
||||
// repeating the original tool call. The replacement is authoritative because
|
||||
// no tool has run yet: discard the speculative call, persist only the final
|
||||
// response, and classify the outcome separately from recovered reasoning.
|
||||
func TestMissingReasoningRecoveryAdoptsRetryWithoutToolCall(t *testing.T) {
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{
|
||||
ToolCalls: []provider.ToolCall{{ID: "discarded", Name: "echo", Arguments: `{"text":"must not run"}`}},
|
||||
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, ReasoningTokens: billedThinkingTokens, FinishReason: "tool_calls"},
|
||||
},
|
||||
testutil.Turn{
|
||||
Text: "completed without a tool",
|
||||
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 3, TotalTokens: 13, FinishReason: "stop"},
|
||||
},
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), sessionstore.NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if mp.CallCount() != 2 {
|
||||
t.Fatalf("provider calls = %d, want malformed + replacement", mp.CallCount())
|
||||
}
|
||||
var toolTurns, toolResults int
|
||||
for _, message := range a.Session().Messages {
|
||||
if message.Role == provider.RoleAssistant && len(message.ToolCalls) > 0 {
|
||||
toolTurns++
|
||||
}
|
||||
if message.Role == provider.RoleTool {
|
||||
toolResults++
|
||||
}
|
||||
}
|
||||
if toolTurns != 0 || toolResults != 0 {
|
||||
t.Fatalf("discarded tool response reached session: turns=%d results=%d session=%+v", toolTurns, toolResults, a.Session().Messages)
|
||||
}
|
||||
last := a.Session().Messages[len(a.Session().Messages)-1]
|
||||
if last.Role != provider.RoleAssistant || last.Content != "completed without a tool" {
|
||||
t.Fatalf("replacement response not adopted: %+v", last)
|
||||
}
|
||||
if got := len(sink.kinds(event.ToolDispatch)); got != 0 {
|
||||
t.Fatalf("discarded tool dispatches = %d, want 0", got)
|
||||
}
|
||||
usageEvents := sink.kinds(event.Usage)
|
||||
if len(usageEvents) == 0 || usageEvents[0].Usage == nil || usageEvents[0].Usage.TotalTokens != 25 {
|
||||
t.Fatalf("replacement usage was not merged truthfully: %+v", usageEvents)
|
||||
}
|
||||
if sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted) != 1 ||
|
||||
sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryReplaced) != 1 ||
|
||||
sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryRecovered) != 0 ||
|
||||
sink.recoveryCount(event.ProtocolRecoveryMissingReasoningFallback) != 0 {
|
||||
t.Fatalf("unexpected recovery classification: %+v", sink.recovery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningRecoveryFailureFallsBackBeforeToolExecution(t *testing.T) {
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{
|
||||
ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
|
||||
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, ReasoningTokens: billedThinkingTokens, FinishReason: "tool_calls"},
|
||||
},
|
||||
testutil.Turn{
|
||||
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 1, TotalTokens: 11},
|
||||
ChunkError: errors.New("recovery stream failed"),
|
||||
},
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), sessionstore.NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run should keep the complete first response, got %v", err)
|
||||
}
|
||||
var toolResults int
|
||||
for _, message := range a.Session().Messages {
|
||||
if message.Role == provider.RoleTool && message.ToolCallID == "c1" {
|
||||
toolResults++
|
||||
}
|
||||
}
|
||||
if toolResults != 1 {
|
||||
t.Fatalf("tool results = %d, want the original call executed once", toolResults)
|
||||
}
|
||||
usageEvents := sink.kinds(event.Usage)
|
||||
if len(usageEvents) == 0 || usageEvents[0].Usage == nil || usageEvents[0].Usage.TotalTokens != 23 {
|
||||
t.Fatalf("failed recovery usage was not accounted for: %+v", usageEvents)
|
||||
}
|
||||
if sink.recoveryCount(event.ProtocolRecoveryMissingReasoningFallback) != 1 {
|
||||
t.Fatalf("fallback audit missing: %+v", sink.recovery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningRecoveryCancellationAccountsBothAttempts(t *testing.T) {
|
||||
prov := &cancelMissingReasoningRetryProvider{retryUsageSent: make(chan struct{})}
|
||||
sink := &recordSink{}
|
||||
a := New(prov, echoRegistry(), sessionstore.NewSession(""), Options{}, sink)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx, "go") }()
|
||||
|
||||
select {
|
||||
case <-prov.retryUsageSent:
|
||||
cancel()
|
||||
case <-time.After(time.Second):
|
||||
cancel()
|
||||
t.Fatal("timed out waiting for the recovery retry usage")
|
||||
}
|
||||
if err := <-done; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run error = %v, want context cancellation", err)
|
||||
}
|
||||
if got := prov.calls.Load(); got != 2 {
|
||||
t.Fatalf("provider calls = %d, want malformed response plus recovery retry", got)
|
||||
}
|
||||
if got := len(sink.kinds(event.ToolDispatch)); got != 0 {
|
||||
t.Fatalf("discarded tool dispatches = %d, want 0", got)
|
||||
}
|
||||
usages := sink.kinds(event.Usage)
|
||||
if len(usages) != 1 || usages[0].Usage == nil || usages[0].Usage.TotalTokens != 23 || usages[0].Usage.FinishReason != "interrupted" {
|
||||
t.Fatalf("recovery cancellation usage = %+v, want one merged interrupted total of 23", usages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionRearmsInMemoryMissingReasoningRecovery(t *testing.T) {
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1r", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{Text: "done"},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c2", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c2r", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{Text: "done again"},
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), sessionstore.NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("first Run: %v", err)
|
||||
}
|
||||
a.SetSession(sessionstore.NewSession(""))
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("second Run: %v", err)
|
||||
}
|
||||
if got := sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 2 {
|
||||
t.Fatalf("recovery retries across two sessions = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A shared state dir turns the old warning cooldown into a cross-process retry
|
||||
// circuit breaker. The first process retries once; a fresh process immediately
|
||||
// uses the empty-key fallback without doubling the request.
|
||||
func TestMissingReasoningRecoveryRateLimitsAcrossProcesses(t *testing.T) {
|
||||
stateDir := testenv.TempDir(t)
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1r", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
sink1 := &recordSink{}
|
||||
a1 := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink1)
|
||||
if err := a1.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("first Run: %v", err)
|
||||
}
|
||||
if got := sink1.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 1 {
|
||||
t.Fatalf("first process recovery retries = %d, want 1", got)
|
||||
}
|
||||
|
||||
mp2 := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c2", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{Text: "done again"},
|
||||
)
|
||||
sink2 := &recordSink{}
|
||||
a2 := New(toolCallReasoningRequiredProvider{mp2}, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink2)
|
||||
if err := a2.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("second process Run: %v", err)
|
||||
}
|
||||
if got := sink2.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 0 {
|
||||
t.Fatalf("fresh process recovery retries = %d, want 0", got)
|
||||
}
|
||||
if got := sink2.recoveryCount(event.ProtocolRecoveryMissingReasoningRetrySuppressed); got != 1 {
|
||||
t.Fatalf("fresh process suppressed retries = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningRecoverySeparatesProviderConfigurations(t *testing.T) {
|
||||
stateDir := testenv.TempDir(t)
|
||||
retryCount := func(identity string) int {
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1r", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}},
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(configuredToolCallReasoningProvider{MockProvider: mp, identity: identity}, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run(%q): %v", identity, err)
|
||||
}
|
||||
return sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
|
||||
}
|
||||
if got := retryCount("openai\x00endpoint-a\x00deepseek-v4-pro"); got != 1 {
|
||||
t.Fatalf("first configuration retries = %d, want 1", got)
|
||||
}
|
||||
if got := retryCount("openai\x00endpoint-a\x00deepseek-v4-pro"); got != 0 {
|
||||
t.Fatalf("same configuration retries = %d, want 0", got)
|
||||
}
|
||||
if got := retryCount("openai\x00endpoint-b\x00deepseek-v4-pro"); got != 1 {
|
||||
t.Fatalf("changed endpoint retries = %d, want 1", got)
|
||||
}
|
||||
if got := retryCount("openai\x00endpoint-a\x00deepseek-v4-flash"); got != 1 {
|
||||
t.Fatalf("changed model retries = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreeHealthyToolCallReasoningTurnsRearmFutureRegression(t *testing.T) {
|
||||
stateDir := testenv.TempDir(t)
|
||||
run := func(turns ...testutil.Turn) int {
|
||||
mp := testutil.NewMock("deepseek-proxy", turns...)
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
return sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
|
||||
}
|
||||
missing := testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}, Usage: &provider.Usage{ReasoningTokens: billedThinkingTokens}}
|
||||
healthy := testutil.Turn{Reasoning: "call echo", ToolCalls: []provider.ToolCall{{ID: "c2", Name: "echo", Arguments: `{"text":"hi"}`}}}
|
||||
if got := run(missing, missing, testutil.Turn{Text: "done"}); got != 1 {
|
||||
t.Fatalf("first incident retries = %d, want 1", got)
|
||||
}
|
||||
for healthyTurn := 1; healthyTurn <= missingReasoningHealthyResolveStreak; healthyTurn++ {
|
||||
if got := run(healthy, testutil.Turn{Text: "done"}); got != 0 {
|
||||
t.Fatalf("healthy turn %d retries = %d, want 0", healthyTurn, got)
|
||||
}
|
||||
}
|
||||
if got := run(missing, missing, testutil.Turn{Text: "done"}); got != 1 {
|
||||
t.Fatalf("post-recovery regression retries = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthyToolCallReasoningStreakWorksWithinOneAgentAndResetsOnMissing(t *testing.T) {
|
||||
stateDir := testenv.TempDir(t)
|
||||
prov := toolCallReasoningRequiredProvider{testutil.NewMock("deepseek-proxy")}
|
||||
a := New(prov, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, event.Discard)
|
||||
calls := []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}
|
||||
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostReplay {
|
||||
t.Fatalf("initial observation = %v, want %v", got, reasoningLostReplay)
|
||||
}
|
||||
for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ {
|
||||
a.observeMissingToolCallReasoning(calls, "healthy reasoning", billedThinkingTokens)
|
||||
}
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostNoReplay {
|
||||
t.Fatalf("missing reset = %v, want %v", got, reasoningLostNoReplay)
|
||||
}
|
||||
for healthy := 1; healthy <= missingReasoningHealthyResolveStreak; healthy++ {
|
||||
a.observeMissingToolCallReasoning(calls, "healthy reasoning", billedThinkingTokens)
|
||||
}
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostReplay {
|
||||
t.Fatalf("post-recovery observation = %v, want %v", got, reasoningLostReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningRecoveryIOFailureStillSuppressesLocally(t *testing.T) {
|
||||
statePath := filepath.Join(testenv.TempDir(t), "not-a-directory")
|
||||
if err := os.WriteFile(statePath, []byte("occupied"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prov := toolCallReasoningRequiredProvider{testutil.NewMock("deepseek-proxy")}
|
||||
a := New(prov, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: statePath}, event.Discard)
|
||||
calls := []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}
|
||||
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostReplay {
|
||||
t.Fatalf("initial observation = %v, want %v", got, reasoningLostReplay)
|
||||
}
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostNoReplay {
|
||||
t.Fatalf("repeated observation = %v, want %v", got, reasoningLostNoReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthyToolCallReasoningRetriesTransientStateWriteFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod permissions are not portable to Windows")
|
||||
}
|
||||
stateDir := testenv.TempDir(t)
|
||||
prov := toolCallReasoningRequiredProvider{testutil.NewMock("deepseek-proxy")}
|
||||
a := New(prov, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, event.Discard)
|
||||
calls := []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}
|
||||
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostReplay {
|
||||
t.Fatalf("initial observation = %v, want %v", got, reasoningLostReplay)
|
||||
}
|
||||
if err := os.Chmod(stateDir, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissionsRestored := false
|
||||
defer func() {
|
||||
if !permissionsRestored {
|
||||
_ = os.Chmod(stateDir, 0o700)
|
||||
}
|
||||
}()
|
||||
if got := a.observeMissingToolCallReasoning(calls, "healthy reasoning", billedThinkingTokens); got != reasoningIntact {
|
||||
t.Fatalf("healthy observation = %v, want %v", got, reasoningIntact)
|
||||
}
|
||||
if err := os.Chmod(stateDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissionsRestored = true
|
||||
for healthy := range missingReasoningHealthyResolveStreak - 1 {
|
||||
if got := a.observeMissingToolCallReasoning(calls, "healthy reasoning", billedThinkingTokens); got != reasoningIntact {
|
||||
t.Fatalf("healthy recovery observation %d = %v, want %v", healthy+1, got, reasoningIntact)
|
||||
}
|
||||
}
|
||||
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostReplay {
|
||||
t.Fatalf("post-recovery observation = %v, want %v", got, reasoningLostReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesOriginalRequiredToolCallReasoningAcrossHook(t *testing.T) {
|
||||
mp := testutil.NewMock("deepseek-proxy",
|
||||
testutil.Turn{
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// missingReasoningWatch is this conversation's live view of one incident. Only
|
||||
// observeMissingToolCallReasoning moves these, and they belong to the session:
|
||||
// replacing the conversation ends the incident being watched.
|
||||
type missingReasoningWatch struct {
|
||||
active bool // gates the one automatic retry, not a user-visible warning
|
||||
stateRecorded bool // avoids a file transaction on every healthy tool-call turn
|
||||
healthyStreak int // anti-flapping when no cross-process state dir is configured
|
||||
}
|
||||
|
||||
// unwrittenResolve is a resolve whose state write failed. It answers to the
|
||||
// provider configuration rather than to any conversation, so it sits beside
|
||||
// missingReasoningWarnState instead of in sessionRuntime — a new session
|
||||
// inherits the debt because the retry it owes is still owed.
|
||||
type unwrittenResolve struct {
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// reasoningObservation classifies one thinking-mode tool-call turn. The states
|
||||
// are exclusive so no caller can act on an impossible pair such as "not missing
|
||||
// but replay it".
|
||||
type reasoningObservation int
|
||||
|
||||
const (
|
||||
// reasoningIntact carried thinking content, or the provider never promises it.
|
||||
reasoningIntact reasoningObservation = iota
|
||||
// reasoningModelSilent was billed no thinking tokens: the model chose not to
|
||||
// think this round, which is ordinary between tool calls and not a defect.
|
||||
reasoningModelSilent
|
||||
// reasoningLostNoReplay was billed thinking tokens whose text never arrived,
|
||||
// with this incident's single replay already spent.
|
||||
reasoningLostNoReplay
|
||||
// reasoningLostReplay is reasoningLostNoReplay with the replay still available.
|
||||
reasoningLostReplay
|
||||
)
|
||||
|
||||
// missing reports whether provider-issued thinking content went absent.
|
||||
func (o reasoningObservation) missing() bool {
|
||||
return o == reasoningLostNoReplay || o == reasoningLostReplay
|
||||
}
|
||||
|
||||
func (o reasoningObservation) String() string {
|
||||
switch o {
|
||||
case reasoningIntact:
|
||||
return "intact"
|
||||
case reasoningModelSilent:
|
||||
return "model-silent"
|
||||
case reasoningLostNoReplay:
|
||||
return "lost-no-replay"
|
||||
case reasoningLostReplay:
|
||||
return "lost-replay"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// classifyTurnReasoning classifies one completed attempt and records the audit
|
||||
// for a silent model, which is observed but never acted on. Call it before
|
||||
// finalizeSamplingUsage: the classifier needs the single attempt's billing, not
|
||||
// the multi-attempt aggregate.
|
||||
func (a *Agent) classifyTurnReasoning(t streamedTurn) reasoningObservation {
|
||||
observed := a.observeMissingToolCallReasoning(t.calls, t.reasoning, usageReasoningTokens(t.usage))
|
||||
if observed == reasoningModelSilent {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningModelSilent})
|
||||
}
|
||||
return observed
|
||||
}
|
||||
|
||||
// observeMissingToolCallReasoning classifies a thinking-mode tool-call turn and
|
||||
// claims the one silent replay its active incident allows. Billed thinking
|
||||
// tokens with no text mean the value was lost in transit and a replay can
|
||||
// recover it; zero billed tokens mean the model stayed silent, where replaying
|
||||
// an identical request costs a full prompt to buy nothing (#6259, #7059).
|
||||
func (a *Agent) observeMissingToolCallReasoning(calls []provider.ToolCall, reasoning string, reasoningTokens int) reasoningObservation {
|
||||
if len(calls) == 0 || !provider.WarnOnMissingToolCallReasoning(a.svc.prov) {
|
||||
return reasoningIntact
|
||||
}
|
||||
if strings.TrimSpace(reasoning) == "" && reasoningTokens <= 0 {
|
||||
return reasoningModelSilent
|
||||
}
|
||||
fingerprint := provider.MissingToolCallReasoningWarningFingerprint(a.svc.prov)
|
||||
observedAt := time.Now()
|
||||
if strings.TrimSpace(reasoning) != "" {
|
||||
if a.svc.warnState == nil {
|
||||
if a.sess.missingReasoning.active {
|
||||
a.sess.missingReasoning.healthyStreak++
|
||||
if a.sess.missingReasoning.healthyStreak >= missingReasoningHealthyResolveStreak {
|
||||
a.sess.missingReasoning.active = false
|
||||
a.sess.missingReasoning.healthyStreak = 0
|
||||
}
|
||||
}
|
||||
return reasoningIntact
|
||||
}
|
||||
shouldResolve := !a.sess.missingReasoning.stateRecorded || a.sess.missingReasoning.active
|
||||
if shouldResolve {
|
||||
result := missingReasoningResolveResult{Recorded: true, Resolved: true}
|
||||
if pending := a.unwrittenResolve.at; !pending.IsZero() {
|
||||
result = a.svc.warnState.resolveAt(fingerprint, pending)
|
||||
if result.Recorded {
|
||||
a.unwrittenResolve.at = time.Time{}
|
||||
}
|
||||
}
|
||||
if result.Recorded {
|
||||
result = a.svc.warnState.resolveAt(fingerprint, observedAt)
|
||||
}
|
||||
if !result.Recorded {
|
||||
if observedAt.After(a.unwrittenResolve.at) {
|
||||
a.unwrittenResolve.at = observedAt
|
||||
}
|
||||
a.sess.missingReasoning.active = true
|
||||
a.sess.missingReasoning.stateRecorded = false
|
||||
} else if result.Resolved {
|
||||
a.sess.missingReasoning.active = false
|
||||
a.sess.missingReasoning.stateRecorded = true
|
||||
} else {
|
||||
a.sess.missingReasoning.active = true
|
||||
a.sess.missingReasoning.stateRecorded = false
|
||||
}
|
||||
}
|
||||
return reasoningIntact
|
||||
}
|
||||
a.sess.missingReasoning.healthyStreak = 0
|
||||
if s := a.svc.warnState; s != nil {
|
||||
stateReady := true
|
||||
alreadyActive := a.sess.missingReasoning.active
|
||||
if pending := a.unwrittenResolve.at; !pending.IsZero() {
|
||||
result := s.resolveAt(fingerprint, pending)
|
||||
stateReady = result.Recorded
|
||||
if result.Recorded {
|
||||
a.unwrittenResolve.at = time.Time{}
|
||||
if result.Resolved {
|
||||
alreadyActive = false
|
||||
a.sess.missingReasoning.active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
claimed := stateReady && s.claimAt(fingerprint, observedAt)
|
||||
if !claimed || alreadyActive {
|
||||
// This exact configuration already attempted recovery for the active
|
||||
// incident, so keep the empty-key fallback without doubling requests.
|
||||
a.sess.missingReasoning.active = true
|
||||
a.sess.missingReasoning.stateRecorded = true
|
||||
return reasoningLostNoReplay
|
||||
}
|
||||
if !stateReady {
|
||||
a.sess.missingReasoning.stateRecorded = false
|
||||
}
|
||||
} else if a.sess.missingReasoning.active {
|
||||
return reasoningLostNoReplay
|
||||
}
|
||||
a.sess.missingReasoning.active = true
|
||||
if a.unwrittenResolve.at.IsZero() {
|
||||
a.sess.missingReasoning.stateRecorded = true
|
||||
}
|
||||
return reasoningLostReplay
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
)
|
||||
|
||||
// billedThinkingTokens stands in for a provider that charged for thinking whose
|
||||
// text never arrived — the only shape a replay can actually recover.
|
||||
const billedThinkingTokens = 64
|
||||
|
||||
// TestModelSilentToolCallReasoningIsNotReplayed pins the cost fix: a tool-call
|
||||
// turn billed zero thinking tokens is ordinary model behaviour between tools,
|
||||
// so it must neither replay the request nor consume the incident's one replay.
|
||||
func TestModelSilentToolCallReasoningIsNotReplayed(t *testing.T) {
|
||||
prov := toolCallReasoningRequiredProvider{testutil.NewMock("deepseek-proxy")}
|
||||
a := New(prov, echoRegistry(), sessionstore.NewSession(""), Options{MissingReasoningWarnStateDir: testenv.TempDir(t)}, event.Discard)
|
||||
calls := []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}
|
||||
|
||||
for round := 1; round <= 3; round++ {
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", 0); got != reasoningModelSilent {
|
||||
t.Fatalf("silent round %d = %v, want %v", round, got, reasoningModelSilent)
|
||||
}
|
||||
}
|
||||
if got := a.observeMissingToolCallReasoning(calls, "", billedThinkingTokens); got != reasoningLostReplay {
|
||||
t.Fatalf("lost field after silent rounds = %v, want %v", got, reasoningLostReplay)
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
package agent
|
||||
|
||||
// reasoning_warn_state.go rate-limits missing tool-call thinking recovery
|
||||
// attempts across sessions and processes (#7059). Legacy warning-oriented Go
|
||||
// names and JSON fields intentionally remain unchanged for upgrade and
|
||||
// cross-version compatibility.
|
||||
//
|
||||
// DeepSeek's thinking-mode contract requires provider-issued thinking content
|
||||
// to accompany tool calls and be replayed on later requests. Missing content is
|
||||
// therefore a compatibility incident, not a permanent provider characteristic.
|
||||
// State is keyed by an opaque configuration fingerprint, expires after a bounded
|
||||
// cooldown, and is cleared after three consecutive healthy tool-call turns so
|
||||
// a future isolated regression gets one fresh silent retry without flapping.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/base/fileutil"
|
||||
)
|
||||
|
||||
const missingReasoningWarnStateFilename = "tool-call-reasoning-warning.json"
|
||||
|
||||
const (
|
||||
missingReasoningWarnStateLockFilename = "tool-call-reasoning-warning.lock"
|
||||
missingReasoningWarnStateVersion = 2
|
||||
missingReasoningWarnStateCooldown = 24 * time.Hour
|
||||
missingReasoningWarnStateMaxIncidents = 256
|
||||
missingReasoningHealthyResolveStreak = 3
|
||||
// Recovery bookkeeping must never make a turn wait indefinitely behind
|
||||
// another process. On contention or I/O failure, callers fail open and allow
|
||||
// the bounded retry rather than silently losing self-healing.
|
||||
missingReasoningWarnStateLockTimeout = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
type missingReasoningIncident struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
WarnedAtUnixMs int64 `json:"warnedAtUnixMs,omitempty"`
|
||||
LastMissingUnixMs int64 `json:"lastMissingAtUnixMs,omitempty"`
|
||||
LastMissingUnixNano int64 `json:"lastMissingAtUnixNano,omitempty"`
|
||||
LastResolvedAtUnixNano int64 `json:"lastResolvedAtUnixNano,omitempty"`
|
||||
// These optional fields extend the v2 document without changing its version.
|
||||
// Older builds ignore them and may drop an in-progress streak on write, while
|
||||
// both generations continue to share the same active-incident boundary.
|
||||
ResolveStreak int `json:"resolveStreak,omitempty"`
|
||||
LastHealthyAtUnixNano int64 `json:"lastHealthyAtUnixNano,omitempty"`
|
||||
}
|
||||
|
||||
type missingReasoningWarnDocument struct {
|
||||
Version int `json:"version"`
|
||||
Incidents []missingReasoningIncident `json:"incidents,omitempty"`
|
||||
// Providers reads the unreleased v1 preview schema. Provider names cannot
|
||||
// safely identify endpoint/model/protocol changes and have no timestamp, so
|
||||
// they deliberately re-arm once and are omitted on the next v2 write.
|
||||
Providers []string `json:"providers,omitempty"`
|
||||
}
|
||||
|
||||
type missingReasoningWarnState struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// missingReasoningWarnClaimFlights coalesces concurrent observations of the
|
||||
// same incident inside one process. filelock.Acquire intentionally has a short
|
||||
// deadline so recovery bookkeeping cannot stall a turn behind another process. On a
|
||||
// slower filesystem (notably Windows CI), several local callers can otherwise
|
||||
// exhaust that deadline while queued behind the first atomic write and each
|
||||
// take the fail-open path, producing duplicate recovery requests.
|
||||
//
|
||||
// Followers update the latest observation timestamp and let the leader persist
|
||||
// it before the flight is removed. This preserves resolveAt's stale-health
|
||||
// ordering guarantee instead of merely suppressing duplicate return values.
|
||||
type missingReasoningWarnClaimFlight struct {
|
||||
latestObservedAt time.Time
|
||||
}
|
||||
|
||||
var missingReasoningWarnClaimFlights = struct {
|
||||
sync.Mutex
|
||||
flights map[string]*missingReasoningWarnClaimFlight
|
||||
}{flights: map[string]*missingReasoningWarnClaimFlight{}}
|
||||
|
||||
// missingReasoningWarnProcessLocks serializes transactions for one state file
|
||||
// before they enter filelock.Acquire. The file lock's short deadline is meant
|
||||
// to bound waits on another process, not make distinct local incidents lose
|
||||
// persistence while queued behind a slower Windows atomic write. Reasonix uses
|
||||
// one state path per home, so retaining these few mutexes for the process
|
||||
// lifetime is bounded in normal operation.
|
||||
var missingReasoningWarnProcessLocks sync.Map
|
||||
|
||||
func newMissingReasoningWarnState(dir string) *missingReasoningWarnState {
|
||||
return &missingReasoningWarnState{dir: strings.TrimSpace(dir)}
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) path() string {
|
||||
return filepath.Join(s.dir, missingReasoningWarnStateFilename)
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) lockPath() string {
|
||||
return filepath.Join(s.dir, missingReasoningWarnStateLockFilename)
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) processLockKey() string {
|
||||
path, err := filepath.Abs(s.path())
|
||||
if err != nil {
|
||||
path = s.path()
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if runtime.GOOS == "windows" {
|
||||
path = strings.ToLower(filepath.ToSlash(path))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) processLock() *sync.Mutex {
|
||||
lock, _ := missingReasoningWarnProcessLocks.LoadOrStore(s.processLockKey(), &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) claimFlightKey(fingerprint string) string {
|
||||
return s.processLockKey() + "\x00" + fingerprint
|
||||
}
|
||||
|
||||
func validMissingReasoningFingerprint(fingerprint string) bool {
|
||||
if len(fingerprint) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range fingerprint {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func missingReasoningUnixNanoFromMillis(unixMs int64) (int64, bool) {
|
||||
if unixMs <= 0 || unixMs > math.MaxInt64/int64(time.Millisecond) {
|
||||
return 0, false
|
||||
}
|
||||
return unixMs * int64(time.Millisecond), true
|
||||
}
|
||||
|
||||
func normalizeMissingReasoningIncident(incident missingReasoningIncident, now time.Time) (missingReasoningIncident, bool) {
|
||||
incident.Fingerprint = strings.TrimSpace(incident.Fingerprint)
|
||||
if !validMissingReasoningFingerprint(incident.Fingerprint) ||
|
||||
incident.LastMissingUnixNano < 0 || incident.LastResolvedAtUnixNano < 0 ||
|
||||
incident.LastHealthyAtUnixNano < 0 || incident.ResolveStreak < 0 ||
|
||||
incident.ResolveStreak >= missingReasoningHealthyResolveStreak {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
|
||||
warnedAtUnixNano := int64(0)
|
||||
if incident.WarnedAtUnixMs != 0 {
|
||||
var ok bool
|
||||
warnedAtUnixNano, ok = missingReasoningUnixNanoFromMillis(incident.WarnedAtUnixMs)
|
||||
if !ok {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
}
|
||||
if incident.LastMissingUnixMs != 0 {
|
||||
lastMissingFromMillis, ok := missingReasoningUnixNanoFromMillis(incident.LastMissingUnixMs)
|
||||
if !ok {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
if incident.LastMissingUnixNano == 0 {
|
||||
incident.LastMissingUnixNano = lastMissingFromMillis
|
||||
} else if incident.LastMissingUnixNano/int64(time.Millisecond) != incident.LastMissingUnixMs {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
} else if incident.LastMissingUnixNano != 0 {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
|
||||
nowUnixNano := now.UnixNano()
|
||||
if warnedAtUnixNano > nowUnixNano || incident.LastMissingUnixNano > nowUnixNano ||
|
||||
incident.LastResolvedAtUnixNano > nowUnixNano || incident.LastHealthyAtUnixNano > nowUnixNano {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
if incident.LastMissingUnixNano > incident.LastResolvedAtUnixNano {
|
||||
if warnedAtUnixNano == 0 || incident.LastMissingUnixNano < warnedAtUnixNano {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
age := now.Sub(time.UnixMilli(incident.WarnedAtUnixMs))
|
||||
if age < 0 || age >= missingReasoningWarnStateCooldown {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
if incident.ResolveStreak > 0 {
|
||||
if incident.LastHealthyAtUnixNano <= incident.LastMissingUnixNano ||
|
||||
incident.LastHealthyAtUnixNano <= incident.LastResolvedAtUnixNano {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
} else if incident.LastHealthyAtUnixNano != 0 {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
return incident, true
|
||||
}
|
||||
if incident.LastResolvedAtUnixNano <= 0 || incident.ResolveStreak != 0 ||
|
||||
incident.LastHealthyAtUnixNano > incident.LastResolvedAtUnixNano {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
resolvedAt := time.Unix(0, incident.LastResolvedAtUnixNano)
|
||||
age := now.Sub(resolvedAt)
|
||||
if age < 0 || age >= missingReasoningWarnStateCooldown {
|
||||
return missingReasoningIncident{}, false
|
||||
}
|
||||
return incident, true
|
||||
}
|
||||
|
||||
func (incident missingReasoningIncident) lastEventUnixNano() int64 {
|
||||
lastEvent := max(incident.LastHealthyAtUnixNano, max(incident.LastResolvedAtUnixNano, incident.LastMissingUnixNano))
|
||||
return lastEvent
|
||||
}
|
||||
|
||||
// load returns only current v2 incidents and resolution watermarks. Missing,
|
||||
// corrupt, legacy, expired, or future-dated entries are treated as absent and
|
||||
// self-heal on the next write. Read failures other than a missing file remain
|
||||
// visible to the transaction so it cannot overwrite state from a partial read.
|
||||
func (s *missingReasoningWarnState) load(now time.Time) (map[string]missingReasoningIncident, error) {
|
||||
incidents := map[string]missingReasoningIncident{}
|
||||
b, err := os.ReadFile(s.path())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return incidents, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var doc missingReasoningWarnDocument
|
||||
if json.Unmarshal(b, &doc) != nil || doc.Version != missingReasoningWarnStateVersion {
|
||||
return incidents, nil
|
||||
}
|
||||
for _, rawIncident := range doc.Incidents {
|
||||
incident, ok := normalizeMissingReasoningIncident(rawIncident, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if previous, exists := incidents[incident.Fingerprint]; !exists || previous.lastEventUnixNano() < incident.lastEventUnixNano() {
|
||||
incidents[incident.Fingerprint] = incident
|
||||
}
|
||||
}
|
||||
return incidents, nil
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) save(incidents map[string]missingReasoningIncident) error {
|
||||
ordered := make([]missingReasoningIncident, 0, len(incidents))
|
||||
for _, incident := range incidents {
|
||||
ordered = append(ordered, incident)
|
||||
}
|
||||
if len(ordered) > missingReasoningWarnStateMaxIncidents {
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
return ordered[i].lastEventUnixNano() > ordered[j].lastEventUnixNano()
|
||||
})
|
||||
ordered = ordered[:missingReasoningWarnStateMaxIncidents]
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
return ordered[i].Fingerprint < ordered[j].Fingerprint
|
||||
})
|
||||
b, err := json.Marshal(missingReasoningWarnDocument{
|
||||
Version: missingReasoningWarnStateVersion,
|
||||
Incidents: ordered,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(s.path(), b, 0o600)
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) acquire() (func(), error) {
|
||||
if err := os.MkdirAll(s.dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), missingReasoningWarnStateLockTimeout)
|
||||
release, err := filelock.Acquire(ctx, s.lockPath())
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
release()
|
||||
cancel()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMissingReasoningObservedAt(observedAt time.Time) time.Time {
|
||||
if observedAt.IsZero() {
|
||||
return time.Now()
|
||||
}
|
||||
return observedAt
|
||||
}
|
||||
|
||||
func missingReasoningTransactionNow(observedAt time.Time) time.Time {
|
||||
now := time.Now()
|
||||
if observedAt.After(now) {
|
||||
return observedAt
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
// persistClaimAt performs one cross-process read-check-write transaction.
|
||||
// Persistence failure returns true so recovery fails open rather than being
|
||||
// disabled by local bookkeeping trouble.
|
||||
func (s *missingReasoningWarnState) persistClaimAt(fingerprint string, observedAt time.Time) bool {
|
||||
processLock := s.processLock()
|
||||
processLock.Lock()
|
||||
defer processLock.Unlock()
|
||||
|
||||
release, err := s.acquire()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
defer release()
|
||||
|
||||
incidents, err := s.load(missingReasoningTransactionNow(observedAt))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
incident, exists := incidents[fingerprint]
|
||||
observedAtUnixNano := observedAt.UnixNano()
|
||||
if exists && (observedAtUnixNano <= incident.LastResolvedAtUnixNano ||
|
||||
observedAtUnixNano <= incident.LastHealthyAtUnixNano) {
|
||||
return false
|
||||
}
|
||||
activeIncident := exists && incident.LastMissingUnixNano > incident.LastResolvedAtUnixNano
|
||||
shouldRetry := !activeIncident
|
||||
if shouldRetry {
|
||||
lastResolvedAtUnixNano := incident.LastResolvedAtUnixNano
|
||||
incident = missingReasoningIncident{
|
||||
Fingerprint: fingerprint,
|
||||
WarnedAtUnixMs: observedAt.UnixMilli(),
|
||||
LastResolvedAtUnixNano: lastResolvedAtUnixNano,
|
||||
}
|
||||
}
|
||||
if incident.LastMissingUnixNano < observedAtUnixNano {
|
||||
incident.LastMissingUnixMs = observedAt.UnixMilli()
|
||||
incident.LastMissingUnixNano = observedAtUnixNano
|
||||
incident.ResolveStreak = 0
|
||||
incident.LastHealthyAtUnixNano = 0
|
||||
}
|
||||
incidents[fingerprint] = incident
|
||||
if err := s.save(incidents); err != nil {
|
||||
return true
|
||||
}
|
||||
return shouldRetry
|
||||
}
|
||||
|
||||
// claimAt records the latest missing-reasoning observation and reports whether
|
||||
// this active incident should receive its one recovery retry. Concurrent
|
||||
// observations of the same incident in this process share one leader; the file
|
||||
// lock covers the leader's read-check-write transactions against other
|
||||
// processes sharing the Reasonix home.
|
||||
func (s *missingReasoningWarnState) claimAt(fingerprint string, observedAt time.Time) bool {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if s == nil || s.dir == "" || !validMissingReasoningFingerprint(fingerprint) {
|
||||
return true
|
||||
}
|
||||
observedAt = normalizeMissingReasoningObservedAt(observedAt)
|
||||
key := s.claimFlightKey(fingerprint)
|
||||
|
||||
missingReasoningWarnClaimFlights.Lock()
|
||||
if flight := missingReasoningWarnClaimFlights.flights[key]; flight != nil {
|
||||
if observedAt.After(flight.latestObservedAt) {
|
||||
flight.latestObservedAt = observedAt
|
||||
}
|
||||
missingReasoningWarnClaimFlights.Unlock()
|
||||
return false
|
||||
}
|
||||
flight := &missingReasoningWarnClaimFlight{latestObservedAt: observedAt}
|
||||
missingReasoningWarnClaimFlights.flights[key] = flight
|
||||
missingReasoningWarnClaimFlights.Unlock()
|
||||
|
||||
processedAt := time.Time{}
|
||||
shouldRetry := false
|
||||
for {
|
||||
missingReasoningWarnClaimFlights.Lock()
|
||||
nextObservedAt := flight.latestObservedAt
|
||||
missingReasoningWarnClaimFlights.Unlock()
|
||||
|
||||
if nextObservedAt.After(processedAt) {
|
||||
shouldRetry = s.persistClaimAt(fingerprint, nextObservedAt) || shouldRetry
|
||||
processedAt = nextObservedAt
|
||||
}
|
||||
|
||||
missingReasoningWarnClaimFlights.Lock()
|
||||
if !flight.latestObservedAt.After(processedAt) {
|
||||
delete(missingReasoningWarnClaimFlights.flights, key)
|
||||
missingReasoningWarnClaimFlights.Unlock()
|
||||
return shouldRetry
|
||||
}
|
||||
missingReasoningWarnClaimFlights.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *missingReasoningWarnState) claim(fingerprint string) bool {
|
||||
return s.claimAt(fingerprint, time.Now())
|
||||
}
|
||||
|
||||
type missingReasoningResolveResult struct {
|
||||
Recorded bool
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// resolveAt records one healthy tool-call turn. Three consecutive healthy
|
||||
// observations resolve the incident; another missing observation resets the
|
||||
// streak. The healthy and resolved watermarks prevent events that happened
|
||||
// earlier but acquired the cross-process lock later from changing newer state.
|
||||
func (s *missingReasoningWarnState) resolveAt(fingerprint string, observedAt time.Time) missingReasoningResolveResult {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if s == nil || s.dir == "" || !validMissingReasoningFingerprint(fingerprint) {
|
||||
return missingReasoningResolveResult{}
|
||||
}
|
||||
observedAt = normalizeMissingReasoningObservedAt(observedAt)
|
||||
processLock := s.processLock()
|
||||
processLock.Lock()
|
||||
defer processLock.Unlock()
|
||||
|
||||
release, err := s.acquire()
|
||||
if err != nil {
|
||||
return missingReasoningResolveResult{}
|
||||
}
|
||||
defer release()
|
||||
|
||||
incidents, err := s.load(missingReasoningTransactionNow(observedAt))
|
||||
if err != nil {
|
||||
return missingReasoningResolveResult{}
|
||||
}
|
||||
incident, exists := incidents[fingerprint]
|
||||
observedAtUnixNano := observedAt.UnixNano()
|
||||
if !exists || incident.LastMissingUnixNano <= incident.LastResolvedAtUnixNano {
|
||||
return missingReasoningResolveResult{Recorded: true, Resolved: true}
|
||||
}
|
||||
if incident.LastMissingUnixNano >= observedAtUnixNano || incident.LastHealthyAtUnixNano >= observedAtUnixNano {
|
||||
return missingReasoningResolveResult{Recorded: true}
|
||||
}
|
||||
incident.ResolveStreak++
|
||||
incident.LastHealthyAtUnixNano = observedAtUnixNano
|
||||
resolved := incident.ResolveStreak >= missingReasoningHealthyResolveStreak
|
||||
if resolved {
|
||||
incident.ResolveStreak = 0
|
||||
incident.LastResolvedAtUnixNano = observedAtUnixNano
|
||||
}
|
||||
incidents[fingerprint] = incident
|
||||
if s.save(incidents) != nil {
|
||||
return missingReasoningResolveResult{}
|
||||
}
|
||||
return missingReasoningResolveResult{Recorded: true, Resolved: resolved}
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func warningFingerprint(label string) string {
|
||||
digest := sha256.Sum256([]byte(label))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func missingReasoningTestNow() time.Time {
|
||||
return time.Now().Add(-time.Hour).Truncate(time.Millisecond)
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStatePersistsCurrentIncidentAcrossInstances(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
fingerprint := warningFingerprint("openai\x00deepseek\x00v4-pro")
|
||||
observedAt := missingReasoningTestNow()
|
||||
if !newMissingReasoningWarnState(dir).claimAt(fingerprint, observedAt) {
|
||||
t.Fatal("fresh configuration must claim its first incident notice")
|
||||
}
|
||||
if newMissingReasoningWarnState(dir).claimAt(fingerprint, observedAt.Add(time.Minute)) {
|
||||
t.Fatal("fresh instance must suppress the same current incident")
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(dir, missingReasoningWarnStateFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("state file missing after claim: %v", err)
|
||||
}
|
||||
latestObservedAt := observedAt.Add(time.Minute)
|
||||
want := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d,"lastMissingAtUnixNano":%d}]}`,
|
||||
fingerprint, observedAt.UnixMilli(), latestObservedAt.UnixMilli(), latestObservedAt.UnixNano())
|
||||
if got := string(b); got != want {
|
||||
t.Fatalf("state file = %s, want %s", got, want)
|
||||
}
|
||||
if strings.Contains(string(b), "deepseek") || strings.Contains(string(b), "v4-pro") {
|
||||
t.Fatalf("state file exposed raw provider configuration: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateSeparatesConfigurationFingerprints(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
now := missingReasoningTestNow()
|
||||
if !s.claimAt(warningFingerprint("endpoint-a\x00model-a"), now) {
|
||||
t.Fatal("first configuration must warn")
|
||||
}
|
||||
if !s.claimAt(warningFingerprint("endpoint-a\x00model-b"), now) {
|
||||
t.Fatal("model change must re-arm the warning")
|
||||
}
|
||||
if !s.claimAt(warningFingerprint("endpoint-b\x00model-a"), now) {
|
||||
t.Fatal("endpoint change must re-arm the warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateExpiresCooldown(t *testing.T) {
|
||||
s := newMissingReasoningWarnState(testenv.TempDir(t))
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := missingReasoningTestNow()
|
||||
if !s.claimAt(fingerprint, now) {
|
||||
t.Fatal("fresh incident must warn")
|
||||
}
|
||||
if s.claimAt(fingerprint, now.Add(missingReasoningWarnStateCooldown-time.Second)) {
|
||||
t.Fatal("incident inside cooldown must stay silent")
|
||||
}
|
||||
if !s.claimAt(fingerprint, now.Add(missingReasoningWarnStateCooldown)) {
|
||||
t.Fatal("incident at cooldown boundary must warn again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateHealthyTurnRearmsRegression(t *testing.T) {
|
||||
s := newMissingReasoningWarnState(testenv.TempDir(t))
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := missingReasoningTestNow()
|
||||
if !s.claimAt(fingerprint, now) {
|
||||
t.Fatal("fresh incident must warn")
|
||||
}
|
||||
for healthy := 1; healthy <= missingReasoningHealthyResolveStreak; healthy++ {
|
||||
result := s.resolveAt(fingerprint, now.Add(time.Duration(healthy)*time.Minute))
|
||||
if !result.Recorded {
|
||||
t.Fatalf("healthy observation %d was not recorded", healthy)
|
||||
}
|
||||
if got, want := result.Resolved, healthy == missingReasoningHealthyResolveStreak; got != want {
|
||||
t.Fatalf("healthy observation %d resolved = %v, want %v", healthy, got, want)
|
||||
}
|
||||
}
|
||||
if !s.claimAt(fingerprint, now.Add(4*time.Minute)) {
|
||||
t.Fatal("regression after three healthy turns must warn again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateMissingTurnResetsHealthyStreak(t *testing.T) {
|
||||
s := newMissingReasoningWarnState(testenv.TempDir(t))
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := missingReasoningTestNow()
|
||||
if !s.claimAt(fingerprint, now) {
|
||||
t.Fatal("fresh incident must warn")
|
||||
}
|
||||
for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ {
|
||||
if result := s.resolveAt(fingerprint, now.Add(time.Duration(healthy)*time.Minute)); !result.Recorded || result.Resolved {
|
||||
t.Fatalf("pre-reset healthy observation %d = %+v", healthy, result)
|
||||
}
|
||||
}
|
||||
if s.claimAt(fingerprint, now.Add(3*time.Minute)) {
|
||||
t.Fatal("missing turn inside the active incident must stay suppressed")
|
||||
}
|
||||
for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ {
|
||||
result := s.resolveAt(fingerprint, now.Add(time.Duration(3+healthy)*time.Minute))
|
||||
if !result.Recorded || result.Resolved {
|
||||
t.Fatalf("post-reset healthy observation %d = %+v", healthy, result)
|
||||
}
|
||||
}
|
||||
if s.claimAt(fingerprint, now.Add(6*time.Minute)) {
|
||||
t.Fatal("two healthy turns after a reset must not re-arm recovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateStaleHealthCannotClearNewerFailure(t *testing.T) {
|
||||
s := newMissingReasoningWarnState(testenv.TempDir(t))
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := missingReasoningTestNow()
|
||||
if !s.claimAt(fingerprint, now) {
|
||||
t.Fatal("fresh incident must warn")
|
||||
}
|
||||
if s.claimAt(fingerprint, now.Add(2*time.Millisecond)) {
|
||||
t.Fatal("newer observation inside cooldown must stay silent")
|
||||
}
|
||||
// Simulate an older healthy observation acquiring the lock after the newer
|
||||
// missing observation. It must not erase the newer incident.
|
||||
s.resolveAt(fingerprint, now.Add(time.Millisecond))
|
||||
if s.claimAt(fingerprint, now.Add(3*time.Millisecond)) {
|
||||
t.Fatal("stale healthy observation erased a newer incident")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateDuplicateHealthAndDelayedFailureDoNotChangeStreak(t *testing.T) {
|
||||
s := newMissingReasoningWarnState(testenv.TempDir(t))
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := missingReasoningTestNow()
|
||||
if !s.persistClaimAt(fingerprint, now) {
|
||||
t.Fatal("fresh incident must warn")
|
||||
}
|
||||
firstHealthyAt := now.Add(2 * time.Millisecond)
|
||||
if result := s.resolveAt(fingerprint, firstHealthyAt); !result.Recorded || result.Resolved {
|
||||
t.Fatalf("first healthy observation = %+v", result)
|
||||
}
|
||||
if result := s.resolveAt(fingerprint, firstHealthyAt); !result.Recorded || result.Resolved {
|
||||
t.Fatalf("duplicate healthy observation = %+v", result)
|
||||
}
|
||||
if s.persistClaimAt(fingerprint, now.Add(time.Millisecond)) {
|
||||
t.Fatal("delayed failure older than healthy progress revived the incident")
|
||||
}
|
||||
if result := s.resolveAt(fingerprint, now.Add(3*time.Millisecond)); !result.Recorded || result.Resolved {
|
||||
t.Fatalf("second unique healthy observation = %+v", result)
|
||||
}
|
||||
if result := s.resolveAt(fingerprint, now.Add(4*time.Millisecond)); !result.Recorded || !result.Resolved {
|
||||
t.Fatalf("third unique healthy observation = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateDelayedFailureCannotReviveResolvedIncident(t *testing.T) {
|
||||
s := newMissingReasoningWarnState(testenv.TempDir(t))
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := time.Now()
|
||||
firstMissingAt := now.Add(-10 * time.Millisecond)
|
||||
delayedMissingAt := now.Add(-8 * time.Millisecond)
|
||||
healthyAt := []time.Time{
|
||||
now.Add(-6 * time.Millisecond),
|
||||
now.Add(-4 * time.Millisecond),
|
||||
now.Add(-2 * time.Millisecond),
|
||||
}
|
||||
|
||||
if !s.persistClaimAt(fingerprint, firstMissingAt) {
|
||||
t.Fatal("fresh incident must warn")
|
||||
}
|
||||
for i, observedAt := range healthyAt {
|
||||
result := s.resolveAt(fingerprint, observedAt)
|
||||
if !result.Recorded || result.Resolved != (i == len(healthyAt)-1) {
|
||||
t.Fatalf("healthy observation %d = %+v", i+1, result)
|
||||
}
|
||||
}
|
||||
// Simulate a missing observation that happened before the healthy result but
|
||||
// completed its cross-process transaction afterward.
|
||||
if s.persistClaimAt(fingerprint, delayedMissingAt) {
|
||||
t.Fatal("delayed pre-recovery failure revived a resolved incident")
|
||||
}
|
||||
if !s.claimAt(fingerprint, now) {
|
||||
t.Fatal("healthy result did not re-arm a later regression")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateV2OptionalStreakFieldsResume(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, missingReasoningWarnStateFilename)
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := missingReasoningTestNow()
|
||||
doc := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d,"lastMissingAtUnixNano":%d,"resolveStreak":2,"lastHealthyAtUnixNano":%d}]}`,
|
||||
fingerprint, now.UnixMilli(), now.UnixMilli(), now.UnixNano(), now.Add(2*time.Minute).UnixNano())
|
||||
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
result := s.resolveAt(fingerprint, now.Add(3*time.Minute))
|
||||
if !result.Recorded || !result.Resolved {
|
||||
t.Fatalf("resumed third healthy observation = %+v", result)
|
||||
}
|
||||
if !s.claimAt(fingerprint, now.Add(4*time.Minute)) {
|
||||
t.Fatal("resumed v2 streak did not re-arm a later regression")
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"version":2`) {
|
||||
t.Fatalf("optional fields changed the v2 document contract: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateFutureLastMissingSelfHeals(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, missingReasoningWarnStateFilename)
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := time.Now().Truncate(time.Millisecond)
|
||||
doc := missingReasoningWarnDocument{
|
||||
Version: missingReasoningWarnStateVersion,
|
||||
Incidents: []missingReasoningIncident{{
|
||||
Fingerprint: fingerprint,
|
||||
WarnedAtUnixMs: now.UnixMilli(),
|
||||
LastMissingUnixMs: now.Add(time.Hour).UnixMilli(),
|
||||
}},
|
||||
}
|
||||
b, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
s.resolveAt(fingerprint, now.Add(time.Minute))
|
||||
if !s.claimAt(fingerprint, now.Add(2*time.Minute)) {
|
||||
t.Fatal("future last-missing timestamp suppressed a re-armed regression")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateLegacyPreviewRearmsAndMigrates(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, missingReasoningWarnStateFilename)
|
||||
if err := os.WriteFile(path, []byte(`{"providers":["deepseek"]}`), 0o600); err != nil {
|
||||
t.Fatalf("seed legacy state: %v", err)
|
||||
}
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
if !s.claimAt(warningFingerprint("deepseek-current-config"), missingReasoningTestNow()) {
|
||||
t.Fatal("legacy provider-name marker must not suppress a configuration-scoped incident")
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), `"providers"`) || !strings.Contains(string(b), `"version":2`) {
|
||||
t.Fatalf("legacy state was not migrated to v2: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateLoadsV2IncidentWithoutNanosecondField(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, missingReasoningWarnStateFilename)
|
||||
fingerprint := warningFingerprint("config")
|
||||
now := missingReasoningTestNow()
|
||||
doc := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d}]}`,
|
||||
fingerprint, now.UnixMilli(), now.UnixMilli())
|
||||
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
if s.claimAt(fingerprint, now.Add(time.Minute)) {
|
||||
t.Fatal("v2 incident without nanosecond fields did not retain its active warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateCorruptFileSelfHeals(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, missingReasoningWarnStateFilename)
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatalf("seed corrupt file: %v", err)
|
||||
}
|
||||
fingerprint := warningFingerprint("config")
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
now := missingReasoningTestNow()
|
||||
if !s.claimAt(fingerprint, now) {
|
||||
t.Fatal("corrupt state must re-arm the incident")
|
||||
}
|
||||
if s.claimAt(fingerprint, now.Add(time.Minute)) {
|
||||
t.Fatal("rewritten state did not retain the incident")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateUsesOwnerOnlyPermissions(t *testing.T) {
|
||||
dir := filepath.Join(testenv.TempDir(t), "state")
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
if !s.claimAt(warningFingerprint("config"), missingReasoningTestNow()) {
|
||||
t.Fatal("fresh incident must warn")
|
||||
}
|
||||
dirInfo, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dirInfo.Mode().Perm(); runtime.GOOS != "windows" && got != 0o700 {
|
||||
t.Fatalf("state directory mode = %o, want 700", got)
|
||||
}
|
||||
fileInfo, err := os.Stat(filepath.Join(dir, missingReasoningWarnStateFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileInfo.Mode().Perm(); runtime.GOOS != "windows" && got != 0o600 {
|
||||
t.Fatalf("state file mode = %o, want 600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateIOFailureFallsBackVisible(t *testing.T) {
|
||||
path := filepath.Join(testenv.TempDir(t), "not-a-directory")
|
||||
if err := os.WriteFile(path, []byte("occupied"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !newMissingReasoningWarnState(path).claimAt(warningFingerprint("config"), missingReasoningTestNow()) {
|
||||
t.Fatal("state I/O failure must keep the diagnostic visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateReadFailureDoesNotOverwriteExistingIncidents(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod permissions are not portable to Windows")
|
||||
}
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, missingReasoningWarnStateFilename)
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
now := missingReasoningTestNow()
|
||||
existingFingerprint := warningFingerprint("existing")
|
||||
newFingerprint := warningFingerprint("new")
|
||||
if !s.claimAt(existingFingerprint, now) {
|
||||
t.Fatal("fresh existing incident must warn")
|
||||
}
|
||||
if err := os.Chmod(path, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissionsRestored := false
|
||||
defer func() {
|
||||
if !permissionsRestored {
|
||||
_ = os.Chmod(path, 0o600)
|
||||
}
|
||||
}()
|
||||
if !s.claimAt(newFingerprint, now.Add(time.Minute)) {
|
||||
t.Fatal("state read failure must keep the new diagnostic visible")
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissionsRestored = true
|
||||
|
||||
incidents, err := s.load(now.Add(2 * time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := incidents[existingFingerprint]; !ok {
|
||||
t.Fatal("state read failure overwrote the existing incident")
|
||||
}
|
||||
if _, ok := incidents[newFingerprint]; ok {
|
||||
t.Fatal("new incident was unexpectedly persisted from a partial read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateEmptyDirFallsBackVisible(t *testing.T) {
|
||||
s := newMissingReasoningWarnState("")
|
||||
fingerprint := warningFingerprint("config")
|
||||
if !s.claim(fingerprint) {
|
||||
t.Fatal("first empty-dir claim must stay visible")
|
||||
}
|
||||
if !s.claim(fingerprint) {
|
||||
t.Fatal("repeated empty-dir claim must stay visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateConcurrentSameIncidentWarnsOnce(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
fingerprint := warningFingerprint("shared-config")
|
||||
now := missingReasoningTestNow()
|
||||
start := make(chan struct{})
|
||||
var warned atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
if newMissingReasoningWarnState(dir).claimAt(fingerprint, now) {
|
||||
warned.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if got := warned.Load(); got != 1 {
|
||||
t.Fatalf("concurrent first warnings = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateConcurrentFollowerPersistsLatestObservation(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
s := newMissingReasoningWarnState(dir)
|
||||
fingerprint := warningFingerprint("shared-config")
|
||||
firstObservedAt := missingReasoningTestNow()
|
||||
latestObservedAt := firstObservedAt.Add(2 * time.Millisecond)
|
||||
|
||||
releaseLock, err := filelock.Acquire(context.Background(), s.lockPath())
|
||||
if err != nil {
|
||||
t.Fatalf("hold state lock: %v", err)
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
releaseLock()
|
||||
}
|
||||
}()
|
||||
|
||||
leaderResult := make(chan bool, 1)
|
||||
go func() {
|
||||
leaderResult <- s.claimAt(fingerprint, firstObservedAt)
|
||||
}()
|
||||
|
||||
key := s.claimFlightKey(fingerprint)
|
||||
deadline := time.Now().Add(missingReasoningWarnStateLockTimeout / 2)
|
||||
for {
|
||||
missingReasoningWarnClaimFlights.Lock()
|
||||
flightPresent := missingReasoningWarnClaimFlights.flights[key] != nil
|
||||
missingReasoningWarnClaimFlights.Unlock()
|
||||
if flightPresent {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("leader did not register its claim flight")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
if s.claimAt(fingerprint, latestObservedAt) {
|
||||
t.Fatal("concurrent follower must not emit a duplicate warning")
|
||||
}
|
||||
releaseLock()
|
||||
released = true
|
||||
if !<-leaderResult {
|
||||
t.Fatal("leader must keep the first incident warning visible")
|
||||
}
|
||||
|
||||
incidents, err := s.load(latestObservedAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
incident, ok := incidents[fingerprint]
|
||||
if !ok || len(incidents) != 1 {
|
||||
t.Fatalf("persisted incidents = %#v, want only %q", incidents, fingerprint)
|
||||
}
|
||||
if got, want := incident.LastMissingUnixMs, latestObservedAt.UnixMilli(); got != want {
|
||||
t.Fatalf("last missing timestamp = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingReasoningWarnStateConcurrentClaimsKeepEveryConfiguration(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
now := missingReasoningTestNow()
|
||||
labels := []string{"alpha", "bravo", "charlie", "delta"}
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for _, label := range labels {
|
||||
fingerprint := warningFingerprint(label)
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
if !newMissingReasoningWarnState(dir).claimAt(fingerprint, now) {
|
||||
t.Errorf("fresh configuration %q did not claim its notice", label)
|
||||
}
|
||||
})
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
fresh := newMissingReasoningWarnState(dir)
|
||||
for _, label := range labels {
|
||||
if fresh.claimAt(warningFingerprint(label), now.Add(time.Minute)) {
|
||||
t.Errorf("configuration %q was lost after concurrent claims", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -47,18 +46,6 @@ func (s *recordSink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
|
||||
s.recovery = append(s.recovery, a)
|
||||
}
|
||||
|
||||
func (s *recordSink) recoveryCount(kind event.ProtocolRecoveryKind) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var count int
|
||||
for _, audit := range s.recovery {
|
||||
if audit.Kind == kind {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// TestAgentEmitsRetryingThenStreams drives the whole chain end-to-end: a real
|
||||
// OpenAI-compatible provider hits an httptest server that returns 503 twice then
|
||||
// a valid SSE stream. The agent must emit a Retrying event per backoff (so the
|
||||
@@ -104,78 +91,3 @@ func TestAgentEmitsRetryingThenStreams(t *testing.T) {
|
||||
t.Errorf("streamed answer = %q, want it to contain %q", answer.String(), "hi there")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeepSeekFlashMissingReasoningRecoveryWithRealSSE exercises the actual
|
||||
// OpenAI-compatible decoder shape used by the official Flash endpoint. The
|
||||
// first response emits a tool call without reasoning_content; the second exact
|
||||
// request includes it; only the adopted call reaches the session and UI.
|
||||
func TestDeepSeekFlashMissingReasoningRecoveryWithRealSSE(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var bodies [][]byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, append([]byte(nil), body...))
|
||||
requestNo := len(bodies)
|
||||
mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
if requestNo == 2 {
|
||||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning_content":"call echo safely"}}]}`+"\n\n")
|
||||
}
|
||||
if requestNo <= 2 {
|
||||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"text\":\"hi\"}"}}]},"finish_reason":"tool_calls"}]}`+"\n\n")
|
||||
_, _ = io.WriteString(w, `data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12,"prompt_cache_hit_tokens":10,"prompt_cache_miss_tokens":0,"completion_tokens_details":{"reasoning_tokens":64}}}`+"\n\n")
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"content":"done"},"finish_reason":"stop"}]}`+"\n\n")
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
prov, err := openai.New(provider.Config{
|
||||
Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4-flash", APIKey: "k",
|
||||
Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New provider: %v", err)
|
||||
}
|
||||
sink := &recordSink{}
|
||||
a := New(prov, echoRegistry(), sessionstore.NewSession(""), Options{}, sink)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
gotBodies := append([][]byte(nil), bodies...)
|
||||
mu.Unlock()
|
||||
if len(gotBodies) != 3 {
|
||||
t.Fatalf("HTTP requests = %d, want malformed + recovery + final", len(gotBodies))
|
||||
}
|
||||
if !bytes.Equal(gotBodies[0], gotBodies[1]) {
|
||||
t.Fatalf("recovery request changed bytes:\nfirst=%s\nretry=%s", gotBodies[0], gotBodies[1])
|
||||
}
|
||||
var toolTurns int
|
||||
for _, message := range a.Session().Messages {
|
||||
if message.Role == provider.RoleAssistant && len(message.ToolCalls) > 0 {
|
||||
toolTurns++
|
||||
if message.ReasoningContent != "call echo safely" {
|
||||
t.Fatalf("adopted reasoning = %q", message.ReasoningContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolTurns != 1 {
|
||||
t.Fatalf("saved tool turns = %d, want 1", toolTurns)
|
||||
}
|
||||
// One partial dispatch from the adopted SSE plus one full execution
|
||||
// dispatch. The discarded malformed stream must not add a third card.
|
||||
if got := len(sink.kinds(event.ToolDispatch)); got != 2 {
|
||||
t.Fatalf("tool dispatch events = %d, want adopted partial + full", got)
|
||||
}
|
||||
for _, notice := range sink.kinds(event.Notice) {
|
||||
if strings.Contains(notice.Text, "reasoning_content") || strings.Contains(notice.Detail, "reasoning_content") {
|
||||
t.Fatalf("protocol warning leaked to UI: %+v", notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,72 +54,6 @@ type streamedTurn struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// deferredStreamSink keeps selected stream events local until the caller
|
||||
// chooses which provider response to adopt. On an ordinary healthy DeepSeek
|
||||
// turn, reasoning arrives before tool calls and unlocks live tool-card events.
|
||||
// On the rare malformed turn with no reasoning, only the speculative partial
|
||||
// tool cards remain buffered, so retrying does not flash duplicate cards in the
|
||||
// UI. A recovery attempt buffers everything because it may be discarded.
|
||||
type deferredStreamSink struct {
|
||||
inner event.Sink
|
||||
deferAll bool
|
||||
waitingForReasoning bool
|
||||
sawReasoning bool
|
||||
events []event.Event
|
||||
}
|
||||
|
||||
func newReasoningAwareStreamSink(inner event.Sink) *deferredStreamSink {
|
||||
return &deferredStreamSink{inner: inner, waitingForReasoning: true}
|
||||
}
|
||||
|
||||
func newDeferredStreamSink(inner event.Sink) *deferredStreamSink {
|
||||
return &deferredStreamSink{inner: inner, deferAll: true}
|
||||
}
|
||||
|
||||
func (s *deferredStreamSink) Emit(e event.Event) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.deferAll {
|
||||
s.events = append(s.events, e)
|
||||
return
|
||||
}
|
||||
if s.waitingForReasoning && e.Kind == event.Reasoning && strings.TrimSpace(e.Text) != "" {
|
||||
s.sawReasoning = true
|
||||
s.inner.Emit(e)
|
||||
s.flushBuffered()
|
||||
return
|
||||
}
|
||||
if s.waitingForReasoning && !s.sawReasoning && e.Kind == event.ToolDispatch {
|
||||
s.events = append(s.events, e)
|
||||
return
|
||||
}
|
||||
s.inner.Emit(e)
|
||||
}
|
||||
|
||||
func (s *deferredStreamSink) flushBuffered() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, e := range s.events {
|
||||
s.inner.Emit(e)
|
||||
}
|
||||
s.events = nil
|
||||
}
|
||||
|
||||
func (s *deferredStreamSink) Flush() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.flushBuffered()
|
||||
}
|
||||
|
||||
func (s *deferredStreamSink) Discard() {
|
||||
if s != nil {
|
||||
s.events = nil
|
||||
}
|
||||
}
|
||||
|
||||
// beginRunTurn handles evidence scope, delivery classification, background-job
|
||||
// evidence re-lease, and the initial user-turn persistence. Callers still own
|
||||
// all Run-level defers (workspace lease, evidence commit, delivery checkpoint,
|
||||
@@ -441,14 +375,7 @@ func (a *Agent) streamWithSamplingRecovery(ctx context.Context, turn int) stream
|
||||
attemptID := newStreamAttemptID(attempt)
|
||||
a.emitStreamAttempt(attemptID, event.StreamAttemptBegin, attempt, "", nil, 0)
|
||||
|
||||
var streamSink *deferredStreamSink
|
||||
attemptSink := a.svc.sink
|
||||
if provider.WarnOnMissingToolCallReasoning(a.svc.prov) {
|
||||
streamSink = newReasoningAwareStreamSink(a.svc.sink)
|
||||
attemptSink = streamSink
|
||||
}
|
||||
|
||||
result := runAttempt(attemptID, attemptSink)
|
||||
result := runAttempt(attemptID, a.svc.sink)
|
||||
billable = mergeSamplingUsage(billable, result.usage)
|
||||
// lastUsage is the latest single-request shape (prompt+completion+cache
|
||||
// for that attempt only). Never the multi-attempt billable aggregate —
|
||||
@@ -458,68 +385,13 @@ func (a *Agent) streamWithSamplingRecovery(ctx context.Context, turn int) stream
|
||||
last.usage = finalizeSamplingUsage(billable, result.usage)
|
||||
|
||||
if result.err != nil {
|
||||
retry, terminal := a.handleSamplingError(ctx, attemptID, attempt, streamSink, &frozen, result, last, billable)
|
||||
retry, terminal := a.handleSamplingError(ctx, attemptID, attempt, &frozen, result, last, billable)
|
||||
if retry {
|
||||
continue
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
// Clean terminal. Optionally repair missing reasoning with one extra
|
||||
// exact replay of the same frozen request (no synthetic prompt).
|
||||
observed := a.observeMissingToolCallReasoning(result.calls, result.reasoning, usageReasoningTokens(result.usage))
|
||||
if observed == reasoningModelSilent {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningModelSilent})
|
||||
}
|
||||
if observed.missing() {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningDetected})
|
||||
if observed == reasoningLostReplay && strings.TrimSpace(result.text) == "" {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryAttempted})
|
||||
retrySink := newDeferredStreamSink(a.svc.sink)
|
||||
retry := runAttempt(attemptID, retrySink)
|
||||
billable = mergeSamplingUsage(billable, retry.usage)
|
||||
if retry.err != nil {
|
||||
retrySink.Discard()
|
||||
if ctx.Err() != nil {
|
||||
streamSink.Discard()
|
||||
a.emitStreamAttempt(attemptID, event.StreamAttemptDiscard, attempt, provider.StreamInterruptReason(retry.err), retry.err, 0)
|
||||
// Use the cancelled retry as the "latest" shape so
|
||||
// FinishReason=interrupted is preserved for accounting.
|
||||
return streamedTurn{usage: finalizeSamplingUsage(billable, retry.usage), attemptID: attemptID, err: retry.err}
|
||||
}
|
||||
// Fall back to the first complete response; no tool ran.
|
||||
streamSink.Flush()
|
||||
a.storeLatestRequestUsage(result.usage)
|
||||
result.usage = finalizeSamplingUsage(billable, result.usage)
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningFallback})
|
||||
a.emitStreamAttempt(attemptID, event.StreamAttemptCommit, attempt, "", nil, result.firstTokenMs)
|
||||
return result
|
||||
}
|
||||
streamSink.Discard()
|
||||
retrySink.Flush()
|
||||
a.storeLatestRequestUsage(retry.usage)
|
||||
retryObserved := a.classifyTurnReasoning(retry)
|
||||
retry.usage = finalizeSamplingUsage(billable, retry.usage)
|
||||
if retryObserved.missing() {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningDetected})
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningFallback})
|
||||
} else if len(retry.calls) == 0 {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryReplaced})
|
||||
} else {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryRecovered})
|
||||
}
|
||||
a.emitStreamAttempt(attemptID, event.StreamAttemptCommit, attempt, "", nil, retry.firstTokenMs)
|
||||
return retry
|
||||
}
|
||||
if observed != reasoningLostReplay || strings.TrimSpace(result.text) != "" {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetrySuppressed})
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningFallback})
|
||||
} else {
|
||||
event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningFallback})
|
||||
}
|
||||
}
|
||||
|
||||
streamSink.Flush()
|
||||
a.emitStreamAttempt(attemptID, event.StreamAttemptCommit, attempt, "", nil, result.firstTokenMs)
|
||||
result.usage = finalizeSamplingUsage(billable, result.usage)
|
||||
return result
|
||||
|
||||
@@ -90,16 +90,6 @@ func estimateFailedAttemptUsage(usage *provider.Usage, frozen samplingRequest, r
|
||||
return est
|
||||
}
|
||||
|
||||
// usageReasoningTokens reports the thinking tokens one attempt was billed for.
|
||||
// Zero also covers providers that never report the split, which keeps the
|
||||
// missing-reasoning classifier from calling a silent model a lost field.
|
||||
func usageReasoningTokens(u *provider.Usage) int {
|
||||
if u == nil {
|
||||
return 0
|
||||
}
|
||||
return u.ReasoningTokens
|
||||
}
|
||||
|
||||
func sawSpeculativeSamplingOutput(result streamedTurn) bool {
|
||||
return result.text != "" || result.reasoning != "" || result.maxArgChars > 0 ||
|
||||
result.partialToolStarted || len(result.calls) > 0 || len(result.partialCalls) > 0
|
||||
|
||||
@@ -39,13 +39,11 @@ func (a *Agent) handleSamplingError(
|
||||
ctx context.Context,
|
||||
attemptID string,
|
||||
attempt int,
|
||||
streamSink *deferredStreamSink,
|
||||
frozen *samplingRequest,
|
||||
result, last streamedTurn,
|
||||
billable *provider.Usage,
|
||||
) (retry bool, terminal streamedTurn) {
|
||||
if provider.IsStreamInterrupted(result.err) && attempt < maxSamplingAttempts {
|
||||
streamSink.Discard()
|
||||
reason := provider.StreamInterruptReason(result.err)
|
||||
a.emitStreamAttempt(attemptID, event.StreamAttemptDiscard, attempt, reason, result.err, 0)
|
||||
delay := streamRetryDelay(attempt)
|
||||
@@ -60,13 +58,11 @@ func (a *Agent) handleSamplingError(
|
||||
return true, streamedTurn{}
|
||||
}
|
||||
if attempt < maxSamplingAttempts && a.window().recoverContextOverflow(ctx, frozen, result.err) {
|
||||
streamSink.Discard()
|
||||
a.emitStreamAttempt(attemptID, event.StreamAttemptDiscard, attempt, "context overflow", result.err, 0)
|
||||
return true, streamedTurn{}
|
||||
}
|
||||
// Exhausted retries or non-retryable error: leave the last speculative UI
|
||||
// visible (no discard) so LocalOnly can mirror it.
|
||||
streamSink.Flush()
|
||||
if frozen != nil && frozen.overflowRecovery != nil && last.err != nil {
|
||||
last.err = fmt.Errorf("%w (context recovery did not help: %w)", last.err, frozen.overflowRecovery)
|
||||
}
|
||||
|
||||
@@ -40,10 +40,6 @@ type agentServices struct {
|
||||
// sink receives the turn's typed event stream. Frontends decide how to
|
||||
// render it; never nil because New defaults it to event.Discard.
|
||||
sink event.Sink
|
||||
// warnState rate-limits recovery retries across sessions and processes by an
|
||||
// opaque provider-configuration fingerprint (#7059). The legacy type and file
|
||||
// names preserve the on-disk v2 contract. nil keeps in-memory gating only.
|
||||
warnState *missingReasoningWarnState
|
||||
// gate is the per-call permission gate for both standard and Plan
|
||||
// workflows. nil disables gating entirely.
|
||||
gate Gate
|
||||
@@ -133,7 +129,6 @@ func newAgentServices(
|
||||
memQueue: opts.MemoryQueue,
|
||||
writeScheduler: opts.WriteScheduler,
|
||||
workspaceLease: opts.WorkspaceLease,
|
||||
warnState: missingReasoningWarnStateFor(opts.MissingReasoningWarnStateDir),
|
||||
mutationObserver: opts.MutationObserver,
|
||||
evidenceSeal: opts.EvidenceSeal,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ type sessionRuntime struct {
|
||||
// claimYields are contended write-claim yields awaiting a receipt.
|
||||
claimYields claimYieldLog
|
||||
|
||||
missingReasoning missingReasoningWatch
|
||||
// mode is the model mode the conversation asked for; a new one starts off.
|
||||
mode requestModeState
|
||||
// effort is the depth a session switched to without rebuilding the provider.
|
||||
@@ -70,7 +69,6 @@ func (r *sessionRuntime) reset(s *sessionstore.Session) {
|
||||
r.cacheHit.Store(0)
|
||||
r.cacheMiss.Store(0)
|
||||
r.output.reset()
|
||||
r.missingReasoning = missingReasoningWatch{}
|
||||
r.mode.reset()
|
||||
r.win.reset()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"go/token"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sessionReset names the fields a new conversation starts from. taskRuntime
|
||||
@@ -14,14 +13,13 @@ import (
|
||||
// holding atomics and mutexes cannot be assigned, so reset must name each field
|
||||
// and this list is what keeps it honest.
|
||||
var sessionReset = map[string]bool{
|
||||
"mu": true,
|
||||
"conversation": true,
|
||||
"output": true,
|
||||
"cacheHit": true,
|
||||
"cacheMiss": true,
|
||||
"missingReasoning": true,
|
||||
"mode": true,
|
||||
"win": true, // windowState.reset, held to its own lists below
|
||||
"mu": true,
|
||||
"conversation": true,
|
||||
"output": true,
|
||||
"cacheHit": true,
|
||||
"cacheMiss": true,
|
||||
"mode": true,
|
||||
"win": true, // windowState.reset, held to its own lists below
|
||||
}
|
||||
|
||||
// sessionCarryOver names the fields reset deliberately leaves alone, each with
|
||||
@@ -178,11 +176,9 @@ func TestSetSessionRestartsTheConversationState(t *testing.T) {
|
||||
a := &Agent{}
|
||||
a.sess.cacheHit.Store(11)
|
||||
a.sess.cacheMiss.Store(7)
|
||||
a.sess.missingReasoning = missingReasoningWatch{active: true, stateRecorded: true, healthyStreak: 2}
|
||||
a.sess.win.compaction.stuck = true
|
||||
a.sess.win.compaction.lastReported = maintenanceReport{reason: NoopNoNewClosedPrefix, turn: 9}
|
||||
a.sess.win.compactionState = sessionstore.CompactionState{}
|
||||
a.unwrittenResolve.at = time.Unix(1, 0)
|
||||
|
||||
next := sessionstore.NewSession("")
|
||||
a.SetSession(next)
|
||||
@@ -193,9 +189,6 @@ func TestSetSessionRestartsTheConversationState(t *testing.T) {
|
||||
if a.sess.cacheHit.Load() != 0 || a.sess.cacheMiss.Load() != 0 {
|
||||
t.Errorf("cache tallies = %d/%d, want a fresh aggregate", a.sess.cacheHit.Load(), a.sess.cacheMiss.Load())
|
||||
}
|
||||
if a.sess.missingReasoning != (missingReasoningWatch{}) {
|
||||
t.Errorf("missingReasoning = %+v, want the incident to end with its conversation", a.sess.missingReasoning)
|
||||
}
|
||||
if a.sess.win.compaction.stuck || a.sess.win.compaction.lastReported.turn != 0 {
|
||||
t.Errorf("compaction progress = stuck:%t lastReported:%+v, want it restarted",
|
||||
a.sess.win.compaction.stuck, a.sess.win.compaction.lastReported)
|
||||
@@ -203,7 +196,4 @@ func TestSetSessionRestartsTheConversationState(t *testing.T) {
|
||||
if a.sess.win.cacheState != CacheStateUnknown {
|
||||
t.Errorf("cacheState = %q, want %q", a.sess.win.cacheState, CacheStateUnknown)
|
||||
}
|
||||
if a.unwrittenResolve.at.IsZero() {
|
||||
t.Error("unwrittenResolve was cleared; the retry it owes belongs to the provider configuration, not the conversation")
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ func TestProtocolRecoveryCarriesTheChildItHappenedIn(t *testing.T) {
|
||||
defer tracker.finish(nil, nil)
|
||||
|
||||
event.RecordProtocolRecovery(tracker.wrap(), event.ProtocolRecoveryAudit{
|
||||
Kind: event.ProtocolRecoveryMissingReasoningDetected,
|
||||
Kind: event.ProtocolRecoveryKind("test"),
|
||||
})
|
||||
event.RecordProtocolRecovery(parent, event.ProtocolRecoveryAudit{
|
||||
Kind: event.ProtocolRecoveryMissingReasoningDetected,
|
||||
Kind: event.ProtocolRecoveryKind("test"),
|
||||
})
|
||||
|
||||
if len(parent.audits) != 2 {
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestRecorderCapturesGuardianUsageAndPreservesProtocolAudit(t *testing.T) {
|
||||
ModelRef: "deepseek/deepseek-v4-flash",
|
||||
Guardian: event.GuardianResult{Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}},
|
||||
})
|
||||
event.RecordProtocolRecovery(r, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningRetryRecovered})
|
||||
event.RecordProtocolRecovery(r, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryKind("test")})
|
||||
flushRecorder(t, r)
|
||||
|
||||
day := dayStart(time.Now())
|
||||
@@ -98,7 +98,7 @@ func TestRecorderCapturesGuardianUsageAndPreservesProtocolAudit(t *testing.T) {
|
||||
if got.Tokens != 15 || got.TopModel != "deepseek/deepseek-v4-flash" {
|
||||
t.Fatalf("guardian usage = %+v", got)
|
||||
}
|
||||
if len(inner.protocol) != 1 || inner.protocol[0].Kind != event.ProtocolRecoveryMissingReasoningRetryRecovered {
|
||||
if len(inner.protocol) != 1 || inner.protocol[0].Kind != event.ProtocolRecoveryKind("test") {
|
||||
t.Fatalf("protocol audit was not forwarded: %+v", inner.protocol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestRecorderRecordsAndForwardsOptionalCapabilities(t *testing.T) {
|
||||
}
|
||||
|
||||
r.RecordReadinessAudit(hostaudit.ReadinessAudit{Result: hostaudit.ReadinessBlocked, MissingVerification: 2})
|
||||
r.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningDetected})
|
||||
r.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryKind("test")})
|
||||
r.RecordTurnCompletion()
|
||||
r.RecordOutcomeProgress(hostaudit.OutcomeSample{Round: 3, Exploration: 2, Objective: 1, LegacyGain: 4})
|
||||
r.RecordCompletionReport(event.CompletionReportAudit{
|
||||
@@ -135,7 +135,7 @@ func TestRecorderRecordsAndForwardsOptionalCapabilities(t *testing.T) {
|
||||
if recs[0].ReadinessAudit == nil || recs[0].ReadinessAudit.Result != "blocked" || recs[0].ReadinessAudit.MissingVerification != 2 {
|
||||
t.Errorf("readiness record = %+v", recs[0].ReadinessAudit)
|
||||
}
|
||||
if recs[1].ProtocolRecovery != string(event.ProtocolRecoveryMissingReasoningDetected) {
|
||||
if recs[1].ProtocolRecovery != string(event.ProtocolRecoveryKind("test")) {
|
||||
t.Errorf("protocol recovery record = %q", recs[1].ProtocolRecovery)
|
||||
}
|
||||
if !recs[2].TurnCompletion {
|
||||
|
||||
+15
-16
@@ -217,22 +217,21 @@ type InitializeResult struct {
|
||||
|
||||
// ProviderDescriptor is a generated Extension Protocol v2 wire DTO.
|
||||
type ProviderDescriptor struct {
|
||||
Ref string `json:"ref" validate:"nonempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ContextWindow int `json:"contextWindow,omitempty" validate:"min=0"`
|
||||
PricingCurrency string `json:"pricingCurrency,omitempty"`
|
||||
CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty" validate:"min=0"`
|
||||
InputPerMillion float64 `json:"inputPerMillion,omitempty" validate:"min=0"`
|
||||
OutputPerMillion float64 `json:"outputPerMillion,omitempty" validate:"min=0"`
|
||||
Vision bool `json:"vision,omitempty"`
|
||||
Tools bool `json:"tools,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty"`
|
||||
Efforts []string `json:"efforts,omitempty"`
|
||||
DefaultEffort string `json:"defaultEffort,omitempty"`
|
||||
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
|
||||
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
|
||||
WarnOnMissingToolCallReasoning bool `json:"warnOnMissingToolCallReasoning,omitempty"`
|
||||
Ref string `json:"ref" validate:"nonempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ContextWindow int `json:"contextWindow,omitempty" validate:"min=0"`
|
||||
PricingCurrency string `json:"pricingCurrency,omitempty"`
|
||||
CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty" validate:"min=0"`
|
||||
InputPerMillion float64 `json:"inputPerMillion,omitempty" validate:"min=0"`
|
||||
OutputPerMillion float64 `json:"outputPerMillion,omitempty" validate:"min=0"`
|
||||
Vision bool `json:"vision,omitempty"`
|
||||
Tools bool `json:"tools,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty"`
|
||||
Efforts []string `json:"efforts,omitempty"`
|
||||
DefaultEffort string `json:"defaultEffort,omitempty"`
|
||||
ToolCallReasoning bool `json:"toolCallReasoning,omitempty"`
|
||||
ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"`
|
||||
}
|
||||
|
||||
// UIActionDecl is a generated Extension Protocol v2 wire DTO.
|
||||
|
||||
+96
-128
@@ -5,28 +5,28 @@
|
||||
"claude-dialect": 1,
|
||||
"codeowners": 0,
|
||||
"commented-code": 0,
|
||||
"complexity": 712,
|
||||
"css-important": 210,
|
||||
"css-size": 9862,
|
||||
"complexity": 676,
|
||||
"css-important": 204,
|
||||
"css-size": 9804,
|
||||
"doc-language": 0,
|
||||
"doc-owner": 27,
|
||||
"doc-prose": 2141,
|
||||
"doc-prose": 2128,
|
||||
"error-text": 9,
|
||||
"essay": 2696,
|
||||
"file-size": 19628,
|
||||
"essay": 2600,
|
||||
"file-size": 18536,
|
||||
"flat-view": 0,
|
||||
"frontend-parity": 13,
|
||||
"function-size": 2652,
|
||||
"function-size": 2494,
|
||||
"layer-order": 0,
|
||||
"layering": 0,
|
||||
"marker": 0,
|
||||
"narrative": 0,
|
||||
"orphan": 66,
|
||||
"process-input": 97,
|
||||
"orphan": 64,
|
||||
"process-input": 96,
|
||||
"refusal-path": 0,
|
||||
"release-note": 661,
|
||||
"stray-file": 0,
|
||||
"struct-state": 31,
|
||||
"struct-state": 30,
|
||||
"window-state": 0,
|
||||
"wire-parity": 0
|
||||
},
|
||||
@@ -70,19 +70,19 @@
|
||||
},
|
||||
"desktop/frontend-next/src/styles/LAYOUT.md": {
|
||||
"doc-owner": 1,
|
||||
"doc-prose": 113
|
||||
"doc-prose": 112
|
||||
},
|
||||
"desktop/frontend-next/src/styles/TOKENS.md": {
|
||||
"doc-owner": 1,
|
||||
"doc-prose": 16
|
||||
"doc-prose": 13
|
||||
},
|
||||
"desktop/frontend-next/src/styles/app.css": {
|
||||
"css-important": 28,
|
||||
"css-size": 5626
|
||||
"css-size": 5579
|
||||
},
|
||||
"desktop/frontend-next/src/styles/studio.css": {
|
||||
"css-important": 182,
|
||||
"css-size": 4236
|
||||
"css-important": 176,
|
||||
"css-size": 4225
|
||||
},
|
||||
"desktop/internal/winuninstall/registry_windows.go": {
|
||||
"essay": 1
|
||||
@@ -97,7 +97,7 @@
|
||||
},
|
||||
"docs/CLI.md": {
|
||||
"doc-owner": 1,
|
||||
"doc-prose": 49
|
||||
"doc-prose": 48
|
||||
},
|
||||
"docs/CLI.zh-CN.md": {
|
||||
"doc-owner": 1,
|
||||
@@ -124,7 +124,7 @@
|
||||
},
|
||||
"docs/HISTORY_SEARCH_CATALOG.md": {
|
||||
"doc-owner": 1,
|
||||
"doc-prose": 8
|
||||
"doc-prose": 6
|
||||
},
|
||||
"docs/PLUGIN_PACKAGES.md": {
|
||||
"doc-prose": 102
|
||||
@@ -143,7 +143,7 @@
|
||||
},
|
||||
"docs/SPEC.md": {
|
||||
"doc-owner": 1,
|
||||
"doc-prose": 339
|
||||
"doc-prose": 333
|
||||
},
|
||||
"docs/THEME_ASSETS.md": {
|
||||
"doc-owner": 1,
|
||||
@@ -188,9 +188,6 @@
|
||||
"internal/assembly/boot/prompt_stability_test.go": {
|
||||
"essay": 4
|
||||
},
|
||||
"internal/assembly/boot/rebuild_subgraph.go": {
|
||||
"function-size": 7
|
||||
},
|
||||
"internal/assembly/boot/reload.go": {
|
||||
"essay": 32
|
||||
},
|
||||
@@ -268,7 +265,7 @@
|
||||
},
|
||||
"internal/contract/config/config.go": {
|
||||
"essay": 30,
|
||||
"file-size": 930,
|
||||
"file-size": 846,
|
||||
"process-input": 3
|
||||
},
|
||||
"internal/contract/config/credentials.go": {
|
||||
@@ -291,12 +288,11 @@
|
||||
},
|
||||
"internal/contract/config/edit.go": {
|
||||
"essay": 9,
|
||||
"file-size": 1238,
|
||||
"file-size": 1213,
|
||||
"process-input": 2
|
||||
},
|
||||
"internal/contract/config/effort.go": {
|
||||
"complexity": 1,
|
||||
"essay": 7
|
||||
"essay": 3
|
||||
},
|
||||
"internal/contract/config/effort_test.go": {
|
||||
"essay": 2
|
||||
@@ -308,7 +304,7 @@
|
||||
"essay": 24,
|
||||
"file-size": 1181,
|
||||
"function-size": 22,
|
||||
"orphan": 2
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/contract/config/main_test.go": {
|
||||
"essay": 1
|
||||
@@ -342,13 +338,13 @@
|
||||
},
|
||||
"internal/contract/config/provider_presets.go": {
|
||||
"claude-dialect": 1,
|
||||
"file-size": 74
|
||||
"file-size": 28
|
||||
},
|
||||
"internal/contract/config/render.go": {
|
||||
"complexity": 89,
|
||||
"complexity": 86,
|
||||
"essay": 1,
|
||||
"file-size": 286,
|
||||
"function-size": 394
|
||||
"file-size": 273,
|
||||
"function-size": 375
|
||||
},
|
||||
"internal/contract/config/vision.go": {
|
||||
"essay": 1,
|
||||
@@ -367,7 +363,7 @@
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/contract/provider/provider.go": {
|
||||
"essay": 37
|
||||
"essay": 35
|
||||
},
|
||||
"internal/contract/provider/retry.go": {
|
||||
"essay": 10
|
||||
@@ -515,7 +511,7 @@
|
||||
"internal/ext/hook/hook.go": {
|
||||
"complexity": 15,
|
||||
"essay": 41,
|
||||
"file-size": 788,
|
||||
"file-size": 774,
|
||||
"process-input": 2
|
||||
},
|
||||
"internal/ext/hook/hook_test.go": {
|
||||
@@ -539,7 +535,7 @@
|
||||
},
|
||||
"internal/ext/installsource/plugin_package.go": {
|
||||
"complexity": 3,
|
||||
"essay": 7,
|
||||
"essay": 4,
|
||||
"function-size": 20
|
||||
},
|
||||
"internal/ext/installsource/skill.go": {
|
||||
@@ -571,7 +567,7 @@
|
||||
},
|
||||
"internal/ext/plugin/plugin.go": {
|
||||
"essay": 28,
|
||||
"file-size": 866,
|
||||
"file-size": 858,
|
||||
"orphan": 3
|
||||
},
|
||||
"internal/ext/plugin/plugin_test.go": {
|
||||
@@ -605,7 +601,7 @@
|
||||
"internal/ext/pluginpkg/pluginpkg.go": {
|
||||
"complexity": 1,
|
||||
"essay": 2,
|
||||
"file-size": 369
|
||||
"file-size": 341
|
||||
},
|
||||
"internal/ext/pluginpkg/state_lock_test.go": {
|
||||
"essay": 1
|
||||
@@ -623,8 +619,8 @@
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/ext/skill/skill.go": {
|
||||
"essay": 5,
|
||||
"file-size": 548,
|
||||
"essay": 1,
|
||||
"file-size": 450,
|
||||
"process-input": 2
|
||||
},
|
||||
"internal/ext/skill/tools.go": {
|
||||
@@ -655,20 +651,16 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/frontend/acp/protocol.go": {
|
||||
"essay": 13,
|
||||
"file-size": 5
|
||||
"essay": 13
|
||||
},
|
||||
"internal/frontend/acp/server.go": {
|
||||
"essay": 4
|
||||
},
|
||||
"internal/frontend/acp/server_test.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/frontend/acp/service.go": {
|
||||
"essay": 49,
|
||||
"file-size": 806,
|
||||
"essay": 40,
|
||||
"file-size": 756,
|
||||
"function-size": 97,
|
||||
"struct-state": 2
|
||||
"struct-state": 1
|
||||
},
|
||||
"internal/frontend/acp/service_lock_test.go": {
|
||||
"essay": 7
|
||||
@@ -680,7 +672,7 @@
|
||||
"essay": 6
|
||||
},
|
||||
"internal/frontend/acp/status.go": {
|
||||
"file-size": 20
|
||||
"file-size": 11
|
||||
},
|
||||
"internal/frontend/acp/switch_recovery_test.go": {
|
||||
"essay": 7
|
||||
@@ -723,7 +715,7 @@
|
||||
"essay": 3
|
||||
},
|
||||
"internal/frontend/cli/review.go": {
|
||||
"essay": 8
|
||||
"essay": 6
|
||||
},
|
||||
"internal/frontend/cli/run_metrics.go": {
|
||||
"essay": 1
|
||||
@@ -737,8 +729,7 @@
|
||||
"function-size": 153
|
||||
},
|
||||
"internal/frontend/cli/serve_frontend.go": {
|
||||
"complexity": 2,
|
||||
"function-size": 68
|
||||
"function-size": 64
|
||||
},
|
||||
"internal/frontend/cli/session_lease.go": {
|
||||
"essay": 4
|
||||
@@ -751,9 +742,7 @@
|
||||
"function-size": 71
|
||||
},
|
||||
"internal/frontend/cli/upgrade.go": {
|
||||
"complexity": 1,
|
||||
"essay": 2,
|
||||
"function-size": 28
|
||||
"essay": 2
|
||||
},
|
||||
"internal/frontend/doctor/report.go": {
|
||||
"essay": 3
|
||||
@@ -771,8 +760,8 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/frontend/serve/serve.go": {
|
||||
"essay": 19,
|
||||
"file-size": 259
|
||||
"essay": 17,
|
||||
"file-size": 167
|
||||
},
|
||||
"internal/frontend/serve/serve_lease_test.go": {
|
||||
"essay": 3
|
||||
@@ -796,10 +785,10 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/model/anthropic/anthropic.go": {
|
||||
"complexity": 38,
|
||||
"essay": 31,
|
||||
"file-size": 68,
|
||||
"function-size": 138,
|
||||
"complexity": 35,
|
||||
"essay": 29,
|
||||
"file-size": 31,
|
||||
"function-size": 117,
|
||||
"struct-state": 5
|
||||
},
|
||||
"internal/model/anthropic/anthropic_test.go": {
|
||||
@@ -809,10 +798,10 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/model/openai/openai.go": {
|
||||
"complexity": 56,
|
||||
"essay": 30,
|
||||
"file-size": 477,
|
||||
"function-size": 227
|
||||
"complexity": 54,
|
||||
"essay": 28,
|
||||
"file-size": 425,
|
||||
"function-size": 209
|
||||
},
|
||||
"internal/model/openai/openai_test.go": {
|
||||
"essay": 5
|
||||
@@ -821,14 +810,14 @@
|
||||
"essay": 7
|
||||
},
|
||||
"internal/model/responses/responses.go": {
|
||||
"essay": 11,
|
||||
"essay": 4,
|
||||
"struct-state": 4
|
||||
},
|
||||
"internal/model/responses/responses_test.go": {
|
||||
"essay": 5
|
||||
"essay": 4
|
||||
},
|
||||
"internal/model/responses/vendor.go": {
|
||||
"essay": 19
|
||||
"essay": 13
|
||||
},
|
||||
"internal/platform/browser/discover.go": {
|
||||
"process-input": 1
|
||||
@@ -912,7 +901,7 @@
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/platform/repair/config.go": {
|
||||
"complexity": 12
|
||||
"complexity": 11
|
||||
},
|
||||
"internal/platform/repair/config_test.go": {
|
||||
"essay": 1
|
||||
@@ -926,7 +915,7 @@
|
||||
},
|
||||
"internal/platform/repair/plan.go": {
|
||||
"essay": 2,
|
||||
"file-size": 48,
|
||||
"file-size": 44,
|
||||
"orphan": 2
|
||||
},
|
||||
"internal/platform/repair/snapshot.go": {
|
||||
@@ -981,12 +970,12 @@
|
||||
},
|
||||
"internal/runtime/agent/agent.go": {
|
||||
"complexity": 32,
|
||||
"essay": 33,
|
||||
"file-size": 1197,
|
||||
"essay": 26,
|
||||
"file-size": 1151,
|
||||
"function-size": 95
|
||||
},
|
||||
"internal/runtime/agent/agent_options.go": {
|
||||
"essay": 10
|
||||
"essay": 7
|
||||
},
|
||||
"internal/runtime/agent/ask.go": {
|
||||
"essay": 1
|
||||
@@ -1024,15 +1013,12 @@
|
||||
"internal/runtime/agent/dependent_writer_preview_test.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/runtime/agent/empty_final_test.go": {
|
||||
"essay": 5
|
||||
},
|
||||
"internal/runtime/agent/execute_batch.go": {
|
||||
"complexity": 22,
|
||||
"function-size": 107
|
||||
"complexity": 18,
|
||||
"function-size": 97
|
||||
},
|
||||
"internal/runtime/agent/execute_one.go": {
|
||||
"essay": 12
|
||||
"essay": 10
|
||||
},
|
||||
"internal/runtime/agent/extensions.go": {
|
||||
"essay": 64
|
||||
@@ -1046,10 +1032,6 @@
|
||||
"internal/runtime/agent/listsessions_bench_test.go": {
|
||||
"essay": 4
|
||||
},
|
||||
"internal/runtime/agent/reasoning_warn_state.go": {
|
||||
"complexity": 2,
|
||||
"essay": 14
|
||||
},
|
||||
"internal/runtime/agent/recovery_gate.go": {
|
||||
"essay": 8
|
||||
},
|
||||
@@ -1057,8 +1039,7 @@
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/runtime/agent/run_loop.go": {
|
||||
"essay": 16,
|
||||
"function-size": 2
|
||||
"essay": 15
|
||||
},
|
||||
"internal/runtime/agent/run_usage.go": {
|
||||
"complexity": 1,
|
||||
@@ -1109,10 +1090,10 @@
|
||||
"essay": 5
|
||||
},
|
||||
"internal/runtime/delegation/task.go": {
|
||||
"complexity": 12,
|
||||
"essay": 12,
|
||||
"file-size": 259,
|
||||
"function-size": 94,
|
||||
"complexity": 8,
|
||||
"essay": 10,
|
||||
"file-size": 177,
|
||||
"function-size": 61,
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/runtime/delegation/task_test.go": {
|
||||
@@ -1141,7 +1122,7 @@
|
||||
},
|
||||
"internal/runtime/recovery/gate.go": {
|
||||
"essay": 4,
|
||||
"file-size": 394
|
||||
"file-size": 391
|
||||
},
|
||||
"internal/runtime/recovery/rules.go": {
|
||||
"complexity": 19,
|
||||
@@ -1175,7 +1156,7 @@
|
||||
},
|
||||
"internal/runtime/usecap/usecapability.go": {
|
||||
"essay": 9,
|
||||
"file-size": 712,
|
||||
"file-size": 657,
|
||||
"function-size": 2
|
||||
},
|
||||
"internal/runtime/writeclaim/scheduler.go": {
|
||||
@@ -1248,8 +1229,7 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/session/control/approval_orchestration.go": {
|
||||
"essay": 38,
|
||||
"frontend-parity": 1
|
||||
"essay": 38
|
||||
},
|
||||
"internal/session/control/attachments.go": {
|
||||
"orphan": 1
|
||||
@@ -1259,7 +1239,7 @@
|
||||
},
|
||||
"internal/session/control/controller.go": {
|
||||
"essay": 29,
|
||||
"file-size": 461
|
||||
"file-size": 421
|
||||
},
|
||||
"internal/session/control/controller_test.go": {
|
||||
"essay": 9
|
||||
@@ -1282,18 +1262,11 @@
|
||||
"internal/session/control/goal_control.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/session/control/goalusage.go": {
|
||||
"essay": 2
|
||||
},
|
||||
"internal/session/control/headless_approval_test.go": {
|
||||
"essay": 10
|
||||
},
|
||||
"internal/session/control/inbox.go": {
|
||||
"frontend-parity": 2
|
||||
},
|
||||
"internal/session/control/input.go": {
|
||||
"essay": 3,
|
||||
"frontend-parity": 2
|
||||
"essay": 3
|
||||
},
|
||||
"internal/session/control/input_test.go": {
|
||||
"essay": 4
|
||||
@@ -1327,7 +1300,7 @@
|
||||
},
|
||||
"internal/session/control/refs.go": {
|
||||
"essay": 5,
|
||||
"file-size": 403,
|
||||
"file-size": 391,
|
||||
"process-input": 1
|
||||
},
|
||||
"internal/session/control/session_lease_keeper.go": {
|
||||
@@ -1349,7 +1322,6 @@
|
||||
"essay": 4
|
||||
},
|
||||
"internal/session/control/slash.go": {
|
||||
"complexity": 1,
|
||||
"essay": 2
|
||||
},
|
||||
"internal/session/control/steer_fallback_test.go": {
|
||||
@@ -1360,32 +1332,31 @@
|
||||
"function-size": 4
|
||||
},
|
||||
"internal/session/control/turn_orchestrator.go": {
|
||||
"essay": 10,
|
||||
"function-size": 3
|
||||
"essay": 5
|
||||
},
|
||||
"internal/session/control/turn_orchestrator_test.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/session/control/turn_submit.go": {
|
||||
"complexity": 3,
|
||||
"complexity": 2,
|
||||
"essay": 4,
|
||||
"function-size": 12
|
||||
"function-size": 8
|
||||
},
|
||||
"internal/state/checkpoint/barrier.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/state/checkpoint/checkpoint.go": {
|
||||
"essay": 8,
|
||||
"file-size": 250
|
||||
"file-size": 150
|
||||
},
|
||||
"internal/state/checkpoint/observer.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/state/checkpoint/transaction.go": {
|
||||
"complexity": 21,
|
||||
"complexity": 16,
|
||||
"essay": 2,
|
||||
"file-size": 664,
|
||||
"function-size": 33
|
||||
"file-size": 648,
|
||||
"function-size": 29
|
||||
},
|
||||
"internal/state/history/indexed.go": {
|
||||
"orphan": 1
|
||||
@@ -1402,12 +1373,11 @@
|
||||
"process-input": 1
|
||||
},
|
||||
"internal/state/memory/store.go": {
|
||||
"essay": 6,
|
||||
"file-size": 12
|
||||
"essay": 6
|
||||
},
|
||||
"internal/state/memory/store_v2.go": {
|
||||
"complexity": 14,
|
||||
"function-size": 10
|
||||
"complexity": 10,
|
||||
"function-size": 7
|
||||
},
|
||||
"internal/state/migration/migration.go": {
|
||||
"process-input": 2
|
||||
@@ -1417,10 +1387,10 @@
|
||||
"orphan": 3
|
||||
},
|
||||
"internal/state/sessionstore/migrate.go": {
|
||||
"complexity": 17,
|
||||
"complexity": 15,
|
||||
"essay": 23,
|
||||
"file-size": 135,
|
||||
"function-size": 13
|
||||
"file-size": 134,
|
||||
"function-size": 11
|
||||
},
|
||||
"internal/state/sessionstore/normalize.go": {
|
||||
"essay": 14
|
||||
@@ -1440,7 +1410,7 @@
|
||||
"internal/state/sessionstore/save.go": {
|
||||
"complexity": 36,
|
||||
"essay": 109,
|
||||
"file-size": 1267,
|
||||
"file-size": 1200,
|
||||
"function-size": 95
|
||||
},
|
||||
"internal/state/sessionstore/save_test.go": {
|
||||
@@ -1460,17 +1430,16 @@
|
||||
"orphan": 2
|
||||
},
|
||||
"internal/state/sessionstore/session_display_read_model.go": {
|
||||
"orphan": 2
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/state/sessionstore/session_events.go": {
|
||||
"essay": 8,
|
||||
"file-size": 14
|
||||
"essay": 8
|
||||
},
|
||||
"internal/state/sessionstore/session_events_test.go": {
|
||||
"essay": 3
|
||||
},
|
||||
"internal/state/sessionstore/session_lease.go": {
|
||||
"essay": 13
|
||||
"essay": 10
|
||||
},
|
||||
"internal/state/sessionstore/session_lease_test.go": {
|
||||
"essay": 4
|
||||
@@ -1502,8 +1471,7 @@
|
||||
},
|
||||
"internal/tools/builtin/bash.go": {
|
||||
"essay": 11,
|
||||
"orphan": 1,
|
||||
"process-input": 1
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/tools/builtin/bash_delete.go": {
|
||||
"process-input": 3
|
||||
@@ -1573,8 +1541,8 @@
|
||||
"essay": 5
|
||||
},
|
||||
"internal/tools/jobs/jobs.go": {
|
||||
"essay": 42,
|
||||
"file-size": 1123,
|
||||
"essay": 35,
|
||||
"file-size": 1044,
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/tools/jobs/jobs_extra_test.go": {
|
||||
@@ -1677,6 +1645,9 @@
|
||||
"sdk/go/wire.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"tools/repolint/frontendparity.go": {
|
||||
"frontend-parity": 13
|
||||
},
|
||||
"tools/repolint/orphan.go": {
|
||||
"essay": 2
|
||||
},
|
||||
@@ -1694,9 +1665,6 @@
|
||||
},
|
||||
"workers/crash-report/src/index.ts": {
|
||||
"file-size": 686
|
||||
},
|
||||
"tools/repolint/frontendparity.go": {
|
||||
"frontend-parity": 13
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user