Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 112 additions & 6 deletions tinyagentos/routes/agent_model_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@
"""
from __future__ import annotations

import json
import logging
import time
from datetime import datetime

from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse

logger = logging.getLogger(__name__)

router = APIRouter()


Expand Down Expand Up @@ -125,10 +130,111 @@ def _bad_request(message: str) -> JSONResponse:
code="model_not_found",
status=404,
)
# Contract satisfied; the turn execution is the next slice.
return _openai_error(
"agent turn execution is not yet implemented for this surface",
type="server_error",
code="not_implemented",
status=501,

# --- Turn execution slice (tsk-gkh4mi) ---------------------------------
# Consent + scope contract satisfied. Drive ONE non-streaming turn through
# the agent's opencode host-server (seam: consent key -> agent -> that
# agent's opencode server + LiteLLM virtual key -> one turn -> OpenAI shape).
# The requested `model` is the agent_id; the host runs the taOS agent's
# opencode server, so we resolve it the same way the chat endpoint does.
stream = bool(body.get("stream", False))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Stream flag accepts non-boolean truthy values

bool(body.get("stream", False)) treats any truthy value as streaming, including the JSON string "false". A client sending "stream": "false" would unexpectedly receive an SSE response instead of a JSON completion.

Suggested change
stream = bool(body.get("stream", False))
stream = body.get("stream") is True

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

try:
reply_text = await _run_agent_turn(request.app.state, model, messages)
except _TurnError as e:
return _openai_error(str(e), type="server_error", code="agent_error", status=502)
Comment on lines +140 to +144

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Client error becomes 502 🐞 Bug ≡ Correctness

When no usable user message text is found, _run_agent_turn() raises _TurnError("no user message
found in request"), and chat_completions maps all _TurnError to HTTP 502 server_error. This
misclassifies a client request validation failure as an upstream/transport failure.
Agent Prompt
## Issue description
Missing/empty user input is currently surfaced as a 502 server_error because `_TurnError` is always mapped to 502.

## Issue Context
The route already has `_bad_request(...)` for OpenAI-shaped 400s, but the “no user message” case is only detected inside `_run_agent_turn()`.

## Fix Focus Areas
- tinyagentos/routes/agent_model_api.py[140-149]
- tinyagentos/routes/agent_model_api.py[172-185]

Suggested approach:
- Validate that there is at least one `{"role":"user"}` message with non-empty text before calling `_run_agent_turn()` and return `_bad_request(...)`.
- Alternatively, introduce a separate exception type (e.g. `_TurnBadRequest`) mapped to 400, keeping `_TurnError` mapped to 502.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

except Exception as e: # defensive: never leak internals as a 500 trace
logger.exception("agent_model_api: turn failed")
return _openai_error(
"agent turn failed", type="server_error", code="agent_error", status=502
)

if stream:
# Streaming not in the locked seam for this slice; return the completed
# turn as a single SSE chunk so standard clients still work.
return _chat_completion_stream(reply_text, model)
return _chat_completion(reply_text, model)
Comment on lines +134 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Stale 501 test expectation 🐞 Bug ☼ Reliability

The PR removes the 501 not_implemented stub and now executes a turn, but an existing endpoint test
still asserts that contract-valid calls return 501. Unless the test is updated to mock the new
runtime and assert the new completion behavior, CI will fail.
Agent Prompt
## Issue description
`tests/test_routes_agent_model_api.py` still asserts a 501 not_implemented response for contract-valid requests, but the implementation now runs a turn and returns a completion (or 502 on runtime failure).

## Issue Context
The unit test should not depend on a real opencode binary/process; it should mock `_run_agent_turn()` (or underlying `ensure_taos_opencode_server`/`drive_turn`) and assert the OpenAI envelope returned.

## Fix Focus Areas
- tinyagentos/routes/agent_model_api.py[134-155]
- tests/test_routes_agent_model_api.py[101-112]

Suggested updates:
- Replace the 501 assertion with a 200 assertion and validate `object == "chat.completion"` and the assistant message content.
- Add a test that forces `_run_agent_turn` to raise `_TurnError` and asserts HTTP 502.
- Optionally add a streaming=true test asserting SSE framing and `[DONE]`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



class _TurnError(Exception):
"""Raised when the agent turn cannot be driven (server not ready, etc.)."""


async def _run_agent_turn(app_state, agent_id: str, messages: list) -> str:
"""Drive one non-streaming agent turn and return the final reply text.

