fix(openai): atomically author selector artifact pair

This commit is contained in:
toki 2026-08-15 06:30:47 +09:00
parent 70176afda6
commit 82bfcd432a
9 changed files with 121 additions and 18 deletions

View file

@ -108,8 +108,9 @@ write operation cannot create parents receives a `prepare-only` initial instruct
the provider must emit exactly one admitted prepare call for
`.iop/job/<request_id>` and must not write PLAN or REVIEW. After the successful
prepare receipt, the resumed selector receives `pair-write` and must emit exactly
the two admitted writes for PLAN and REVIEW with the accepted default PLAN grammar
and deterministic pending REVIEW grammar. A parent-creating write binding receives
one atomic artifact-pair call containing the complete PLAN and REVIEW content with
the accepted default PLAN grammar and deterministic pending REVIEW grammar. Edge
expands that decision into two independently receipted admitted caller writes. A parent-creating write binding receives
`pair-write` immediately on its initial selector turn. In Chat Completions this is
the final leading `system` message; in native Messages it is the final top-level
`system` text block. The instruction is not caller input, does not alter the retained

View file

@ -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에는 현재 frontier의 canonical `prepare(path)` 또는 `write(path,content)` schema만 보여주고, pair-write에는 `parallel_tool_calls=true`를 명시한다. Validated result는 admitted caller tool의 실제 schema(예: `bash(command)`)로 encode한 뒤 caller에게 투영한다. 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 생성을 요구하지 않는다. 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는 보존한다.

View file

@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. |
| managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. |
| marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. |
| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/<request_id>` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only the current canonical `prepare(path)` or `write(path,content)` schema, with parallel tool calls enabled only for pair-write; Edge validates and encodes it into the admitted caller tool schema such as `bash(command)` before projection. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/<request_id>` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. |
| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/<request_id>` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/<request_id>` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. |
| marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. |
| marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. |
| marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. |
@ -373,6 +373,7 @@ sequenceDiagram
- 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. The reasoning Responses conversion omits incompatible generic SDK `temperature`/`top_p` defaults. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command.
- 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately.
- 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-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.

View file

