tikuu-agent-runner 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.3
2
+ Name: tikuu-agent-runner
3
+ Version: 0.1.0
4
+ Summary: SDK that wraps a Tikuu agent invoke handler in a FastAPI /ping + /invoke server
5
+ Author: Utkarsh Tiwari
6
+ Author-email: Utkarsh Tiwari <tiwariutkarsh66@gmail.com>
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: uvicorn[standard]>=0.32.0
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # tikuu-agent-runner
13
+
14
+ Python SDK that wraps a Tikuu agent invoke handler in the HTTP surface expected
15
+ by tikuu-api-server (`GET /ping`, `POST /invoke`).
16
+
17
+ For usage, see [docs/USAGE.md](docs/USAGE.md).
18
+ For the HTTP contract, see [docs/API_REFERENCE.md](docs/API_REFERENCE.md).
19
+ For package layout, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
20
+
21
+ ## Requirements
22
+
23
+ - Python 3.13+
24
+ - [uv](https://docs.astral.sh/uv/)
25
+
26
+ ## Setup
27
+
28
+ Install dependencies into a local virtual environment:
29
+
30
+ ```bash
31
+ uv sync
32
+ ```
@@ -0,0 +1,21 @@
1
+ # tikuu-agent-runner
2
+
3
+ Python SDK that wraps a Tikuu agent invoke handler in the HTTP surface expected
4
+ by tikuu-api-server (`GET /ping`, `POST /invoke`).
5
+
6
+ For usage, see [docs/USAGE.md](docs/USAGE.md).
7
+ For the HTTP contract, see [docs/API_REFERENCE.md](docs/API_REFERENCE.md).
8
+ For package layout, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
9
+
10
+ ## Requirements
11
+
12
+ - Python 3.13+
13
+ - [uv](https://docs.astral.sh/uv/)
14
+
15
+ ## Setup
16
+
17
+ Install dependencies into a local virtual environment:
18
+
19
+ ```bash
20
+ uv sync
21
+ ```
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "tikuu-agent-runner"
3
+ version = "0.1.0"
4
+ description = "SDK that wraps a Tikuu agent invoke handler in a FastAPI /ping + /invoke server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "fastapi>=0.115.0",
9
+ "uvicorn[standard]>=0.32.0",
10
+ ]
11
+
12
+ [[project.authors]]
13
+ name = "Utkarsh Tiwari"
14
+ email = "tiwariutkarsh66@gmail.com"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.1,<0.13.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = ["httpx>=0.28.1"]
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "tikuu-agent-runner"
3
+ version = "0.1.0"
4
+ description = "SDK that wraps a Tikuu agent invoke handler in a FastAPI /ping + /invoke server"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Utkarsh Tiwari", email = "tiwariutkarsh66@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "fastapi>=0.115.0",
12
+ "uvicorn[standard]>=0.32.0",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.12.1,<0.13.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "httpx>=0.28.1",
22
+ ]
@@ -0,0 +1,13 @@
1
+ """Tikuu Agent Runner: decorate an invoke handler and serve `/ping` + `/invoke`.
2
+
3
+ The public surface is intentionally small — import ``TikuuAgentRunner`` and
4
+ register one async ``(payload, context)`` handler before calling ``run()``.
5
+ """
6
+
7
+ from tikuu_agent_runner.errors import RunnerConfigurationError
8
+ from tikuu_agent_runner.runner import TikuuAgentRunner
9
+
10
+ __all__ = [
11
+ "RunnerConfigurationError",
12
+ "TikuuAgentRunner",
13
+ ]
@@ -0,0 +1,126 @@
1
+ """FastAPI application factory for agent containers.
2
+
3
+ Owns only HTTP concerns: readiness (``GET /ping``) and invoke
4
+ (``POST /invoke``). Business logic stays in the user-supplied handler.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from typing import Any
12
+
13
+ from fastapi import FastAPI, Request, Response, status
14
+ from fastapi.encoders import jsonable_encoder
15
+ from fastapi.responses import JSONResponse
16
+
17
+ from tikuu_agent_runner.constants import INVOKE_PATH, PING_PATH
18
+ from tikuu_agent_runner.context import build_invoke_context
19
+ from tikuu_agent_runner.handler import InvokeHandler
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def create_app(invoke_handler: InvokeHandler) -> FastAPI:
25
+ """Build a FastAPI app wired to a single invoke handler.
26
+
27
+ Args:
28
+ invoke_handler: Async ``(payload, context)`` callable registered via
29
+ ``TikuuAgentRunner.invoke``.
30
+
31
+ Returns:
32
+ A configured FastAPI application ready for uvicorn.
33
+ """
34
+ app = FastAPI(
35
+ title="Tikuu Agent Runner",
36
+ description=(
37
+ "In-container HTTP surface for Tikuu agents: "
38
+ f"`GET {PING_PATH}` for readiness and "
39
+ f"`POST {INVOKE_PATH}` for invoke."
40
+ ),
41
+ version="0.1.0",
42
+ )
43
+
44
+ @app.get(
45
+ PING_PATH,
46
+ status_code=status.HTTP_200_OK,
47
+ summary="Readiness probe",
48
+ response_description="Empty 200 when the process is ready to invoke",
49
+ )
50
+ async def ping() -> Response:
51
+ """Return HTTP 200 so the API server can treat the container as ready.
52
+
53
+ Body is intentionally empty: the probe only checks status code.
54
+ """
55
+ return Response(status_code=status.HTTP_200_OK)
56
+
57
+ @app.post(
58
+ INVOKE_PATH,
59
+ summary="Invoke agent handler",
60
+ response_description="JSON body produced by the user handler",
61
+ )
62
+ async def invoke(request: Request) -> JSONResponse:
63
+ """Parse JSON body, build context, call the user handler, return JSON.
64
+
65
+ Fail-cheap order: decode body → require JSON object → call handler →
66
+ encode result. Handler exceptions become 500 without crashing the
67
+ process so the container stays ready for later invokes.
68
+ """
69
+ try:
70
+ payload = await _parse_payload(request)
71
+ except json.JSONDecodeError:
72
+ return JSONResponse(
73
+ status_code=status.HTTP_400_BAD_REQUEST,
74
+ content={"detail": "Request body must be valid JSON"},
75
+ )
76
+ except _PayloadNotObjectError:
77
+ return JSONResponse(
78
+ status_code=status.HTTP_400_BAD_REQUEST,
79
+ content={"detail": "Request body must be a JSON object"},
80
+ )
81
+
82
+ context = build_invoke_context(request.headers)
83
+
84
+ try:
85
+ result = await invoke_handler(payload, context)
86
+ except Exception:
87
+ # Log full traceback for operators; keep the HTTP body generic so
88
+ # agent internals are not leaked to API clients.
89
+ logger.exception("Invoke handler raised an unexpected error")
90
+ return JSONResponse(
91
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
92
+ content={"detail": "Invoke handler failed"},
93
+ )
94
+
95
+ try:
96
+ content = jsonable_encoder(result)
97
+ except Exception:
98
+ logger.exception("Invoke handler return value is not JSON-serializable")
99
+ return JSONResponse(
100
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
101
+ content={"detail": "Invoke handler returned non-JSON value"},
102
+ )
103
+
104
+ return JSONResponse(status_code=status.HTTP_200_OK, content=content)
105
+
106
+ return app
107
+
108
+
109
+ class _PayloadNotObjectError(Exception):
110
+ """Internal: body decoded as JSON but was not a JSON object (dict)."""
111
+
112
+
113
+ async def _parse_payload(request: Request) -> dict[str, Any]:
114
+ """Decode the request body as a JSON object.
115
+
116
+ The API server validates JSON before proxying; this re-check keeps local
117
+ ``runner.run()`` usage safe when callers hit the agent directly.
118
+ """
119
+ body = await request.body()
120
+ # Empty body is not a valid JSON object under our contract; reject early.
121
+ if len(body) == 0:
122
+ raise _PayloadNotObjectError
123
+ data = json.loads(body)
124
+ if not isinstance(data, dict):
125
+ raise _PayloadNotObjectError
126
+ return data
@@ -0,0 +1,22 @@
1
+ """Shared constants for the agent HTTP contract.
2
+
3
+ Kept in one module so paths and header names stay aligned with
4
+ ``tikuu-api-server`` invoke proxy defaults (``/ping``, ``/invoke``,
5
+ ``X-Session-Id``).
6
+ """
7
+
8
+ # Paths the API server readiness probe and invoke proxy call on the container.
9
+ PING_PATH = "/ping"
10
+ INVOKE_PATH = "/invoke"
11
+
12
+ # Canonical session header forwarded by the API server to the agent container.
13
+ SESSION_HEADER = "X-Session-Id"
14
+
15
+ # Bind defaults: container port published by the API is 8000/tcp, so the
16
+ # process must listen on all interfaces inside the container.
17
+ DEFAULT_HOST = "0.0.0.0"
18
+ DEFAULT_PORT = 8000
19
+
20
+ # Exact parameter names required on an ``@runner.invoke`` handler.
21
+ PAYLOAD_PARAM = "payload"
22
+ CONTEXT_PARAM = "context"
@@ -0,0 +1,49 @@
1
+ """Build the invoke ``context`` dict from inbound HTTP metadata.
2
+
3
+ Separated from the FastAPI route so context construction stays easy to
4
+ unit-test and can grow (more headers / env) without touching routing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+
11
+ from tikuu_agent_runner.constants import SESSION_HEADER
12
+
13
+
14
+ def build_invoke_context(headers: Mapping[str, str]) -> dict[str, str]:
15
+ """Return metadata for the invoke handler's ``context`` argument.
16
+
17
+ ``session_id`` is included only when ``X-Session-Id`` is present and
18
+ non-empty after stripping whitespace. Missing or blank headers omit the
19
+ key entirely so handlers can use ``context.get("session_id")``.
20
+
21
+ Args:
22
+ headers: Inbound request headers (case-insensitive lookup supported
23
+ via Starlette's ``Headers`` or a plain mapping).
24
+
25
+ Returns:
26
+ A new dict of context fields for the user handler.
27
+ """
28
+ context: dict[str, str] = {}
29
+ # Starlette Headers support case-insensitive get; plain dicts may not,
30
+ # so normalize by trying the canonical name then a lower-case scan.
31
+ session_id = _get_header(headers, SESSION_HEADER)
32
+ if session_id is not None:
33
+ stripped = session_id.strip()
34
+ if stripped:
35
+ context["session_id"] = stripped
36
+ return context
37
+
38
+
39
+ def _get_header(headers: Mapping[str, str], name: str) -> str | None:
40
+ """Read a header by name, tolerant of case differences in plain dicts."""
41
+ # Prefer Mapping.get when the mapping already does case-insensitive lookup.
42
+ direct = headers.get(name)
43
+ if direct is not None:
44
+ return direct
45
+ target = name.lower()
46
+ for key, value in headers.items():
47
+ if key.lower() == target:
48
+ return value
49
+ return None
@@ -0,0 +1,15 @@
1
+ """Domain errors raised by the runner before the ASGI server starts.
2
+
3
+ These are fail-fast configuration mistakes (wrong decorator usage), not
4
+ HTTP-layer errors. Request-time failures are mapped to status codes inside
5
+ the FastAPI routes instead.
6
+ """
7
+
8
+
9
+ class RunnerConfigurationError(Exception):
10
+ """Raised when the runner is misconfigured before serving requests.
11
+
12
+ Examples: decorating a second invoke handler, calling ``run()`` with no
13
+ handler, or registering a handler whose signature is not
14
+ ``(payload, context)``.
15
+ """
@@ -0,0 +1,73 @@
1
+ """Invoke handler typing and signature validation.
2
+
3
+ The public contract is a single async callable named parameters
4
+ ``payload`` then ``context``. Validation runs at decoration time so
5
+ miswired workflows fail before the process listens for traffic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ from tikuu_agent_runner.constants import CONTEXT_PARAM, PAYLOAD_PARAM
15
+ from tikuu_agent_runner.errors import RunnerConfigurationError
16
+
17
+ # User handler: payload and context are dicts; return value becomes JSON.
18
+ InvokeHandler = Callable[[dict[str, Any], dict[str, Any]], Awaitable[Any]]
19
+
20
+
21
+ def validate_invoke_handler(func: Callable[..., Any]) -> InvokeHandler:
22
+ """Ensure ``func`` is an async ``(payload, context)`` handler.
23
+
24
+ Args:
25
+ func: Candidate decorated by ``@runner.invoke``.
26
+
27
+ Returns:
28
+ The same function, typed as ``InvokeHandler``.
29
+
30
+ Raises:
31
+ RunnerConfigurationError: Sync function, wrong arity, or parameter
32
+ names/order that are not exactly ``payload``, ``context``.
33
+ """
34
+ if not inspect.iscoroutinefunction(func):
35
+ raise RunnerConfigurationError(
36
+ "@runner.invoke requires an async def handler"
37
+ )
38
+
39
+ signature = inspect.signature(func)
40
+ params = list(signature.parameters.values())
41
+ # Reject *args/**kwargs and defaults so the contract stays explicit.
42
+ if len(params) != 2:
43
+ raise RunnerConfigurationError(
44
+ "@runner.invoke handler must accept exactly two parameters: "
45
+ f"{PAYLOAD_PARAM}, {CONTEXT_PARAM}"
46
+ )
47
+
48
+ expected = (PAYLOAD_PARAM, CONTEXT_PARAM)
49
+ actual = tuple(param.name for param in params)
50
+ if actual != expected:
51
+ raise RunnerConfigurationError(
52
+ "@runner.invoke handler parameters must be named "
53
+ f"{PAYLOAD_PARAM}, {CONTEXT_PARAM} in that order "
54
+ f"(got {', '.join(actual)})"
55
+ )
56
+
57
+ for param in params:
58
+ if param.kind not in (
59
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
60
+ inspect.Parameter.POSITIONAL_ONLY,
61
+ inspect.Parameter.KEYWORD_ONLY,
62
+ ):
63
+ raise RunnerConfigurationError(
64
+ "@runner.invoke handler must not use *args or **kwargs"
65
+ )
66
+ if param.default is not inspect.Parameter.empty:
67
+ # Defaults would hide missing payload/context; keep the call site
68
+ # always supplying both from the HTTP layer.
69
+ raise RunnerConfigurationError(
70
+ "@runner.invoke handler parameters must not have defaults"
71
+ )
72
+
73
+ return func # type: ignore[return-value]
@@ -0,0 +1,106 @@
1
+ """``TikuuAgentRunner``: decorate one invoke handler and serve HTTP.
2
+
3
+ Typical usage lives in a workflow module::
4
+
5
+ from tikuu_agent_runner import TikuuAgentRunner
6
+
7
+ runner = TikuuAgentRunner()
8
+
9
+ @runner.invoke
10
+ async def invoke_handler(payload: dict, context: dict) -> dict:
11
+ return {"message": "Hello, world!"}
12
+
13
+ if __name__ == "__main__":
14
+ runner.run()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from typing import Any, TypeVar
21
+
22
+ from fastapi import FastAPI
23
+
24
+ from tikuu_agent_runner.app import create_app
25
+ from tikuu_agent_runner.constants import DEFAULT_HOST, DEFAULT_PORT
26
+ from tikuu_agent_runner.errors import RunnerConfigurationError
27
+ from tikuu_agent_runner.handler import InvokeHandler, validate_invoke_handler
28
+
29
+ F = TypeVar("F", bound=Callable[..., Any])
30
+
31
+
32
+ class TikuuAgentRunner:
33
+ """Host a single agent invoke handler behind FastAPI ``/ping`` and ``/invoke``.
34
+
35
+ Attributes:
36
+ host: Bind address used by ``run()`` (default ``0.0.0.0`` for Docker).
37
+ port: Bind port used by ``run()`` (default ``8000`` to match the API
38
+ server's ``AGENT_CONTAINER_PORT``).
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ host: str = DEFAULT_HOST,
45
+ port: int = DEFAULT_PORT,
46
+ ) -> None:
47
+ """Create a runner with optional bind overrides for local development.
48
+
49
+ Args:
50
+ host: Interface uvicorn should bind.
51
+ port: TCP port uvicorn should bind.
52
+ """
53
+ self.host = host
54
+ self.port = port
55
+ # None until @invoke runs; run() refuses to start without a handler so
56
+ # /ping never advertises readiness for a container that cannot invoke.
57
+ self._invoke_handler: InvokeHandler | None = None
58
+
59
+ def invoke(self, func: F) -> F:
60
+ """Register ``func`` as the sole ``POST /invoke`` handler.
61
+
62
+ The decorated function must be ``async def`` with parameters named
63
+ ``payload`` and ``context`` (in that order). A second decoration on
64
+ the same runner instance raises ``RunnerConfigurationError``.
65
+
66
+ Args:
67
+ func: Async handler receiving ``(payload, context)`` dicts.
68
+
69
+ Returns:
70
+ The original function (unwrapped) so tests can call it directly.
71
+
72
+ Raises:
73
+ RunnerConfigurationError: Handler already set or signature invalid.
74
+ """
75
+ if self._invoke_handler is not None:
76
+ raise RunnerConfigurationError(
77
+ "Only one @runner.invoke handler may be registered"
78
+ )
79
+ self._invoke_handler = validate_invoke_handler(func)
80
+ return func
81
+
82
+ def build_app(self) -> FastAPI:
83
+ """Return the FastAPI app for this runner (useful in tests).
84
+
85
+ Raises:
86
+ RunnerConfigurationError: No handler registered yet.
87
+ """
88
+ if self._invoke_handler is None:
89
+ raise RunnerConfigurationError(
90
+ "No @runner.invoke handler registered; call run() only after "
91
+ "decorating an invoke handler"
92
+ )
93
+ return create_app(self._invoke_handler)
94
+
95
+ def run(self) -> None:
96
+ """Start uvicorn and block until the server exits.
97
+
98
+ Raises:
99
+ RunnerConfigurationError: No handler registered yet.
100
+ """
101
+ # Import uvicorn lazily so importing the SDK does not require the
102
+ # server stack until the process actually listens.
103
+ import uvicorn
104
+
105
+ app = self.build_app()
106
+ uvicorn.run(app, host=self.host, port=self.port)