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})