Reuses the host opencode server lifecycle (ensure_taos_opencode_server) and
the opencode turn driver (drive_turn). Collects the 'final' reply from the
sink; degrades to _TurnError on transport failure so the caller returns 502.
"""
from tinyagentos.taos_agent_runtime import ensure_taos_opencode_server
from tinyagentos.opencode_runtime import drive_turn

# The last user message is the prompt; system/earlier messages are context
# the agent harness already carries per-turn, so we pass the latest user text.
user_text = ""
for m in reversed(messages):
if isinstance(m, dict) and m.get("role") == "user":
user_text = m.get("content", "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Empty user message content is rejected as "no message found"

If the last user message has content: "" or an empty content-parts list, not user_text is True and the endpoint raises _TurnError("no user message found in request"). An empty user message is still a user message; the error message is also misleading in that case.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if isinstance(user_text, list): # content parts -> flatten
user_text = " ".join(
p.get("text", "") for p in user_text if isinstance(p, dict)
)
break
if not user_text:
raise _TurnError("no user message found in request")

server = await ensure_taos_opencode_server(app_state, agent_id)
collected: dict = {"final": None}

def _sink(reply: dict) -> None:
if reply.get("kind") == "final":
collected["final"] = reply.get("content", "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: None content in a final reply is treated as no reply

reply.get("content", "") returns None when the final reply explicitly has content: None. The subsequent collected["final"] is None check then raises "agent returned no reply" even though a final reply was received.

Suggested change
collected["final"] = reply.get("content", "")
collected["final"] = reply.get("content") or ""

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

elif reply.get("kind") == "error" and collected["final"] is None:
collected["_error"] = reply.get("error", "agent turn failed")

await drive_turn(
user_text,
trace_id=None,
sink=_sink,
base_url=server.base_url,
model_id=agent_id,
model_provider_id="litellm",
server_password=getattr(app_state, "taos_opencode_password", None),
Comment on lines +186 to +202

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Agent id used as model 🐞 Bug ≡ Correctness

_run_agent_turn() passes agent_id into ensure_taos_opencode_server() and
drive_turn(model_id=agent_id), but opencode uses model_id as the provider model identifier in
prompt_async. If agent_id differs from the agent’s configured LLM model (common), opencode will
request a non-existent model and the route will return 502 for otherwise valid requests.
Agent Prompt
## Issue description
`_run_agent_turn()` currently treats the OpenAI `model` parameter (agent id per consent contract) as the opencode/LiteLLM `model_id`. In opencode, `model_id` is the *LLM model identifier* placed into the `prompt_async` body, so passing an agent id will make prompts fail.

## Issue Context
Agents have a configured LLM model (e.g. `agent_dict["model"]`) that should be used for opencode’s `modelID`.

## Fix Focus Areas
- Resolve the agent dict from `app_state.config.agents` using `agent_id` and extract its configured LLM model id.
- Call `ensure_taos_opencode_server(app_state, llm_model_id)` and `drive_turn(..., model_id=llm_model_id, ...)`.
- If the agent cannot be resolved or has no configured model, return an OpenAI-shaped 400 (invalid_request_error) rather than attempting the turn.

### References
- tinyagentos/routes/agent_model_api.py[162-203]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

)
if collected.get("_error") and collected["final"] is None:
raise _TurnError(collected["_error"])
if collected["final"] is None:
raise _TurnError("agent returned no reply")
return collected["final"]


def _chat_completion(content: str, model: str) -> JSONResponse:
"""OpenAI ChatCompletion (non-streaming) envelope."""
return JSONResponse({
"id": f"chatcmpl-{_short_id()}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
})


def _chat_completion_stream(content: str, model: str):
"""OpenAI SSE stream envelope (single completion chunk)."""
from fastapi.responses import StreamingResponse

def _gen():
yield f"data: {json.dumps({'id': f'chatcmpl-{_short_id()}', 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': model, 'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': content}, 'finish_reason': 'stop'}]})}\n\n"
yield "data: [DONE]\n\n"

return StreamingResponse(_gen(), media_type="text/event-stream")


def _short_id() -> str:
import secrets
return secrets.token_hex(8)
Loading