diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md index 4b9c2d76..26141748 100644 --- a/agent-contract/inner/edge-config-runtime-refresh.md +++ b/agent-contract/inner/edge-config-runtime-refresh.md @@ -51,7 +51,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c - `ConcreteProtocolProfile.ResolveOperationURL(op)`는 완성된 resolved upstream URL을 반환한다. absolute operation URL은 그대로 보존하며 relative operation path는 normalized base URL에 1회 join된다. 표기된 `/v1/...` 값은 return value가 아니라 operation-path input이다 (`models` → `GET /v1/models` 또는 `GET /anthropic/v1/models`, `chat_completions` → `POST /v1/chat/completions`, `messages` → `POST /v1/messages`, `count_tokens` → `POST /v1/messages/count_tokens`, `responses` → `POST /v1/responses`). - `validOperationsByDriver`는 driver별 허용 operation의 closed set이다. `openai_chat`은 `models`, `chat_completions`, `responses`, `count_tokens`를 허용한다. `anthropic_messages`는 `models`, `messages`, `count_tokens`를 허용한다. `openai_responses`는 `models`, `responses`, `count_tokens`를 허용한다. - `openai.provider_auth` is a legacy-mode-only request-time raw provider token forwarding rule. `enabled=false` is the default; when enabled in legacy mode, omitted fields resolve to `from_header=X-IOP-Provider-Authorization`, `target_header=Authorization`, `scheme=Bearer`, and `required=true`. Managed mode rejects this configuration and rejects a caller-supplied legacy provider credential header. -- `openai.stream_evidence_gate` configures request-local Recovery Coordinator limits, the ingress snapshot bound, and optional semantic policy. Every supported Chat Completions, normalized Responses, provider tunnel, provider-pool, and tool-validation response already uses the `packages/go/streamgate` request runtime as its sole liveness owner. `enabled` defaults to false and controls only configured semantic filter registration/capability admission; false preserves endpoint-native compatibility inside the same runtime and does not restore a legacy response or retry owner. `max_request_fault_recovery`는 요청당 전체 fault recovery 상한(`0..3`, omitted 기본값 3, explicit 0은 모든 fault recovery 비활성화)이다. `max_strategy_fault_recovery`는 fault strategy(exact_replay/continuation_repair/schema_repair)별 상한(`0..max_request_fault_recovery`, omitted 기본값은 effective request total 상속, explicit 0은 해당 strategy 비활성화)이며 request-start 시점에 immutable runtime option snapshot으로 각 fault strategy에 동일하게 적용된다. `max_ingress_snapshot_bytes`는 ingress snapshot 바이트 상한(`1..16777216` [16 MiB], omitted/0 기본값 16 MiB)이다. `environment`는 request-start selector snapshot이며 `dev|dev-corp`만 허용하고 omitted 기본값은 `dev`다. `filters[]`는 unique `filter` (`repeat_guard|schema_gate|provider_error`) policy이다. `enabled` omitted=true, `enforcement` omitted=`blocking`, `capability` omitted=`output.`, `hold_evidence_runes` omitted=500, `timeout_ms` omitted=5000으로 정규화하며 selector는 `environment|model_group|model|provider`로만 filter enablement/enforcement를 보정한다. base-disabled filter도 registry snapshot에 남아 더 구체적인 selector가 활성화할 수 있고, 실제 target에서 활성화된 `blocking` filter만 provider capability admission에 참여한다. `observe_only`는 evidence를 만들지만 admission을 막지 않는다. `repeat_guard` uses the configured rune bound for active request-local history/current-stream inspection and stores only bounded fingerprints, counts, and offsets in its semantic snapshot and observations. `schema_gate` and `provider_error` remain lifecycle foundations until their matcher Tasks; an unmatched provider error never creates exact replay. Config accepts no caller/agent selector. +- `openai.stream_evidence_gate` configures request-local Recovery Coordinator limits, the ingress snapshot bound, and optional semantic policy. Every supported Chat Completions, normalized Responses, provider tunnel, provider-pool, and tool-validation response already uses the `packages/go/streamgate` request runtime as its sole liveness owner. `enabled` defaults to false and controls only configured semantic filter registration/capability admission; false preserves endpoint-native compatibility inside the same runtime and does not restore a legacy response or retry owner. `max_request_fault_recovery`는 요청당 전체 fault recovery 상한(`0..3`, omitted 기본값 3, explicit 0은 모든 fault recovery 비활성화)이다. `max_strategy_fault_recovery`는 fault strategy(exact_replay/continuation_repair/schema_repair)별 상한(`0..max_request_fault_recovery`, omitted 기본값은 effective request total 상속, explicit 0은 해당 strategy 비활성화)이며 request-start 시점에 immutable runtime option snapshot으로 각 fault strategy에 동일하게 적용된다. base 또는 selector에서 활성화될 수 있는 blocking `repeat_guard`는 continuation recovery를 생성하므로 두 recovery 상한이 모두 최소 1이어야 하며, 모순된 설정은 load 시 거부한다. `max_ingress_snapshot_bytes`는 ingress snapshot 바이트 상한(`1..16777216` [16 MiB], omitted/0 기본값 16 MiB)이다. `environment`는 request-start selector snapshot이며 `dev|dev-corp`만 허용하고 omitted 기본값은 `dev`다. `filters[]`는 unique `filter` (`repeat_guard|schema_gate|provider_error`) policy이다. `enabled` omitted=true, `enforcement` omitted=`blocking`, `capability` omitted=`output.`, `hold_evidence_runes` omitted=500, `timeout_ms` omitted=5000으로 정규화하며 selector는 `environment|model_group|model|provider`로만 filter enablement/enforcement를 보정한다. base-disabled filter도 registry snapshot에 남아 더 구체적인 selector가 활성화할 수 있고, 실제 target에서 활성화된 `blocking` filter만 provider capability admission에 참여한다. `observe_only`는 evidence를 만들지만 admission을 막지 않는다. `repeat_guard` uses the configured rune bound for active request-local history/current-stream inspection and stores only bounded fingerprints, counts, and offsets in its semantic snapshot and observations. `schema_gate` and `provider_error` remain lifecycle foundations until their matcher Tasks; an unmatched provider error never creates exact replay. Config accepts no caller/agent selector. - `openai.stream_evidence_gate` 설정은 request-start 시점에 snapshot으로 고정되며 in-flight request의 실행 중 refresh 영향에서 격리된다 (generation isolation). 새 generation의 설정은 이후 시작되는 새 request에만 적용된다. - The internal `response_stalled` recovery registration is always present for a supported OpenAI runtime request. It is not a member of `filters[]`, has no configurable capability, and does not participate in provider capability admission. It consumes only an Edge-confirmed typed handoff; configurable `provider_error` keeps its generic foundation behavior. - The request-start `models[].context_window_tokens` snapshot is the resume builder's target context bound. Each Chat/Responses runtime shares one request-local content/reasoning recorder across its initial and recovery event sources. A continuation rebuild uses only that recorder and the fixed directive; unknown or exceeded context rejects the rebuild before re-admission. An omitted caller temperature selects `0.2`, `0.4`, then `0.6` by continuation strategy attempt, while an explicit value is preserved. Recorder state and its raw values remain request-local, are consumed once per attempt, and are never added to config refresh state or observations. Repeat history and counters are pinned to the same request-start config generation and are not refreshable TTL/session state. diff --git a/agent-spec/runtime/stream-evidence-gate.md b/agent-spec/runtime/stream-evidence-gate.md index 111164cf..e125a369 100644 --- a/agent-spec/runtime/stream-evidence-gate.md +++ b/agent-spec/runtime/stream-evidence-gate.md @@ -110,7 +110,7 @@ sequenceDiagram ## 설정/데이터/이벤트 - `openai.stream_evidence_gate.enabled` defaults to `false` and controls only configured semantic filters and their capability admission. The Core owns the supported response/liveness lifecycle in both states, while disabled mode preserves endpoint-native compatibility through runtime adapters. -- `max_request_fault_recovery`는 0..3, `max_strategy_fault_recovery`는 0..request-total이고 생략 시 request-total을 상속한다. +- `max_request_fault_recovery`는 0..3, `max_strategy_fault_recovery`는 0..request-total이고 생략 시 request-total을 상속한다. base 또는 selector에서 활성화될 수 있는 blocking `repeat_guard`는 두 상한이 모두 최소 1이어야 한다. - `max_ingress_snapshot_bytes`는 1..16777216이며 생략 시 16 MiB다. raw body limit은 첫 read 전에 적용되고 canonical body, typed view와 rebuild peak가 같은 request-local ledger에 포함된다. - Stream Evidence Gate 설정 변경은 현재 restart-required다. request가 시작된 뒤 config/registry snapshot은 바뀌지 않는다. - The production Core registry includes the common Noop filter, configured active `repeat_guard`, schema/provider-error lifecycle foundations, and applicable request-local tool validation. Repeat detection uses the configured 500-rune default, never time-based release, and returns a continuation only before a tool/side-effect boundary. Provider-error still records unmatched errors as pass until its matcher Task. diff --git a/docs/edge-local-dev-guide.md b/docs/edge-local-dev-guide.md index 1b1d966e..d0408a8c 100644 --- a/docs/edge-local-dev-guide.md +++ b/docs/edge-local-dev-guide.md @@ -83,7 +83,7 @@ Agent dispatcher silence safety = 70000 Edge request hard timeout > 70000 ``` -dev-runtime은 `max_request_fault_recovery: 0`과 `max_strategy_fault_recovery: 0`을 명시하고 Pi agent retry도 비활성화한다. 따라서 provider 무진행은 Node가 60초에 한 번만 종료하며 Pi·Edge·Dispatcher가 같은 요청을 다시 중첩 실행하지 않는다. +dev-runtime은 blocking `repeat_guard`와 함께 `max_request_fault_recovery: 1`, `max_strategy_fault_recovery: 1`을 사용하고 Pi agent retry는 비활성화한다. 따라서 반복 출력에는 Stream Evidence Gate가 요청 내부 continuation recovery를 최대 한 번 수행하고, 외부 Pi 재시도나 중첩 요청은 만들지 않는다. `response_stall_timeout_ms` 변경은 restart-required다. `config check`와 `config refresh --mode dry-run`에서 이를 확인한 뒤 Edge와 Node를 같은 source ref로 rebuild/restart하고, 각 binary의 build identity와 실행 중인 process identity를 다시 대조한다. tracked 검증 근거에는 source/build/config 식별자, 단조 시간, terminal 개수와 결과 분류만 남기며 prompt, output, token, credential 원문은 기록하지 않는다. diff --git a/packages/go/config/edge_types.go b/packages/go/config/edge_types.go index 9158db3d..3e1481db 100644 --- a/packages/go/config/edge_types.go +++ b/packages/go/config/edge_types.go @@ -540,6 +540,11 @@ func (s StreamEvidenceGateConf) Validate() error { if effStrategy > effTotal { return fmt.Errorf("max_strategy_fault_recovery (%d) must not exceed max_request_fault_recovery (%d)", effStrategy, effTotal) } + for i, filter := range s.Filters { + if s.Enabled && filter.Filter == StreamGateFilterRepeatGuard && filterCanResolveBlocking(filter) && (effTotal == 0 || effStrategy == 0) { + return fmt.Errorf("stream_evidence_gate filters[%d]: blocking repeat_guard requires max_request_fault_recovery and max_strategy_fault_recovery to be at least 1", i) + } + } effIngress := s.EffectiveMaxIngressSnapshotBytes() maxAllowedIngress := 16 * 1024 * 1024 @@ -561,6 +566,29 @@ func (s StreamEvidenceGateConf) Validate() error { return nil } +func filterCanResolveBlocking(filter StreamGateFilterPolicyConf) bool { + if filter.EffectiveEnabled() && filter.EffectiveEnforcement() == StreamGateFilterEnforcementBlocking { + return true + } + for _, selector := range filter.Selectors { + enabled := filter.EffectiveEnabled() + if selector.Enabled != nil { + enabled = *selector.Enabled + } + if !enabled { + continue + } + enforcement := filter.EffectiveEnforcement() + if selector.Enforcement != "" { + enforcement = selector.Enforcement + } + if enforcement == StreamGateFilterEnforcementBlocking { + return true + } + } + return false +} + // EdgeOpenAIProviderAuthConf configures request-time provider token forwarding. // It only describes which caller header to forward and never stores raw token // values in config. diff --git a/packages/go/config/stream_evidence_gate_config_test.go b/packages/go/config/stream_evidence_gate_config_test.go index f26d1127..99b8a4ae 100644 --- a/packages/go/config/stream_evidence_gate_config_test.go +++ b/packages/go/config/stream_evidence_gate_config_test.go @@ -406,6 +406,107 @@ openai: } } +func TestStreamEvidenceGate_BlockingRepeatGuardRequiresRecoveryBudget(t *testing.T) { + boolPtr := func(value bool) *bool { return &value } + zero, one := 0, 1 + tests := []struct { + name string + gate config.StreamEvidenceGateConf + expectErr bool + }{ + { + name: "blocking base with zero budget", + gate: config.StreamEvidenceGateConf{ + Enabled: true, + MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero, + Filters: []config.StreamGateFilterPolicyConf{{Filter: config.StreamGateFilterRepeatGuard}}, + }, + expectErr: true, + }, + { + name: "blocking selector with zero budget", + gate: config.StreamEvidenceGateConf{ + Enabled: true, + MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero, + Filters: []config.StreamGateFilterPolicyConf{{ + Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false), + Selectors: []config.StreamGateFilterSelectorConf{{ + Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enabled: boolPtr(true), + }}, + }}, + }, + expectErr: true, + }, + { + name: "blocking selector with one recovery", + gate: config.StreamEvidenceGateConf{ + Enabled: true, + MaxRequestFaultRecovery: &one, MaxStrategyFaultRecovery: &one, + Filters: []config.StreamGateFilterPolicyConf{{ + Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false), + Selectors: []config.StreamGateFilterSelectorConf{{ + Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enabled: boolPtr(true), + }}, + }}, + }, + }, + { + name: "observe only with zero budget", + gate: config.StreamEvidenceGateConf{ + Enabled: true, + MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero, + Filters: []config.StreamGateFilterPolicyConf{{ + Filter: config.StreamGateFilterRepeatGuard, Enforcement: config.StreamGateFilterEnforcementObserveOnly, + }}, + }, + }, + { + name: "blocking selector enforcement with zero budget", + gate: config.StreamEvidenceGateConf{ + Enabled: true, + MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero, + Filters: []config.StreamGateFilterPolicyConf{{ + Filter: config.StreamGateFilterRepeatGuard, Enforcement: config.StreamGateFilterEnforcementObserveOnly, + Selectors: []config.StreamGateFilterSelectorConf{{ + Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enforcement: config.StreamGateFilterEnforcementBlocking, + }}, + }}, + }, + expectErr: true, + }, + { + name: "disabled with zero budget", + gate: config.StreamEvidenceGateConf{ + Enabled: true, + MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero, + Filters: []config.StreamGateFilterPolicyConf{{ + Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false), + }}, + }, + }, + { + name: "disabled gate with blocking filter and zero budget", + gate: config.StreamEvidenceGateConf{ + Enabled: false, + MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero, + Filters: []config.StreamGateFilterPolicyConf{{Filter: config.StreamGateFilterRepeatGuard}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.gate.Validate() + if tt.expectErr && err == nil { + t.Fatal("Validate() succeeded, want recovery-budget error") + } + if !tt.expectErr && err != nil { + t.Fatalf("Validate() failed: %v", err) + } + }) + } +} + func TestStreamEvidenceGate_IngressSnapshotBytes_TableFixture(t *testing.T) { maxAllowed := 16 * 1024 * 1024 // 16777216