@ -210,6 +210,60 @@ func (s *artifactFrontierStore) pairRequired(requestID, ownerEdgeID string) bool
return record != nil && record.ownerEdgeID == ownerEdgeID && record.phase == artifactPhasePairReady
}
// expandSelectorPair turns the selector-only atomic pair tool into the two
// caller-visible workspace writes. The provider never needs to coordinate two
// parallel calls, while the caller still executes and receipts each file
// independently through its admitted workspace tool contract.
func (s *artifactFrontierStore) expandSelectorPair(
requestID, ownerEdgeID string,
output normalizedStageOutput,
) (normalizedStageOutput, error) {
if s == nil || requestID == "" {
return output, nil
}
s.mu.Lock()
defer s.mu.Unlock()
record := s.records[requestID]
if record == nil || record.ownerEdgeID != ownerEdgeID ||
(record.phase != artifactPhasePairReady && (record.phase != artifactPhasePinned || !record.binding.createsParents())) {
return output, nil
}
if len(output.ToolCalls) != 1 || output.ToolCalls[0].Name != hotPathArtifactPairToolName {
return output, nil
}
write := record.binding.operation(opKindWrite)
if write == nil || strings.TrimSpace(write.toolName) == "" {
return normalizedStageOutput{}, fmt.Errorf("artifact pair write binding is unavailable")
}
call := output.ToolCalls[0]
planContent, planOK := call.Arguments["plan_content"].(string)
reviewContent, reviewOK := call.Arguments["review_content"].(string)
if !planOK || strings.TrimSpace(planContent) == "" || !reviewOK || strings.TrimSpace(reviewContent) == "" {
return normalizedStageOutput{}, fmt.Errorf("artifact pair tool requires complete plan_content and review_content")
}
providerID := strings.TrimSpace(call.ProviderCallID)
if providerID == "" {
providerID = strings.TrimSpace(call.ID)
}
if !validLogicalRequestID(providerID) {
return normalizedStageOutput{}, fmt.Errorf("artifact pair provider tool id is invalid")
}
paths := newReservedPaths(requestID)
output.ToolCalls = []normalizedToolCall{
{ID: pairProviderCallID(providerID, "plan"), ProviderCallID: pairProviderCallID(providerID, "plan"), Name: write.toolName, Arguments: map[string]any{"path": paths.PlanPath, "content": planContent}},
{ID: pairProviderCallID(providerID, "review"), ProviderCallID: pairProviderCallID(providerID, "review"), Name: write.toolName, Arguments: map[string]any{"path": paths.ReviewPath, "content": reviewContent}},
}
return output, nil
}
func pairProviderCallID(base, role string) string {
suffix := "_" + role
if len(base)+len(suffix) > 256 {
base = base[:256-len(suffix)]
}
return base + suffix
}
func (s *artifactFrontierStore) issue(
turn *hotPathTurn,
output normalizedStageOutput,

View file

@ -320,6 +320,29 @@ func newArtifactPairFixture(t *testing.T, endpoint string, createsParents bool)
}
}
func TestArtifactSelectorAtomicPairExpandsToCallerWrites(t *testing.T) {
fixture := newArtifactPairFixture(t, "openai", true)
output, err := fixture.server.artifactFrontiers.expandSelectorPair(fixture.requestID, fixture.ownerEdgeID, normalizedStageOutput{
ToolCalls: []normalizedToolCall{{
ID: "provider_pair", Name: hotPathArtifactPairToolName,
Arguments: map[string]any{"plan_content": testLightweightPlan(t), "review_content": testPendingReview(t)},
}},
})
if err != nil {
t.Fatal(err)
}
if len(output.ToolCalls) != 2 {
t.Fatalf("expanded calls=%+v", output.ToolCalls)
}
if output.ToolCalls[0].Name != "workspace" || output.ToolCalls[0].Arguments["path"] != fixture.paths.PlanPath ||
output.ToolCalls[1].Name != "workspace" || output.ToolCalls[1].Arguments["path"] != fixture.paths.ReviewPath {
t.Fatalf("expanded caller writes=%+v", output.ToolCalls)
}
if output.ToolCalls[0].ProviderCallID == output.ToolCalls[1].ProviderCallID {
t.Fatalf("expanded provider ids must be distinct: %+v", output.ToolCalls)
}
}
func (f *artifactPairFixture) issuePrepare() []string {
f.t.Helper()
ids, err := f.issue([]normalizedToolCall{{

View file

@ -461,7 +461,7 @@ func scriptedSelectorDirective(providerBody []byte, operation string) (string, s
return requestID, state, nil
}
required := []string{
"Return exactly two admitted write tool calls",
"Return exactly one iop_write_artifact_pair tool call",
"PLAN path: " + paths.PlanPath,
"REVIEW path: " + paths.ReviewPath,
"The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets.",

View file

@ -1163,6 +1163,12 @@ func (s *Server) dispatchPresetTurn(
preset = found
}
}
output, err := s.artifactFrontiers.expandSelectorPair(requestID, ownerEdgeID, output)
if err != nil {
s.terminalPresetRequest(requestID, ownerEdgeID)
writeHotPathPresetDispatchError(w, r, protocol, http.StatusBadRequest, "invalid_request_error", err.Error())
return err
}
decision, err := classifyHotPathOutput(preset, issued, output, gate)
if err != nil {
if initialAdmission {

View file

@ -15,6 +15,7 @@ import (
const (
maxHotPathSelectorProviderInstructionBytes = 4096
maxHotPathSelectorOutputTokens = 4096
hotPathArtifactPairToolName = "iop_write_artifact_pair"
)
func buildHotPathSelectorProviderInstruction(requestID string, state selectorInstructionState) (string, error) {
@ -33,7 +34,7 @@ Do not write PLAN or REVIEW in this turn. Do not mention or infer an absolute wo
case selectorInstructionPairWrite:
instruction = fmt.Sprintf(`IOP caller-workspace selector instruction.
Operation: pair-write
Return exactly two admitted write tool calls for the request-local artifact pair and no other tool call. Do not mention or infer an absolute workspace path.
Return exactly one iop_write_artifact_pair tool call containing both complete artifact contents and no other tool call. Do not mention or infer an absolute workspace path.
PLAN path: %s
REVIEW path: %s
The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets. For this deterministic seed, use exactly the following two-step, one-verification form and replace only angle-bracketed text:
@ -179,6 +180,30 @@ func prepareHotPathSelectorCanonicalTools(tunnel edgeservice.SubmitProviderTunne
actual[strings.TrimSpace(name)] = function
}
}
if len(actual) == 0 {
return nil, fmt.Errorf("selector canonical tool is absent from caller tools")
}
if operation == "write" {
root["tools"] = []any{map[string]any{
"type": "function",
"function": map[string]any{
"name": hotPathArtifactPairToolName,
"description": "Author the complete IOP Plan and pending Review artifact pair in one atomic selector decision.",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"plan_content": map[string]any{"type": "string", "description": "Complete Plan markdown"},
"review_content": map[string]any{"type": "string", "description": "Complete pending Review markdown"},
},
"required": []any{"plan_content", "review_content"},
"additionalProperties": false,
},
},
}}
root["tool_choice"] = "required"
delete(root, "parallel_tool_calls")
return json.Marshal(root)
}
names := make([]string, 0, len(actual))
for name := range actual {
names = append(names, name)
@ -204,16 +229,9 @@ func prepareHotPathSelectorCanonicalTools(tunnel edgeservice.SubmitProviderTunne
},
})
}
if len(canonical) == 0 {
return nil, fmt.Errorf("selector canonical tool is absent from caller tools")
}
root["tools"] = canonical
root["tool_choice"] = "auto"
if operation == "write" {
root["parallel_tool_calls"] = true
} else {
delete(root, "parallel_tool_calls")
}
root["tool_choice"] = "required"
delete(root, "parallel_tool_calls")
return json.Marshal(root)
}
if tunnel.BuildBody == nil {

View file

@ -309,11 +309,11 @@ func TestHotPathSelectorCanonicalWriteToolReplacesCallerCommandSchema(t *testing
function := tools[0].(map[string]any)["function"].(map[string]any)
parameters := function["parameters"].(map[string]any)
properties := parameters["properties"].(map[string]any)
if function["name"] != "bash" || properties["path"] == nil || properties["content"] == nil || properties["command"] != nil {
if function["name"] != hotPathArtifactPairToolName || properties["plan_content"] == nil || properties["review_content"] == nil || properties["command"] != nil {
t.Fatalf("canonical write function=%+v", function)
}
if request["parallel_tool_calls"] != true {
t.Fatalf("pair-write did not enable parallel tool calls: %+v", request)
if request["tool_choice"] != "required" || request["parallel_tool_calls"] != nil {
t.Fatalf("pair-write must force one atomic tool call: %+v", request)
}
}