Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

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

Abierto
#4,624 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
4/5
Tiempo estimado
3-5 días
Aptitud para principiantes
45/100
Tipo de issue
Nueva funcionalidad
Claridad
Bastante claro
Estado de actividad
Activo
Stack tecnológico
go

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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/

Lenguaje dominante
Go
Estrellas
4.4k
Forks
508
Merge medio
8 h 6 min
PR fusionados (30 d)
209

Preparar el entorno

Abrir en Codespaces

Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.

  • Incluye un Dockerfile o un archivo de Docker Compose
  • Sin plantilla de pull request
  • Sin guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de docker/docker-agent

Todos los issues de docker/docker-agent

Issues similares

Más issues de Go

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.