Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

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

Đang mở
#4,624 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
45/100
Loại issue
Tính năng
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
go
Lĩnh vực
backend-api-design

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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/

Ngôn ngữ chính
Go
Star
4.4k
Fork
508
Merge trung bình
8 giờ 6 phút
Pull request đã merge (30 ngày)
209

Chuẩn bị môi trường

Mở trong Codespaces

Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.

  • Có Dockerfile hoặc tệp Docker Compose
  • Không có mẫu pull request
  • Không có hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của docker/docker-agent

Tất cả issue của docker/docker-agent

Issue tương tự

Thêm issue về Go

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.