Skip to content

Commit 33e32b5

Browse files
authored
Merge pull request #2 from LLM-project-Team/dynamo/transport-refactor
Dynamo/transport refactor
2 parents 0e12ea0 + 49b166d commit 33e32b5

14 files changed

Lines changed: 679 additions & 109 deletions

File tree

‎tests/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""TinyAgent test suite."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"model":"llama3.2:latest","created_at":"2026-10-10T03:55:03.747565489Z","response":"OK","done":true,"done_reason":"stop","context":[128006,9125,128007,271,38766,1303,33025,2696,25,6790,220,2366,18,271,128009,128006,882,128007,271,21509,449,832,3492,25,10619,128009,128006,78191,128007,271,4012],"total_duration":2482083673,"load_duration":2385723400,"prompt_eval_count":31,"prompt_eval_cached_count":0,"prompt_eval_duration":58650000,"eval_count":2,"eval_duration":35593000}

‎tests/stub_server.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Threaded local HTTP server for backend contract tests."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7+
from threading import Lock, Thread
8+
from time import sleep
9+
10+
11+
class _Handler(BaseHTTPRequestHandler):
12+
def do_POST(self) -> None:
13+
length = int(self.headers.get("Content-Length", "0"))
14+
raw_body = self.rfile.read(length)
15+
try:
16+
decoded = json.loads(raw_body.decode("utf-8"))
17+
except (UnicodeError, ValueError):
18+
decoded = None
19+
20+
state = self.server.state # type: ignore[attr-defined]
21+
with state.lock:
22+
state.requests.append(
23+
{
24+
"path": self.path,
25+
"headers": dict(self.headers.items()),
26+
"body": raw_body,
27+
"json": decoded,
28+
}
29+
)
30+
status, body, delay_s = state.response
31+
if delay_s:
32+
sleep(delay_s)
33+
self.send_response(status)
34+
self.send_header("Content-Type", "application/json")
35+
self.send_header("Content-Length", str(len(body)))
36+
self.end_headers()
37+
try:
38+
self.wfile.write(body)
39+
except OSError:
40+
pass
41+
42+
def log_message(self, format: str, *args: object) -> None:
43+
return
44+
45+
46+
class StubServer:
47+
def __init__(self) -> None:
48+
self.lock = Lock()
49+
self.requests: list[dict] = []
50+
self.response: tuple[int, bytes, float] = (200, b"{}", 0.0)
51+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
52+
self.server.state = self # type: ignore[attr-defined]
53+
self.thread = Thread(target=self.server.serve_forever, daemon=True)
54+
self.thread.start()
55+
56+
@property
57+
def url(self) -> str:
58+
host, port = self.server.server_address
59+
return f"http://{host}:{port}"
60+
61+
def set_response(
62+
self, status: int = 200, body: bytes | str | dict | list = b"{}", delay_s: float = 0
63+
) -> None:
64+
if isinstance(body, (dict, list)):
65+
encoded = json.dumps(body).encode("utf-8")
66+
elif isinstance(body, str):
67+
encoded = body.encode("utf-8")
68+
else:
69+
encoded = body
70+
with self.lock:
71+
self.response = (status, encoded, delay_s)
72+
73+
def close(self) -> None:
74+
self.server.shutdown()
75+
self.server.server_close()
76+
self.thread.join(timeout=1)

