-
-
Notifications
You must be signed in to change notification settings - Fork 36
feat(agent-model-api): agent turn execution slice (tsk-gkh4mi) #2175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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() | ||||||
|
|
||||||
|
|
||||||
|
|
@@ -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)) | ||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Client error becomes 502 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
|
||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4. Stale 501 test expectation 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
|
||||||
|
|
||||||
|
|
||||||
| 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", "") | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Reply with |
||||||
| 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", "") | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]:
Suggested change
Reply with |
||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Agent id used as model _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
|
||||||
| ) | ||||||
| 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) | ||||||
There was a problem hiding this comment.
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.Reply with
@kilocode-bot fix itto have Kilo Code address this issue.