From c051529c054a73764a11bcbd09689e28c14a281f Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 08:42:16 +0900 Subject: [PATCH] =?UTF-8?q?fix(openai):=20Review=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20handoff=20=EC=93=B0=EA=B8=B0=EC=99=80=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work 도구 호출에 review 경로가 포함됐다는 이유만으로 handoff write로 오분류하지 않는다. 실제 write content가 있는 호출만 handoff로 판정하고 단순 read/inspection은 caller 도구로 통과시킨다. --- apps/edge/internal/openai/hot_path_light.go | 38 +++++++++++-------- .../internal/openai/hot_path_light_test.go | 13 +++++++ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 48b6a16a..20401872 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -981,29 +981,19 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o return "", nil } paths := newReservedPaths(requestID) - reviewWrites := 0 + reviewWrites := make([]normalizedToolCall, 0, 1) for _, call := range output.ToolCalls { - for _, observed := range reservedPathsFromToolCall(call) { - if cleanRelativePath(observed) == cleanRelativePath(paths.ReviewPath) { - reviewWrites++ - } + if isWorkerReviewHandoffCall(record.binding, paths, call) { + reviewWrites = append(reviewWrites, call) } } - if reviewWrites == 0 { + if len(reviewWrites) == 0 { return hotPathPendingLocalTools, nil } - if reviewWrites != 1 || !record.localPlanRead { + if len(reviewWrites) != 1 || !record.localPlanRead { return "", fmt.Errorf("worker review handoff must contain one exact review write after reading the plan") } - var reviewCall normalizedToolCall - for _, call := range output.ToolCalls { - for _, observed := range reservedPathsFromToolCall(call) { - if cleanRelativePath(observed) == cleanRelativePath(paths.ReviewPath) { - reviewCall = call - } - } - } - content, err := artifactWriteContent(record.binding, reviewCall) + content, err := artifactWriteContent(record.binding, reviewWrites[0]) if err != nil { return "", err } @@ -1013,6 +1003,22 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o return hotPathPendingLocalHandoff, nil } +func isWorkerReviewHandoffCall(binding *workspaceBinding, paths reservedPaths, call normalizedToolCall) bool { + write := binding.operation(opKindWrite) + if write == nil || strings.TrimSpace(write.contentField) == "" { + return false + } + if _, ok := lookupMappedArgument(call.Arguments, write.contentField); !ok { + return false + } + for _, observed := range reservedPathsFromToolCall(call) { + if cleanRelativePath(observed) == cleanRelativePath(paths.ReviewPath) { + return true + } + } + return false +} + type hotPathReviewEvidence struct { planRead bool reviewRead bool diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 8e03a04b..43881c52 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -78,6 +78,19 @@ func TestHotPathStageOrdinaryWorkspacePathPassesThrough(t *testing.T) { } } +func TestWorkerReviewReadIsNotClassifiedAsHandoffWrite(t *testing.T) { + binding := mustBinding(t, fullWorkspaceAlternative("workspace", "workspace", false), []any{openAIChatTool("workspace", structuredSchema())}) + paths := newReservedPaths("req_review_read") + read := normalizedToolCall{Name: "workspace", Arguments: map[string]any{"path": paths.ReviewPath}} + if isWorkerReviewHandoffCall(binding, paths, read) { + t.Fatal("review read was classified as a handoff write") + } + write := normalizedToolCall{Name: "workspace", Arguments: map[string]any{"path": paths.ReviewPath, "content": testCompletedReviewText()}} + if !isWorkerReviewHandoffCall(binding, paths, write) { + t.Fatal("review write was not classified as a handoff write") + } +} + func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { callerTools := []any{openAIChatTool("run_command", commandSchema())} tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive, Tools: callerTools})