‎tests/test_backends.py‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""PRD-02 transport tests against a local standard-library HTTP stub."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import socket
7+
import unittest
8+
import warnings
9+
10+
from tests.stub_server import StubServer
11+
from tinyagent.backends import LlamaCppBackend, OllamaBackend
12+
from tinyagent.errors import BackendError
13+
14+
15+
DEFAULT_STOP = ["\n\n", "\nQuery:", "\nReply:"]
16+
17+
18+
class BackendTest(unittest.TestCase):
19+
def setUp(self) -> None:
20+
self.server = StubServer()
21+
self.addCleanup(self.server.close)
22+
23+
def _ollama(self, *, timeout_s: float = 60.0, num_ctx: int | None = None):
24+
return OllamaBackend(
25+
"test-model", host=self.server.url, timeout_s=timeout_s, num_ctx=num_ctx
26+
)
27+
28+
def _llamacpp(self, *, timeout_s: float = 60.0):
29+
return LlamaCppBackend(base_url=self.server.url, timeout_s=timeout_s)
30+
31+
def test_ollama_request_contract(self) -> None:
32+
self.server.set_response(200, {"response": " raw\ntext "})
33+
result = self._ollama().complete("prompt", stop=DEFAULT_STOP, max_tokens=96)
34+
request = self.server.requests[0]
35+
self.assertEqual(request["path"], "/api/generate")
36+
headers = {name.lower(): value for name, value in request["headers"].items()}
37+
self.assertEqual(headers["content-type"], "application/json")
38+
self.assertEqual(
39+
request["json"],
40+
{
41+
"model": "test-model",
42+
"prompt": "prompt",
43+
"stream": False,
44+
"raw": False,
45+
"options": {
46+
"temperature": 0.0,
47+
"top_k": 1,
48+
"top_p": 1.0,
49+
"seed": 42,
50+
"num_predict": 96,
51+
"stop": DEFAULT_STOP,
52+
},
53+
},
54+
)
55+
self.assertEqual(result.text, " raw\ntext ")
56+
self.assertEqual(result.backend, "ollama:test-model")
57+
self.assertEqual(len(self.server.requests), 1)
58+
59+
def test_ollama_optional_context_and_token_counts(self) -> None:
60+
self.server.set_response(
61+
200,
62+
{"response": "ok", "prompt_eval_count": 5, "eval_count": 2},
63+
)
64+
result = self._ollama(num_ctx=2048).complete(
65+
"prompt", stop=(), max_tokens=3
66+
)
67+
self.assertEqual(self.server.requests[0]["json"]["options"]["num_ctx"], 2048)
68+
self.assertEqual((result.prompt_tokens, result.completion_tokens), (5, 2))
69+
70+
def test_ollama_omits_context_and_invalid_token_counts(self) -> None:
71+
self.server.set_response(
72+
200,
73+
{"response": "ok", "prompt_eval_count": "5", "eval_count": True},
74+
)
75+
result = self._ollama().complete("prompt", stop=(), max_tokens=1)
76+
self.assertNotIn("num_ctx", self.server.requests[0]["json"]["options"])
77+
self.assertEqual((result.prompt_tokens, result.completion_tokens), (None, None))
78+
79+
def test_llamacpp_request_contract(self) -> None:
80+
self.server.set_response(
81+
200,
82+
{"content": "unchanged", "tokens_evaluated": 7, "tokens_predicted": 4},
83+
)
84+
result = self._llamacpp().complete("prompt", stop=DEFAULT_STOP, max_tokens=96)
85+
request = self.server.requests[0]
86+
self.assertEqual(request["path"], "/completion")
87+
self.assertEqual(
88+
request["json"],
89+
{
90+
"prompt": "prompt",
91+
"stream": False,
92+
"temperature": 0.0,
93+
"top_k": 1,
94+
"top_p": 1.0,
95+
"seed": 42,
96+
"n_predict": 96,
97+
"stop": DEFAULT_STOP,
98+
"cache_prompt": True,
99+
},
100+
)
101+
self.assertEqual((result.text, result.backend), ("unchanged", "llamacpp"))
102+
self.assertEqual((result.prompt_tokens, result.completion_tokens), (7, 4))
103+
104+
def test_constructor_and_call_validation(self) -> None:
105+
for model in ("", " ", None):
106+
with self.subTest(model=model), self.assertRaises(ValueError):
107+
OllamaBackend(model) # type: ignore[arg-type]
108+
backend = self._ollama()
109+
for max_tokens in (0, 513, 1.5, True):
110+
with self.subTest(max_tokens=max_tokens), self.assertRaises(ValueError):
111+
backend.complete("prompt", stop=(), max_tokens=max_tokens)
112+
with self.assertRaises(ValueError):
113+
backend.complete("prompt", stop=None, max_tokens=96) # type: ignore[arg-type]
114+
self.assertEqual(self.server.requests, [])
115+
116+
def test_sampling_has_no_public_override(self) -> None:
117+
self.assertNotIn("temperature", OllamaBackend.__init__.__annotations__)
118+
self.assertNotIn("temperature", LlamaCppBackend.__init__.__annotations__)
119+
self.assertNotIn("temperature", OllamaBackend.complete.__annotations__)
120+
self.assertNotIn("temperature", LlamaCppBackend.complete.__annotations__)
121+
122+
def test_unreachable_is_typed_and_uses_one_attempt(self) -> None:
123+
probe = socket.socket()
124+
probe.bind(("127.0.0.1", 0))
125+
port = probe.getsockname()[1]
126+
probe.close()
127+
backend = OllamaBackend("test-model", host=f"http://127.0.0.1:{port}")
128+
with self.assertRaises(BackendError) as raised:
129+
backend.complete("secret prompt", stop=(), max_tokens=1)
130+
self.assertEqual(raised.exception.code, "E_BACKEND_UNREACHABLE")
131+
self.assertNotIn("secret prompt", raised.exception.detail)
132+
self.assertLessEqual(len(raised.exception.detail), 200)
133+
134+
def test_timeout_is_typed(self) -> None:
135+
self.server.set_response(200, {"response": "late"}, delay_s=0.4)
136+
with self.assertRaises(BackendError) as raised:
137+
self._ollama(timeout_s=0.05).complete(
138+
"prompt", stop=(), max_tokens=1
139+
)
140+
self.assertEqual(raised.exception.code, "E_BACKEND_TIMEOUT")
141+
self.assertIn("0.05s", raised.exception.detail)
142+
self.assertEqual(len(self.server.requests), 1)
143+
144+
def test_http_error_precedes_url_error_mapping(self) -> None:
145+
for count, status in enumerate((404, 500), 1):
146+
with self.subTest(status=status):
147+
self.server.set_response(status, "server says no")
148+
with self.assertRaises(BackendError) as raised:
149+
self._ollama().complete("secret prompt", stop=(), max_tokens=1)
150+
self.assertEqual(raised.exception.code, "E_BACKEND_HTTP")
151+
self.assertIn(f"HTTP {status}", raised.exception.detail)
152+
self.assertNotIn("secret prompt", raised.exception.detail)
153+
self.assertEqual(len(self.server.requests), count)
154+
155+
def test_ollama_error_field(self) -> None:
156+
self.server.set_response(
157+
200, {"error": "model not found: secret prompt " + "x" * 250}
158+
)
159+
with self.assertRaises(BackendError) as raised:
160+
self._ollama().complete("secret prompt", stop=(), max_tokens=1)
161+
self.assertEqual(raised.exception.code, "E_BACKEND_HTTP")
162+
self.assertNotIn("secret prompt", raised.exception.detail)
163+
self.assertLessEqual(len(raised.exception.detail), 200)
164+
165+
def test_protocol_errors(self) -> None:
166+
cases = (
167+
(b"not json", self._ollama()),
168+
(b"[]", self._ollama()),
169+
(b'{"response": 2}', self._ollama()),
170+
(b'{"content": null}', self._llamacpp()),
171+
)
172+
for body, backend in cases:
173+
with self.subTest(body=body, backend=backend):
174+
self.server.set_response(200, body)
175+
with self.assertRaises(BackendError) as raised:
176+
backend.complete("prompt", stop=(), max_tokens=1)
177+
self.assertEqual(raised.exception.code, "E_BACKEND_PROTOCOL")
178+
179+
def test_fifty_malformed_responses_only_raise_backend_error(self) -> None:
180+
backend = self._ollama()
181+
for index in range(50):
182+
body = (b"\xff" * (index + 1)) if index % 2 else json.dumps(
183+
{"wrong": index}
184+
).encode("utf-8")
185+
self.server.set_response(200, body)
186+
with self.subTest(index=index), self.assertRaises(BackendError):
187+
backend.complete("prompt", stop=(), max_tokens=1)
188+
self.assertEqual(len(self.server.requests), 50)
189+
190+
def test_remote_host_warns_and_repr_does_not_hold_prompt(self) -> None:
191+
with warnings.catch_warnings(record=True) as caught:
192+
warnings.simplefilter("always")
193+
backend = OllamaBackend("test-model", host="http://example.invalid:11434")
194+
self.assertEqual(len(caught), 1)
195+
self.assertIn("non-loopback", str(caught[0].message))
196+
self.assertNotIn("prompt", repr(backend).lower())
197+
198+
199+
if __name__ == "__main__":
200+
unittest.main()

