Native API: distinguish provider finish_reason from inferred EOF completion (follow-up to #3669)
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 45/100
- Issue 类型
- 功能
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- go
调研方向
Start by tracing the native API stream completion path and the OpenAI-compatible SSE response handling that sets finish_reason and processes [DONE] or EOF. Compare the explicit-stop, done-only, bare-EOF, and truncated-body cases from the reproduction. Done means the emitted completion or diagnostic preserves whether the provider supplied the finish reason and distinguishes missing terminal signals; the issue names no source files or tests.
由索引模型根据 Issue 内容生成。
描述
Description
The native API currently reports stream_stopped {reason: "normal", finish_reason: "stop"} both when an OpenAI-compatible provider explicitly sends finish_reason: "stop" and when a complete HTTP response ends with neither a non-null finish_reason nor [DONE]. The emitted completion does not identify whether stop was observed from the provider or inferred at EOF.
This is a follow-up to #3669 and #3668. I understand that stopping a content-only bare-EOF turn was an intentional compatibility change to avoid invoking the model repeatedly with unchanged history. This report does not request reverting that stop decision or restoring automatic continuation/retry loops. It asks for distinguishable termination provenance/diagnostics, and a separate review of the no-finish_reason/no-[DONE] case versus the [DONE]-only gateway compatibility case.
Expected Behavior
- Preserve the anti-loop behavior from #3669 where required.
- Expose whether the provider actually supplied the reported finish reason or the runtime inferred it. An optional provenance/observed flag or a diagnostic event would be sufficient; specific field names are left to maintainers.
- Distinguish a
[DONE]-only response from one that has neither terminal signal. Please consider an explicit incomplete/unknown outcome or diagnostic for the latter instead of making it indistinguishable from a provider-confirmedstop. - Do not infer semantic task completion from prose or add automatic model re-entry as a workaround.
Actual Behavior and Verification Results
Fresh runs of the self-contained fixture below produced identical results on the unmodified official Windows amd64 and Linux amd64 v1.150.0 binaries:
| Case | Synthetic upstream response | Native reason | Native finish_reason | Native error |
|---|---|---|---|---|
explicit_stop |
Text + explicit stop + [DONE], no usage |
normal |
stop |
none |
done_only |
Text + [DONE], no non-null finish reason |
normal |
stop |
none |
bare_eof |
Text only; no finish reason and no [DONE]; complete HTTP body |
normal |
stop |
none |
transport_cut |
Same text, but HTTP Content-Length deliberately exceeds bytes sent | error |
absent | model_error |
The first three cases each made one fixture-provider request. The transport-truncation control made three requests before reporting model_error (the engine's existing retry behavior; the harness has no retry loop). Every case intentionally omits usage, including the valid explicit-stop control. Thus absence of usage alone does not identify a missing finish reason.
The bare_eof fixture sends an accurate HTTP Content-Length and closes normally. It is not an incomplete HTTP transfer: the missing evidence is at the SSE/application-protocol layer. The transport-cut control demonstrates the different handling of an actually truncated HTTP body.
Steps to Reproduce
- Save the following as
repro.py. - Use an official Docker Agent v1.150.0 standalone binary.
- Run
python3 repro.py /path/to/docker-agent(Windows:python repro.py path/to/docker-agent.exe).
Python 3 standard library only. The script starts a loopback-only synthetic provider and a separate native API process with temporary config/data/session storage. It does not call a real model, load an existing agent configuration, inspect a native database, or execute tools/MCPs. Its temporary native assets are removed after the test process stops.
Self-contained reproduction
"""Python 3 stdlib only. Usage: python repro.py /path/to/docker-agent"""
import hashlib
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import Request, ProxyHandler, build_opener
CASES = ("explicit_stop", "done_only", "bare_eof", "transport_cut")
calls = {name: 0 for name in CASES}
http = build_opener(ProxyHandler({}))
def frame(delta, finish=None):
return "data: " + json.dumps({
"id": "fixture", "object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]
}) + "\n\n"
class Provider(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_POST(self):
request = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
name = [m["content"] for m in request["messages"] if m["role"] == "user"][-1]
calls[name] += 1
body = frame({"role": "assistant", "content": "fixture text"})
if name == "explicit_stop":
body += frame({}, "stop")
if name in ("explicit_stop", "done_only"):
body += "data: [DONE]\n\n"
body = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(body) + (1000 if name == "transport_cut" else 0)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
self.wfile.flush()
self.close_connection = True
def request(base, method, path, payload=None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
return http.open(Request(base + path, data, {"Content-Type": "application/json"}, method=method), timeout=30)
def main():
binary = str(Path(sys.argv[1]).resolve())
print(json.dumps({"platform": sys.platform,
"version": subprocess.check_output([binary, "version"], text=True).strip(),
"sha256": hashlib.sha256(Path(binary).read_bytes()).hexdigest()}), flush=True)
provider = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
threading.Thread(target=provider.serve_forever, daemon=True).start()
try:
with tempfile.TemporaryDirectory(prefix="docker-agent-eof-") as root:
with socket.socket() as reserve:
reserve.bind(("127.0.0.1", 0))
port = reserve.getsockname()[1]
config = Path(root) / "repro.yaml"
config.write_text("""version: '15'
providers:
fixture:
api_type: openai_chatcompletions
base_url: http://127.0.0.1:%d/v1
token_key: EOF_FIXTURE_TOKEN
models:
fixture:
provider: fixture
model: fixture-model
max_tokens: 128
agents:
root:
model: fixture
instruction: Fixture only.
max_iterations: 1
""" % provider.server_port, encoding="utf-8")
env = dict(os.environ, EOF_FIXTURE_TOKEN="local-fixture-only", DOCKER_AGENT_DISABLE_DESKTOP_PROXY="1")
command = [binary, "serve", "api", str(config), "--listen", "127.0.0.1:%d" % port,
"--session-db", str(Path(root) / "native.db"),
"--config-dir", str(Path(root) / "config"), "--data-dir", str(Path(root) / "data")]
with open(Path(root) / "engine.log", "w") as log:
process = subprocess.Popen(command, env=env, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
try:
base = "http://127.0.0.1:%d" % port
for _ in range(100):
try:
with request(base, "GET", "/api/ping"):
break
except OSError:
if process.poll() is not None:
raise RuntimeError("fixture engine exited")
time.sleep(0.1)
else:
raise RuntimeError("fixture engine did not start")
for name in CASES:
with request(base, "POST", "/api/sessions", {"title": "EOF fixture", "working_dir": root}) as response:
session = json.load(response)["id"]
with request(base, "POST", "/api/sessions/%s/agent/repro/root" % session,
{"messages": [{"role": "user", "content": name}]}) as response:
lines = response.read().decode("utf-8").splitlines()
events = [json.loads(line[5:].strip()) for line in lines if line.startswith("data:") and line[5:].strip() != "[DONE]"]
stop = next(e for e in reversed(events) if e["type"] == "stream_stopped")
print(json.dumps({"case": name, "providerRequests": calls[name],
"reason": stop.get("reason"), "finish_reason": stop.get("finish_reason"),
"errors": [e.get("code") for e in events if e["type"] == "error"],
"hasLastMessageUsage": any(e.get("usage", {}).get("last_message") is not None
for e in events if e["type"] == "token_usage")}), flush=True)
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
finally:
provider.shutdown()
provider.server_close()
if __name__ == "__main__":
main()
Docker Agent Version
v1.150.0, commit 5277e7842058fc3fbb9943a6e88bc05a1c8e6fe2.
Official binary SHA-256:
- Windows amd64:
144191a21a25c0c1ecfa8c0b320af00c8373187a27fccd73256a7fc39de3d6c3 - Linux amd64:
791fa6bac9f829847f00ab948f838bf54d11e0830988a5e5bce88d65ea65f2d1
OS / Model
Windows amd64 and Ubuntu/WSL2 Linux amd64; public CLI + native HTTP API. Model: a local deterministic Chat Completions fixture, no real provider or credentials.
Captured Verification Output
Windows
{"platform":"win32","version":"docker-agent version v1.150.0\nCommit: 5277e7842058fc3fbb9943a6e88bc05a1c8e6fe2","sha256":"144191a21a25c0c1ecfa8c0b320af00c8373187a27fccd73256a7fc39de3d6c3"}
{"case":"explicit_stop","providerRequests":1,"reason":"normal","finish_reason":"stop","errors":[],"hasLastMessageUsage":false}
{"case":"done_only","providerRequests":1,"reason":"normal","finish_reason":"stop","errors":[],"hasLastMessageUsage":false}
{"case":"bare_eof","providerRequests":1,"reason":"normal","finish_reason":"stop","errors":[],"hasLastMessageUsage":false}
{"case":"transport_cut","providerRequests":3,"reason":"error","finish_reason":null,"errors":["model_error"],"hasLastMessageUsage":false}
Linux
{"platform":"linux","version":"docker-agent version v1.150.0\nCommit: 5277e7842058fc3fbb9943a6e88bc05a1c8e6fe2","sha256":"791fa6bac9f829847f00ab948f838bf54d11e0830988a5e5bce88d65ea65f2d1"}
{"case":"explicit_stop","providerRequests":1,"reason":"normal","finish_reason":"stop","errors":[],"hasLastMessageUsage":false}
{"case":"done_only","providerRequests":1,"reason":"normal","finish_reason":"stop","errors":[],"hasLastMessageUsage":false}
{"case":"bare_eof","providerRequests":1,"reason":"normal","finish_reason":"stop","errors":[],"hasLastMessageUsage":false}
{"case":"transport_cut","providerRequests":3,"reason":"error","finish_reason":null,"errors":["model_error"],"hasLastMessageUsage":false}
Impact and Limits
A downstream integration cannot tell a provider-confirmed stop from this EOF inference using these completion fields alone, which can affect completion-triggered workflows and diagnosis of apparent early turn endings.
An observed early-ending downstream session motivated this investigation, but its original upstream SSE was not retained. This report does not claim that the EOF fallback caused that production incident, that the provider failed to emit a terminal signal in that incident, or that a tool call was lost. All reproducible findings and output above come from synthetic local fixtures. No production prompts, logs, identifiers, addresses, configuration, or credentials are included.
Related: #3669 / #3668. Native API event contract: https://docker.github.io/docker-agent/features/api-server/
- 主要语言
- Go
- 星标
- 4.6k
- 派生
- 522
- 平均合并
- 8 小时 20 分钟
- 30 天内合并 PR
- 219
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 提供 Dockerfile 或 Docker Compose 文件
- 没有 Pull Request 模板
- 没有贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
docker/docker-agent 的其他 Issue
-
pkg/hooks tests fail with a misleading assertion when jq is not installed可能已有人在做 @dwin-gharibi 于 57 天前认领。 未关闭area/testing
难度 2/5 1-3 小时 新手友好度 86/100
docker/docker-agent#4052 ·
维护者通常 1 天内回复
-
`remove_directory` and `create_directory` hide work they already did when a later path fails可能已有人在做 @dwin-gharibi 于 75 天前认领。 未关闭area/tools
难度 2/5 1-3 小时 新手友好度 85/100
docker/docker-agent#3933 ·
维护者通常 1 天内回复
-
`edit_file` silently edits the first match when `oldText` occurs more than once可能已有人在做 @dwin-gharibi 于 75 天前认领。 未关闭area/tools
难度 2/5 1-3 小时 新手友好度 78/100
docker/docker-agent#3929 · 2 条评论 ·
维护者通常 1 天内回复
-
test(runtime): Claude 5.x lifecycle — compaction, reload, switches, shared clients可能已有人在做 @aheritier 于 1 天前认领。 未关闭area/providers area/providers/anthropic area/runtime area/sessions area/testing
docker/docker-agent#4654 · 已指派 1 人 ·
维护者通常 1 天内回复
-
feat(provider): exact signed-thinking capture, persistence and replay (incl. Bedrock)可能已有人在做 @aheritier 于 1 天前认领。 未关闭area/providers/anthropic area/providers/bedrock area/sessions
docker/docker-agent#4653 · 已指派 1 人 ·
维护者通常 1 天内回复
查看 docker/docker-agent 的全部 Issue
相似的 Issue
-
automation models
难度 2/5 1-3 小时 新手友好度 65/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 76/100
QuantumNous/new-api#7930 ·
维护者通常 1 天内回复
-
area/gateway kind/bug
难度 2/5 1-3 小时 新手友好度 74/100
vllm-project/aibrix#3226 · 1 条评论 ·
维护者通常 1 天内回复
-
kind/bug kind/flake
难度 2/5 1-3 小时 新手友好度 73/100
kubernetes-sigs/kueue#17518 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 68/100
维护者通常 1 天内回复