Skip to content
Merged
Show file tree
Hide file tree
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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: pip

- name: Install dependencies
run: pip install .[dev]

- name: Ruff
run: ruff check .

- name: Mypy
run: mypy src tests

test:
name: Test
runs-on: ubuntu-latest
needs: lint
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install dependencies
run: pip install .[dev]

- name: Pytest
run: pytest
2 changes: 1 addition & 1 deletion examples/custom_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path
from typing import Any

from tsunagi import Pipeline, Tracer, step
from tsunagi import Pipeline, step


class JSONLTracer:
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ strict = true
warn_return_any = true
warn_unused_configs = true

[[tool.mypy.overrides]]
module = "tests.*"
disable_error_code = ["arg-type"]

[tool.ruff]
target-version = "py311"
line-length = 100
Expand Down
23 changes: 13 additions & 10 deletions src/tsunagi/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@

import json
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from tsunagi.context import Context
from tsunagi.tool import Tool
from tsunagi.tracer import NullTracer, Tracer

if TYPE_CHECKING:
from collections.abc import AsyncIterator

from tsunagi.tool import Tool


@dataclass
class Message:
Expand Down Expand Up @@ -88,9 +92,10 @@ def __init__(

def _get_client(self) -> Any:
if self._client is None:
from anthropic import AsyncAnthropic
import importlib

self._client = AsyncAnthropic(**self.client_kwargs)
anthropic = importlib.import_module("anthropic")
self._client = anthropic.AsyncAnthropic(**self.client_kwargs)
return self._client

async def send(
Expand Down Expand Up @@ -148,9 +153,10 @@ def __init__(

def _get_client(self) -> Any:
if self._client is None:
from openai import AsyncOpenAI
import importlib

self._client = AsyncOpenAI(**self.client_kwargs)
openai_module = importlib.import_module("openai")
self._client = openai_module.AsyncOpenAI(**self.client_kwargs)
return self._client

async def send(
Expand Down Expand Up @@ -247,10 +253,7 @@ async def chat(self, user_message: str) -> str:
await self._tracer.on_step_start(ctx, tc.name, tc.arguments)
try:
result = await tool_obj(**tc.arguments)
if isinstance(result, str):
result_str = result
else:
result_str = json.dumps(result)
result_str = result if isinstance(result, str) else json.dumps(result)
await self._tracer.on_step_end(ctx, tc.name, result_str)
except Exception as e: # pragma: no cover - exercised in tests
result_str = json.dumps({"error": str(e)})
Expand Down
2 changes: 1 addition & 1 deletion src/tsunagi/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ async def run_parallel(
step_inputs = [input] * len(steps)

try:
tasks = [s.execute(inp) for s, inp in zip(steps, step_inputs)]
tasks = [s.execute(inp) for s, inp in zip(steps, step_inputs, strict=True)]
return list(await asyncio.gather(*tasks))
except Exception as e: # pragma: no cover - handled in pipeline tests
raise PipelineError("parallel", e) from e
5 changes: 4 additions & 1 deletion src/tsunagi/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

import random
from dataclasses import dataclass
from typing import Callable
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Callable


@dataclass(frozen=True)
Expand Down
19 changes: 13 additions & 6 deletions src/tsunagi/step.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@
import asyncio
import functools
import inspect
from typing import Any, Awaitable, Callable, TypeVar, overload
from typing import TYPE_CHECKING, Any, TypeVar, overload

from tsunagi.errors import StepError
from tsunagi.errors import TimeoutError as TsunagiTimeoutError
from tsunagi.retry import NO_RETRY, RetryConfig

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable

T = TypeVar("T")


Expand Down Expand Up @@ -70,19 +73,23 @@ async def execute(self, *args: Any, **kwargs: Any) -> Any:
"""
last_error: Exception | None = None

timeout_seconds = self.timeout_seconds

for attempt in range(self.retry.max_attempts):
try:
if self.timeout_seconds is not None:
if timeout_seconds is not None:
result = await asyncio.wait_for(
self.fn(*args, **kwargs),
timeout=self.timeout_seconds,
timeout=timeout_seconds,
)
else:
result = await self.fn(*args, **kwargs)
return result

except asyncio.TimeoutError:
raise TsunagiTimeoutError(self.name, self.timeout_seconds) from None
except TimeoutError:
if timeout_seconds is None:
raise
raise TsunagiTimeoutError(self.name, timeout_seconds) from None

except Exception as e:
last_error = e
Expand Down Expand Up @@ -150,7 +157,7 @@ async def bound_fn(*a: Any, **kw: Any) -> Any:
try:
return await self._original.execute(*args)
finally:
self._original.fn = original_fn # type: ignore[assignment]
self._original.fn = original_fn


class StepSequence:
Expand Down
5 changes: 4 additions & 1 deletion src/tsunagi/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
from __future__ import annotations

import inspect
from typing import Any, Awaitable, Callable, get_type_hints
from typing import TYPE_CHECKING, Any, get_type_hints

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable


class Tool:
Expand Down
5 changes: 3 additions & 2 deletions src/tsunagi/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
from __future__ import annotations

import sys
from typing import Any, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from tsunagi.context import Context
if TYPE_CHECKING:
from tsunagi.context import Context


@runtime_checkable
Expand Down
Empty file added tests/__init__.py
Empty file.
11 changes: 8 additions & 3 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any

import pytest

from tsunagi import Agent, AgentEvent, ToolCall, tool
from tsunagi.agent import Message

if TYPE_CHECKING:
from tsunagi.agent import Message


class TextAdapter:
Expand Down Expand Up @@ -88,7 +91,9 @@ async def test_agent_unknown_tool() -> None:
assert result == "final"

tool_result_message = adapter.seen_messages[-1][-1]
content = tool_result_message.content[0]["content"]
assert isinstance(tool_result_message.content, list)
content_list: list[dict[str, Any]] = tool_result_message.content
content = content_list[0]["content"]
assert "Unknown tool" in content


Expand All @@ -101,7 +106,7 @@ async def test_agent_stream() -> None:
agent = Agent(adapter)
agent.register(adder)

events = [event async for event in agent.stream("calc")] # type: ignore[misc]
events = [event async for event in agent.stream("calc")]

assert events[0] == AgentEvent(
type="tool_call", tool="adder", args={"a": 1, "b": 2}
Expand Down
2 changes: 1 addition & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import pytest

from tsunagi import Pipeline
from tests.conftest import add_one, double, to_string
from tsunagi import Pipeline


@pytest.mark.asyncio
Expand Down
2 changes: 1 addition & 1 deletion tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import pytest

from tsunagi import NullTracer, Pipeline, PipelineError
from tests.conftest import add_one, always_fail, double, to_string
from tsunagi import NullTracer, Pipeline, PipelineError


class MockTracer:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import pytest

from tsunagi import NO_RETRY, RetryConfig
from tests import conftest
from tests.conftest import fail_twice_then_succeed
from tsunagi import NO_RETRY, RetryConfig


def test_no_retry_default() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import pytest

from tsunagi import StepError, StepSequence, step
from tests.conftest import add_one, always_fail, double, to_string
from tsunagi import StepError, StepSequence, step


@pytest.mark.asyncio
Expand Down