Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

Native API: distinguish provider finish_reason from inferred EOF completion (follow-up to #3669)

未关闭
#4,624 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 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 内容生成。

描述

area/api area/providers/openai area/runtime
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-confirmed stop.
  • 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
  1. Save the following as repro.py.
  2. Use an official Docker Agent v1.150.0 standalone binary.
  3. 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

环境准备

在 Codespaces 中打开

在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。

  • 提供 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 没有贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

docker/docker-agent 的其他 Issue

查看 docker/docker-agent 的全部 Issue

相似的 Issue

更多 Go Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。