fix(openai): canonicalize worker review handoff
This commit is contained in:
parent
9baf54a026
commit
bfaa9e8684
7 changed files with 96 additions and 6 deletions
|
|
@ -96,7 +96,8 @@ admitted by the caller request. Edge never receives or configures a raw caller
|
|||
workspace path. Before issuing caller writes, Edge validates the selector's
|
||||
default lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn`
|
||||
inventory request-locally. Work must read PLAN, perform and verify caller-workspace
|
||||
work, and replace REVIEW with a completed handoff covering every PLAN item. Review
|
||||
work, and use the Edge-owned canonical `iop_write_worker_review(content)` tool once
|
||||
to replace REVIEW with a completed handoff covering every PLAN item. Review
|
||||
must read both artifacts and successfully inspect an ordinary caller result before
|
||||
it can return a non-empty caller-visible terminal. It never rewrites REVIEW; repair
|
||||
and re-verification remain in the same Review binding. Cleanup targets only the
|
||||
|
|
|
|||
|
|
@ -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가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. 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 provider에는 ordinary caller tools와 함께 단일 canonical `iop_write_worker_review(content)` handoff tool을 제공하며, 완료된 REVIEW는 이 도구만 사용한다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. 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는 보존한다.
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ sequenceDiagram
|
|||
- 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-15: Removed the redundant outer identity projection from caller-stage-only cleanup so its issued semantic hash and caller-visible call ID remain identical.
|
||||
- 2026-08-15: Canonicalized reserved Work/Review path aliases such as caller `filePath` back to the issued path field before command-mode binding encode.
|
||||
- 2026-08-15: Added one Work-only canonical REVIEW handoff tool so completed control content is normalized into the caller binding instead of depending on an arbitrary caller edit or shell schema.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1712,7 +1712,7 @@ func hotPathStageMetaFromDispatch(stageID string, dispatch edgeservice.RunDispat
|
|||
func hotPathStageRunInput(snapshot hotPathDispatchSnapshot, prompt string) map[string]any {
|
||||
messages := hotPathChatStageMessages(snapshot, prompt)
|
||||
input := map[string]any{"prompt": prompt, "messages": messages}
|
||||
if tools := hotPathChatTools(snapshot.Tools); len(tools) > 0 {
|
||||
if tools := hotPathChatTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 {
|
||||
input["tools"] = tools
|
||||
input["tool_choice"] = "auto"
|
||||
}
|
||||
|
|
@ -1733,7 +1733,7 @@ func hotPathChatStageBody(snapshot hotPathDispatchSnapshot, prompt, target strin
|
|||
body := map[string]any{
|
||||
"model": target, "messages": hotPathChatStageMessages(snapshot, prompt), "stream": snapshot.Stream,
|
||||
}
|
||||
if tools := hotPathChatTools(snapshot.Tools); len(tools) > 0 {
|
||||
if tools := hotPathChatTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 {
|
||||
body["tools"] = tools
|
||||
body["tool_choice"] = "auto"
|
||||
}
|
||||
|
|
@ -1750,7 +1750,7 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target
|
|||
body := map[string]any{
|
||||
"model": target, "max_tokens": 4096, "messages": hotPathAnthropicStageMessages(snapshot, prompt), "stream": snapshot.Stream,
|
||||
}
|
||||
if tools := hotPathAnthropicTools(snapshot.Tools); len(tools) > 0 {
|
||||
if tools := hotPathAnthropicTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 {
|
||||
body["tools"] = tools
|
||||
body["tool_choice"] = map[string]any{"type": "auto"}
|
||||
}
|
||||
|
|
@ -1763,6 +1763,27 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target
|
|||
return json.Marshal(body)
|
||||
}
|
||||
|
||||
func hotPathStageProviderTools(snapshot hotPathDispatchSnapshot) []any {
|
||||
tools := cloneAnySlice(snapshot.Tools)
|
||||
if snapshot.Phase != hotPathPhaseLocalActive {
|
||||
return tools
|
||||
}
|
||||
return append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": hotPathWorkerReviewToolName,
|
||||
"description": "Commit the complete validated worker REVIEW handoff after implementation and verification.",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{"type": "string", "description": "Complete REVIEW markdown"},
|
||||
},
|
||||
"required": []any{"content"}, "additionalProperties": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func applyHotPathStageOptions(body map[string]any, options map[string]any, reserved map[string]struct{}) {
|
||||
for key, value := range options {
|
||||
if _, blocked := reserved[key]; blocked {
|
||||
|
|
|
|||
|
|
@ -881,6 +881,39 @@ func (s *hotPathLightStore) commitLocal(requestID, ownerEdgeID string, output no
|
|||
return hotPathLightDisposition{RequestID: requestID, StageID: reviewStageID, Phase: record.phase}, nil
|
||||
}
|
||||
|
||||
func (s *hotPathLightStore) annotateWorkerReviewHandoff(
|
||||
requestID, ownerEdgeID string,
|
||||
output normalizedStageOutput,
|
||||
) (normalizedStageOutput, error) {
|
||||
if s == nil {
|
||||
return normalizedStageOutput{}, fmt.Errorf("light flow is unavailable")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record := s.records[requestID]
|
||||
if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive {
|
||||
return normalizedStageOutput{}, fmt.Errorf("local flow state is unavailable")
|
||||
}
|
||||
paths := newReservedPaths(requestID)
|
||||
seen := 0
|
||||
for index := range output.ToolCalls {
|
||||
call := &output.ToolCalls[index]
|
||||
if call.Name != hotPathWorkerReviewToolName {
|
||||
continue
|
||||
}
|
||||
content, ok := call.Arguments["content"].(string)
|
||||
if !ok || strings.TrimSpace(content) == "" {
|
||||
return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff content is required")
|
||||
}
|
||||
call.Path = paths.ReviewPath
|
||||
seen++
|
||||
}
|
||||
if seen > 1 {
|
||||
return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff must be emitted exactly once")
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, output normalizedStageOutput) (hotPathPendingKind, error) {
|
||||
if s == nil {
|
||||
return "", fmt.Errorf("light flow is unavailable")
|
||||
|
|
@ -1032,6 +1065,13 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di
|
|||
return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID,
|
||||
hotPathLightEndpointErrorForCause(protocol, http.StatusBadGateway, snapshot.StageID, err))
|
||||
}
|
||||
if snapshot.Phase == hotPathPhaseLocalActive {
|
||||
output, err = s.lightFlows.annotateWorkerReviewHandoff(requestID, s.edgeIDValue(), output)
|
||||
if err != nil {
|
||||
return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID,
|
||||
hotPathLightEndpointError(protocol, http.StatusBadRequest, err.Error()))
|
||||
}
|
||||
}
|
||||
visible = mergeVisibleStageOutput(visible, output)
|
||||
// The collector compatibility path remains the endpoint renderer until
|
||||
// endpoint codecs consume released deltas directly. Feed the same
|
||||
|
|
|
|||
|
|
@ -54,6 +54,31 @@ func TestHotPathStageCanonicalReadMapsToCallerCommandTool(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWorkerReviewHandoffUsesCanonicalStageTool(t *testing.T) {
|
||||
tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive})
|
||||
schemas, err := normalizeToolSchemas(tools)
|
||||
if err != nil || schemas[hotPathWorkerReviewToolName] == nil {
|
||||
t.Fatalf("local canonical handoff tool=%+v err=%v", schemas, err)
|
||||
}
|
||||
if reviewTools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseReviewActive}); len(reviewTools) != 0 {
|
||||
t.Fatalf("review stage unexpectedly received worker handoff tool: %+v", reviewTools)
|
||||
}
|
||||
|
||||
store := newHotPathLightStore(1)
|
||||
store.records["req_handoff"] = &hotPathLightRecord{
|
||||
requestID: "req_handoff", ownerEdgeID: "edge", phase: hotPathPhaseLocalActive,
|
||||
}
|
||||
output, err := store.annotateWorkerReviewHandoff("req_handoff", "edge", normalizedStageOutput{ToolCalls: []normalizedToolCall{{
|
||||
ID: "provider_handoff", Name: hotPathWorkerReviewToolName, Arguments: map[string]any{"content": "# Review\ncomplete"},
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output.ToolCalls[0].Path != newReservedPaths("req_handoff").ReviewPath {
|
||||
t.Fatalf("annotated handoff=%+v", output.ToolCalls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotPathLightRequiresCompletedWorkerHandoff(t *testing.T) {
|
||||
for _, endpoint := range []string{"openai", "anthropic"} {
|
||||
endpoint := endpoint
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"unicode"
|
||||
)
|
||||
|
||||
const hotPathWorkerReviewToolName = "iop_write_worker_review"
|
||||
|
||||
type hotPathArtifactPaths struct {
|
||||
PlanPath string
|
||||
ReviewPath string
|
||||
|
|
@ -125,7 +127,7 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) {
|
|||
|
||||
switch in.Role {
|
||||
case "local":
|
||||
b.WriteString("Read the exact issued PLAN before doing any work. Perform the task and verification in the caller workspace with ordinary caller tools. Then overwrite the exact issued REVIEW path using this closed handoff grammar: '# Review', '## Worker Item Status' with every PLAN item exactly once as '- Pn: completed', '## Worker Changes', '## Worker Verification', and '## Deviations'; every section must be non-empty. Do not complete without a successful PLAN read and REVIEW write.")
|
||||
b.WriteString("Read the exact issued PLAN before doing any work. Perform the task and verification in the caller workspace with ordinary caller tools. Then call iop_write_worker_review exactly once with the complete REVIEW content using this closed handoff grammar: '# Review', '## Worker Item Status' with every PLAN item exactly once as '- Pn: completed', '## Worker Changes', '## Worker Verification', and '## Deviations'; every section must be non-empty. Do not use an ordinary caller tool for the issued REVIEW path. Do not complete without a successful PLAN read and this REVIEW handoff call.")
|
||||
case "review":
|
||||
switch phase {
|
||||
case hotPathPhaseReviewActive:
|
||||
|
|
|
|||
Loading…
Reference in a new issue