‎tinyagent/agent.py‎

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,22 @@ def run(self, query: str) -> AgentResult:
4444
current = original
4545
outputs: list[str] = []
4646
last_tool_result: ToolResult | None = None
47+
attempt = 0
48+
49+
def finish(status, text, provenance, error_code=None) -> AgentResult:
50+
# Reads the loop state (attempt, outputs, last_tool_result) at call time.
51+
return AgentResult(
52+
status, text, provenance, last_tool_result,
53+
attempt + 1, tuple(outputs), error_code,
54+
)
4755

4856
for attempt in range(1 + self.max_repairs):
49-
calls = attempt + 1
5057
try:
5158
completion = self.backend.complete(
5259
current, stop=STOP_SEQUENCES, max_tokens=self.max_tokens
5360
)
5461
except BackendError as error:
55-
return AgentResult(
56-
"error", f"{error.code}: {error.detail}", "none",
57-
last_tool_result, calls, tuple(outputs), error.code,
58-
)
62+
return finish("error", f"{error.code}: {error.detail}", "none", error.code)
5963

6064
outputs.append(completion.text)
6165
outcome = parse(completion.text)
@@ -65,33 +69,23 @@ def run(self, query: str) -> AgentResult:
6569
)
6670
last_tool_result = result
6771
if result.ok:
68-
return AgentResult(
69-
"tool", result.output or "", "tool", result,
70-
calls, tuple(outputs), None,
71-
)
72+
return finish("tool", result.output or "", "tool")
7273
code = result.error_code or "E_TOOL_EXCEPTION"
73-
if code in EXEC_TERMINAL:
74-
return AgentResult(
75-
"error", f"{code}: {result.error_detail or ''}".rstrip(": "),
76-
"none", result, calls, tuple(outputs), code,
77-
)
7874
detail = result.error_detail or ""
75+
if code in EXEC_TERMINAL:
76+
return finish("error", f"{code}: {detail}".rstrip(": "), "none", code)
7977
elif isinstance(outcome, ParsedFinal):
8078
if not self.require_tool:
81-
return AgentResult(
82-
"final", outcome.text, "model", last_tool_result,
83-
calls, tuple(outputs), None,
84-
)
79+
return finish("final", outcome.text, "model")
8580
code, detail = "E_FINAL_NOT_ALLOWED", ""
8681
elif isinstance(outcome, ParseFailure):
8782
code, detail = outcome.code, ""
8883
else:
8984
raise TypeError("parser returned an invalid outcome")
9085

9186
if attempt == self.max_repairs:
92-
return AgentResult(
93-
"error", f"retry exhausted after {code}", "none",
94-
last_tool_result, calls, tuple(outputs), "E_RETRY_EXHAUSTED",
87+
return finish(
88+
"error", f"retry exhausted after {code}", "none", "E_RETRY_EXHAUSTED"
9589
)
9690
current = render_repair_prompt(
9791
original, completion.text, code, detail

‎tinyagent/backends/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Supported native local inference backends."""
2+
3+
from tinyagent.backends.llamacpp import LlamaCppBackend
4+
from tinyagent.backends.ollama import OllamaBackend
5+
6+
__all__ = ["OllamaBackend", "LlamaCppBackend"]

0 commit comments

Comments
 (0)