fix(openai): buffer command-mode stage projection
This commit is contained in:
parent
deea8d9592
commit
0b037d0367
7 changed files with 62 additions and 15 deletions
|
|
@ -123,9 +123,10 @@ arguments/input. SDK-added text, thinking, and provider metadata decorations are
|
|||
part of that issue identity; the retained request prefix and exact result IDs remain
|
||||
independently validated.
|
||||
|
||||
Work/Review provider deltas are collected through the bounded internal stage before
|
||||
caller projection. Edge validates and maps canonical operations to the selected caller
|
||||
binding first, so an internal tool name is never released early on a Messages stream.
|
||||
For a command-mode caller binding, Work/Review provider deltas are collected through
|
||||
the bounded internal stage before caller projection. Edge validates and maps canonical
|
||||
operations first, so an internal tool name is never released early on a Messages stream;
|
||||
directly projectable structured bindings retain progressive streaming.
|
||||
|
||||
A preset marked with `single_request` is excluded from this continuation before
|
||||
caller tool binding. Config validation rejects a preset that declares both
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist
|
|||
Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다.
|
||||
|
||||
- provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다.
|
||||
- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 내부 stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다.
|
||||
- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다.
|
||||
- selected provider가 Ollama/native provider처럼 normalized execution을 요구하면 Edge는 normalized `RunRequest` path를 사용한다. 이 경로는 OpenAI-compatible 표면을 입력/출력 compatibility layer로 제공하되, backend 호출은 normalized adapter 계약을 따른다.
|
||||
- `metadata`는 경로 선택자가 아니다. Edge는 route 결정 뒤 인증 principal, usage/observability 등 IOP가 아는 bounded metadata key만 발췌한다. 이 발췌 정보는 provider body를 바꾸는 selector가 아니며, passthrough 응답 body에 IOP marker/event/envelope를 섞지 않는다.
|
||||
- Chat Completions 성공 응답의 top-level `model` echo가 provider-served model이면 caller가 요청한 IOP model alias로 정규화할 수 있다. reasoning/content/tool_calls 같은 provider payload field는 보존한다.
|
||||
|
|
|
|||
|
|
@ -376,7 +376,7 @@ sequenceDiagram
|
|||
- 2026-08-15: Replaced probabilistic parallel PLAN/REVIEW selector calls with one canonical atomic artifact-pair call that Edge deterministically expands into the same two admitted caller writes.
|
||||
- 2026-08-15: Bound continuation issue identity to semantic tool calls so compatible SDK decoration of assistant messages does not break an otherwise exact caller receipt.
|
||||
- 2026-08-15: Normalized Work/Review canonical reserved-artifact operation names through the selected caller binding before encoding them into structured or command-mode caller tools.
|
||||
- 2026-08-15: Buffered each bounded Work/Review provider stage until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization.
|
||||
- 2026-08-15: Buffered command-mode Work/Review provider stages until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization while retaining structured-binding progressive streams.
|
||||
- 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas.
|
||||
- 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets.
|
||||
- 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent.
|
||||
|
|
|
|||
|
|
@ -1294,6 +1294,10 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh
|
|||
MaxQueue: route.MaxQueue, QueueTimeoutMS: route.QueueTimeoutMS, Metadata: metadata,
|
||||
EstimatedInputTokens: estimate, ContextClass: contextClass, ProviderPool: route.ProviderPool,
|
||||
}
|
||||
collectedOuter := outer
|
||||
if snapshot.RequiresCollectedProjection {
|
||||
collectedOuter = nil
|
||||
}
|
||||
|
||||
if !route.ProviderPool {
|
||||
if routeUsesProviderTunnel(route) {
|
||||
|
|
@ -1316,11 +1320,11 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh
|
|||
if shouldProgressivelyReleaseHotPathStage(snapshot, outer) {
|
||||
return s.runHotPathLiveTunnelStage(ctx, snapshot, outer, handle, dispatch)
|
||||
}
|
||||
output, collectErr := s.collectHotPathOwnedTunnelStage(ctx, snapshot.StageID, nil, handle, dispatch, "openai")
|
||||
output, collectErr := s.collectHotPathOwnedTunnelStage(ctx, snapshot.StageID, collectedOuter, handle, dispatch, "openai")
|
||||
if collectErr != nil {
|
||||
return normalizedStageOutput{}, hotPathStageCorrelation{}, collectErr
|
||||
}
|
||||
output.CallerStageOnly = true
|
||||
output.CallerStageOnly = snapshot.RequiresCollectedProjection
|
||||
return output, stageCorrelation(snapshot.StageID, output, dispatch), nil
|
||||
}
|
||||
handle, submitErr := s.service.SubmitRun(ctx, runReq)
|
||||
|
|
@ -1331,11 +1335,11 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh
|
|||
if shouldProgressivelyReleaseHotPathStage(snapshot, outer) {
|
||||
return s.runHotPathLiveNormalizedStage(ctx, snapshot, outer, handle, dispatch)
|
||||
}
|
||||
output, collectErr := s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, nil, handle, dispatch)
|
||||
output, collectErr := s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, collectedOuter, handle, dispatch)
|
||||
if collectErr != nil {
|
||||
return normalizedStageOutput{}, hotPathStageCorrelation{}, collectErr
|
||||
}
|
||||
output.CallerStageOnly = true
|
||||
output.CallerStageOnly = snapshot.RequiresCollectedProjection
|
||||
return output, stageCorrelation(snapshot.StageID, output, dispatch), nil
|
||||
}
|
||||
|
||||
|
|
@ -1386,10 +1390,10 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh
|
|||
}
|
||||
switch result.Path {
|
||||
case edgeservice.ProviderPoolPathNormalized:
|
||||
output, err = s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, nil, result.Run, result.DispatchInfo)
|
||||
output, err = s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, collectedOuter, result.Run, result.DispatchInfo)
|
||||
case edgeservice.ProviderPoolPathTunnel:
|
||||
output, err = s.collectHotPathOwnedTunnelStage(
|
||||
ctx, snapshot.StageID, nil, result.Tunnel, result.DispatchInfo, hotPathStageWireProtocol(result.DispatchInfo),
|
||||
ctx, snapshot.StageID, collectedOuter, result.Tunnel, result.DispatchInfo, hotPathStageWireProtocol(result.DispatchInfo),
|
||||
)
|
||||
default:
|
||||
s.abortHotPathRejectedDispatch(rejection)
|
||||
|
|
@ -1401,7 +1405,7 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh
|
|||
if err != nil {
|
||||
return normalizedStageOutput{}, hotPathStageCorrelation{}, err
|
||||
}
|
||||
output.CallerStageOnly = true
|
||||
output.CallerStageOnly = snapshot.RequiresCollectedProjection
|
||||
if strings.TrimSpace(output.ResponseID) == "" {
|
||||
return normalizedStageOutput{}, hotPathStageCorrelation{}, fmt.Errorf("hot path stage completion is missing provider identity")
|
||||
}
|
||||
|
|
@ -1414,7 +1418,8 @@ func shouldProgressivelyReleaseHotPathStage(snapshot hotPathDispatchSnapshot, ou
|
|||
// Releasing provider deltas before classification would let the caller run
|
||||
// an unbound internal tool call. Collect the bounded stage first, then project
|
||||
// only the validated caller-visible output.
|
||||
return false
|
||||
return !snapshot.RequiresCollectedProjection && snapshot.Stream &&
|
||||
(snapshot.Protocol == "openai" || snapshot.Protocol == "anthropic") && outer != nil
|
||||
}
|
||||
|
||||
// newHotPathRejectedDispatchOwner builds one result-scoped disposal owner for a
|
||||
|
|
|
|||
|
|
@ -145,6 +145,9 @@ type hotPathDispatchSnapshot struct {
|
|||
Tools []any
|
||||
Transcript []hotPathStageExchange
|
||||
Stream bool
|
||||
// RequiresCollectedProjection prevents canonical provider tool deltas from
|
||||
// escaping before a command-mode caller binding can encode them.
|
||||
RequiresCollectedProjection bool
|
||||
// OutputBudget is recalculated from the request-local outer accumulator
|
||||
// before every stage. Limited, remaining, and exhausted are distinct so an
|
||||
// exhausted turn cannot be encoded as a one-token provider request.
|
||||
|
|
@ -346,6 +349,7 @@ func (s *hotPathLightStore) beginDispatch(requestID, ownerEdgeID string, stream
|
|||
Protocol: record.protocol, Phase: record.phase, StageID: stageID, Stage: stage,
|
||||
Route: route, PresetRoute: cloneHotPathDispatch(record.dispatch), Input: input,
|
||||
Tools: cloneAnySlice(record.tools), Transcript: cloneStageTranscript(transcript), Stream: stream,
|
||||
RequiresCollectedProjection: record.binding.requiresCollectedProjection(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -805,11 +809,25 @@ func (s *hotPathLightStore) matchRecordLocked(ownerEdgeID, principalRef, protoco
|
|||
if record.principalRef != principalRef {
|
||||
return nil, true, errLogicalRequestPrincipal
|
||||
}
|
||||
if record.protocol != protocol || record.lineage != lineage.Prefix {
|
||||
return nil, true, errLogicalRequestLineage
|
||||
if record.protocol != protocol {
|
||||
return nil, true, fmt.Errorf("%w: protocol changed", errLogicalRequestLineage)
|
||||
}
|
||||
if record.lineage != lineage.Prefix {
|
||||
return nil, true, describeArtifactPrefixMismatch(record.lineage, lineage.Prefix)
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
for _, record := range candidates {
|
||||
if record.ownerEdgeID != ownerEdgeID || record.principalRef != principalRef || record.protocol != protocol {
|
||||
continue
|
||||
}
|
||||
if record.lineage != lineage.Prefix {
|
||||
return nil, true, describeArtifactPrefixMismatch(record.lineage, lineage.Prefix)
|
||||
}
|
||||
if record.pendingHash != lineage.IssuedCallHash {
|
||||
return nil, true, fmt.Errorf("%w: issued tool calls changed", errLogicalRequestLineage)
|
||||
}
|
||||
}
|
||||
return nil, true, errLogicalRequestLineage
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,18 @@ type workspaceBinding struct {
|
|||
fingerprint string
|
||||
}
|
||||
|
||||
func (b *workspaceBinding) requiresCollectedProjection() bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
for _, operation := range b.operations {
|
||||
if operation != nil && operation.mode == modeCommand {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compileWorkspaceBinding selects the first configured alternative whose every
|
||||
// declared operation matches an actual decoded tool by exact tool name and
|
||||
// recursive schema matcher. It never infers workspace roles from tool-name
|
||||
|
|
|
|||
|
|
@ -306,6 +306,17 @@ func TestWorkspaceCommandEncodingAndGuards(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceBindingCommandModeRequiresCollectedProjection(t *testing.T) {
|
||||
command := mustBinding(t, fullWorkspaceAlternative("command", "bash", true), []any{openAIChatTool("bash", commandSchema())})
|
||||
if !command.requiresCollectedProjection() {
|
||||
t.Fatal("command-mode binding must collect before caller projection")
|
||||
}
|
||||
structured := mustBinding(t, fullWorkspaceAlternative("structured", "workspace", false), []any{openAIChatTool("workspace", structuredSchema())})
|
||||
if structured.requiresCollectedProjection() {
|
||||
t.Fatal("structured binding unexpectedly disabled progressive projection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceBindingReceipts(t *testing.T) {
|
||||
binding := mustBinding(t, workspaceAlternative("structured", "write_file", false, true), []any{openAIChatTool("write_file", structuredSchema())})
|
||||
payload, err := encodeWorkspaceCall(binding, opKindWrite, normalizedToolCall{
|
||||
|
|
|
|||
Loading…
Reference in a new issue