diff --git a/AGENTS.md b/AGENTS.md index 3dde760..162ccac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,10 +104,20 @@ agentkit/ - **Atomic Commits**: One logical change per commit. - **Branching Strategy**: - Feature/task branches only: `feat/`, `fix/`, `test/`, `docs/`, `chore/`. + - Branch lanes: `develop` is the integration lane, `fix/work-queue` is the starting lane for maintenance fixes, and `feat/roadmap` is the starting lane for feature work. Create a short-lived task branch from the appropriate lane and open its PR against `main`. - **Never commit directly to `main`** (except the initial warm-up scaffolding push). - **Never force-push `main`**. - All task changes must be opened as pull requests and merged via `gh pr merge`. +### Release and tag workflow +- Use Semantic Versioning. Keep `[project].version` in `pyproject.toml` aligned with the latest published release until a release PR proposes the next version. +- Before choosing a version, fetch tags and inspect the current release: `git fetch --tags`, `git tag --sort=-version:refname`, and `gh release view`. +- Bump the version and update release notes in a task branch; run the full quality gates and open a PR. Do not create a release tag before that PR is merged to `main`. +- After merge, verify `main` is current and the version is unused, then create an annotated tag on the merge commit: `git tag -a vX.Y.Z -m "AgentKit vX.Y.Z"`. Push the tag with `git push origin vX.Y.Z` and create the GitHub release with `gh release create vX.Y.Z --verify-tag --generate-notes`. +- For package artifacts, build and inspect before publishing: `python -m pip install build twine`, `python -m build`, and `python -m twine check dist/*`. Confirm the package registry name belongs to this repository and that the publishing identity is configured before any upload; never publish credentials or artifacts from an unreviewed branch. +- Registry check on 2026-11-03 found that PyPI's `agentkit` project metadata identifies a different maintainer and project. Do not upload this repository under that name unless ownership is confirmed; resolve the distribution name and update `pyproject.toml`, install instructions, and the trusted publisher configuration first. +- Never move or reuse a published tag. If a tag already exists, select the next unused version and record the correction in the release PR. + --- ## 5. Definition of Done (DoD) diff --git a/README.md b/README.md index 32c85b6..fbec279 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # AgentKit +AgentKit logo + [![CI](https://github.com/getdownandcode/AgentKit/actions/workflows/ci.yml/badge.svg)](https://github.com/getdownandcode/AgentKit/actions/workflows/ci.yml) [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) AgentKit is a Python service for running tool-using AI agents. A client submits a goal, the agent asks a language model what to do, runs any requested tools, and returns a final answer. Runs and tool steps are recorded so they can be inspected later. -The runtime is implemented in this repository rather than delegated to an agent framework. It supports Google Gemini and OpenAI; tests use a fake model client so the normal test suite does not need provider credentials. +The runtime is implemented in this repository rather than delegated to an agent framework. It supports Google Gemini and OpenAI; the default Gemini model is `gemini-3.1-flash-lite`. Tests use a fake model client so the normal test suite does not need provider credentials. ## What happens during a run @@ -128,7 +130,7 @@ The standard API registry includes these tools: - `calculator` evaluates arithmetic through a restricted AST evaluator. - `read_file` reads files under the configured sandbox directory and blocks traversal and symlink escapes. - `http_fetch` fetches HTTP(S) resources while blocking private, loopback, link-local, and metadata addresses. -- `sql_readonly` runs a single read-only query using a dedicated PostgreSQL role. It fails closed when that role is not configured. +- `sql_readonly` runs a single read-only query using the dedicated PostgreSQL role configured by `DATABASE_READONLY_URL`. It fails closed when that role is not configured. - `web_search` is included only when `SEARCH_API_KEY` is set. Tool arguments are validated against JSON Schema derived from Python function annotations. Synchronous tools run outside the event loop; tool results and errors are returned in a structured form. @@ -181,6 +183,7 @@ def add_numbers(left: int, right: int) -> int: """Add two integers.""" return left + right + schema = registry.schemas()[0] schema.name, schema.parameters ``` diff --git a/agentkit/api/ratelimit.py b/agentkit/api/ratelimit.py index 755377d..1371dde 100644 --- a/agentkit/api/ratelimit.py +++ b/agentkit/api/ratelimit.py @@ -95,8 +95,16 @@ async def check( Redis is unreachable. """ now = time.time() - effective_limit = limit or self.requests_per_window or 30 - effective_window = float(window_seconds or self.window_seconds) + effective_limit = ( + limit + if limit is not None + else self.requests_per_window + if self.requests_per_window is not None + else 30 + ) + effective_window = float( + window_seconds if window_seconds is not None else self.window_seconds + ) if redis_client is not None: redis_key = f"{self.prefix}{key}" @@ -143,8 +151,11 @@ def _check_window_in_memory(self, key: str, now: float, window: float, limit: in active_timestamps = [t for t in self._memory_store.get(key, []) if t > now - window] if len(active_timestamps) >= limit: - oldest_score = active_timestamps[0] - retry_after = max(1, math.ceil(oldest_score + window - now)) + retry_after = ( + max(1, math.ceil(active_timestamps[0] + window - now)) + if active_timestamps + else max(1, math.ceil(window)) + ) self._memory_store[key] = active_timestamps logger.warning( "In-memory rate limit exceeded for key %s. Retry after: %ds", diff --git a/agentkit/api/routes.py b/agentkit/api/routes.py index ca09063..e93a618 100644 --- a/agentkit/api/routes.py +++ b/agentkit/api/routes.py @@ -25,6 +25,7 @@ from agentkit.core.agent import Agent from agentkit.core.errors import PersistenceError, RunNotFoundError from agentkit.core.trace import StepTrace, TraceSink +from agentkit.core.types import RunStatus from agentkit.memory.base import MemoryStore from agentkit.memory.pg_store import PostgresMemoryStore from agentkit.tools.registry import ToolRegistry @@ -75,7 +76,7 @@ async def create_run( run_id=run_record.id, session_id=run_record.session_id, goal=run_record.goal, - status=run_record.status, + status=RunStatus(run_record.status), answer=run_record.final_answer, failure_reason=run_record.failure_reason, total_input_tokens=run_record.total_input_tokens, @@ -90,7 +91,7 @@ async def create_run( run_id=result.run_id, session_id=payload.session_id, goal=payload.goal, - status=result.status.value, + status=result.status, answer=result.final_answer, failure_reason=result.failure_reason, total_input_tokens=result.total_input_tokens, @@ -117,7 +118,7 @@ async def get_run_details( run_id=run_record.id, session_id=run_record.session_id, goal=run_record.goal, - status=run_record.status, + status=RunStatus(run_record.status), answer=run_record.final_answer, failure_reason=run_record.failure_reason, total_input_tokens=run_record.total_input_tokens, diff --git a/agentkit/api/schemas.py b/agentkit/api/schemas.py index 7de38f0..586d4a2 100644 --- a/agentkit/api/schemas.py +++ b/agentkit/api/schemas.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field +from agentkit.core.types import RunStatus from agentkit.db.models import SESSION_ID_MAX_LENGTH @@ -32,7 +33,7 @@ class RunResponse(BaseModel): run_id: str = Field(..., description="Unique identifier of the run.") session_id: str | None = Field(default=None, description="Associated session identifier.") goal: str = Field(..., description="Task objective submitted for this run.") - status: str = Field(..., description="Execution status of the run.") + status: RunStatus = Field(..., description="Execution status of the run.") answer: str | None = Field(default=None, description="Final answer produced by the agent.") failure_reason: str | None = Field( default=None, description="Reason for failure if status is failed or timed_out." diff --git a/agentkit/tools/executor.py b/agentkit/tools/executor.py index 7deb40e..2fa7a9a 100644 --- a/agentkit/tools/executor.py +++ b/agentkit/tools/executor.py @@ -12,6 +12,14 @@ from agentkit.tools.registry import ToolRegistry +def _truncate_output(output: str, max_chars: int) -> str: + """Keep a truncation notice inside the configured output character budget.""" + marker = f"\n[Output truncated: exceeded {max_chars} character limit]" + if max_chars <= len(marker): + return marker[:max_chars] + return output[: max_chars - len(marker)] + marker + + async def execute_tool( registry: "ToolRegistry", tool_call: ToolCall, @@ -75,10 +83,7 @@ def get_latency_ms() -> int: if raw_output.latency_ms == 0: updates["latency_ms"] = latency_ms if raw_output.ok and len(raw_output.output) > max_chars: - updates["output"] = ( - raw_output.output[:max_chars] - + f"\n[Output truncated: exceeded {max_chars} character limit]" - ) + updates["output"] = _truncate_output(raw_output.output, max_chars) updates["truncated"] = True return raw_output.model_copy(update=updates) if updates else raw_output @@ -87,10 +92,7 @@ def get_latency_ms() -> int: # Truncate if output exceeds max_chars limit if len(output_str) > max_chars: - truncated_str = ( - output_str[:max_chars] - + f"\n[Output truncated: exceeded {max_chars} character limit]" - ) + truncated_str = _truncate_output(output_str, max_chars) return ToolResult.success( truncated_str, latency_ms=latency_ms, diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c0ff7c8..20c0a6d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -4,6 +4,7 @@ This document tracks execution progress across all backlog tasks, including pull | Task ID | Milestone | Title | Branch | PR # | Commits | Status | Notes & Blockers | |---------|-----------|-------|--------|------|---------|--------|------------------| +| ROADMAP-21 | M16 | Executor output cap, rate-limit zero quota, typed run status | `fix/robustness-odds-and-ends` | [#125](https://github.com/getdownandcode/AgentKit/pull/125) | 3 | In review | Fixes three small robustness items from #113; corrects README claims caught by docs-accuracy tests; aligns package version with latest release tag `v1.0.1`; adds release/tag procedure and logo. Test-suite audit found no unnecessary tests to remove; coverage is 93.57%. Pruned 19 merged remote refs and created `develop`, `fix/work-queue`, and `feat/roadmap` lanes. | | ROADMAP-01/06/07 | M14 | Run cancellation, token accounting, optional search registration | `fix/related-run-tool-hardening` | [#122](https://github.com/getdownandcode/AgentKit/pull/122) | 5 | In review | Fixes #93, #98 and #99 together: canceled runs persist terminal state, turn usage is attributed once across tool traces, and web_search is registered only with SEARCH_API_KEY. Review-fix round: cancellation during the awaited history load (after create_run but before the guarded loop) also finalizes the run as canceled with history untouched (tests/unit/test_agent_cancellation.py, ADR-021), and the real-infra /tools test now pins SEARCH_API_KEY per case so it is deterministic with the key set or unset. Quality gates pass (ruff check/format, mypy agentkit tests, pytest 508 hermetic passed, 31 real-infra passed, pytest-cov 93.4% >= 85%). | | M0-T01 | M0 | Repository Setup & Context Scaffolding | `main` | N/A | 7 | Done | Initial warm-up repository scaffolding | | M0-T02 | M0 | Configuration Management | `feat/config-settings` | #1 | 2 | Done | Pydantic BaseSettings with env validation | diff --git a/docs/assets/agentkit-logo.png b/docs/assets/agentkit-logo.png new file mode 100644 index 0000000..d680052 Binary files /dev/null and b/docs/assets/agentkit-logo.png differ diff --git a/pyproject.toml b/pyproject.toml index 84d46e1..1be72dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentkit" -version = "0.1.0" +version = "1.0.1" description = "A lightweight, provider-agnostic ReAct agent framework in Python built from scratch" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/unit/test_api_core.py b/tests/unit/test_api_core.py index 4da45d3..ed204ec 100644 --- a/tests/unit/test_api_core.py +++ b/tests/unit/test_api_core.py @@ -3,7 +3,9 @@ from pydantic import BaseModel from agentkit.api.main import create_app +from agentkit.api.schemas import RunResponse from agentkit.core.errors import ToolNotFoundError, ToolSecurityError +from agentkit.core.types import RunStatus def test_app_creation() -> None: @@ -11,6 +13,20 @@ def test_app_creation() -> None: assert app.title == "AgentKit API" +def test_run_response_status_is_a_run_status_enum() -> None: + """The API boundary should validate statuses and publish their enum values.""" + response = RunResponse.model_validate( + { + "run_id": "run-1", + "session_id": None, + "goal": "test", + "status": "succeeded", + "created_at": "2026-01-01T00:00:00Z", + } + ) + assert response.status is RunStatus.SUCCEEDED + + def test_domain_exception_handler() -> None: app = create_app() router = APIRouter() diff --git a/tests/unit/test_api_ratelimit.py b/tests/unit/test_api_ratelimit.py index 35803f9..e496282 100644 --- a/tests/unit/test_api_ratelimit.py +++ b/tests/unit/test_api_ratelimit.py @@ -94,6 +94,18 @@ async def test_rate_limiter_in_memory_fallback() -> None: await limiter.check(key=key, redis_client=None) +@pytest.mark.asyncio +async def test_rate_limiter_preserves_zero_limit() -> None: + """An explicit zero quota denies requests instead of falling back to a default.""" + limiter = RateLimiter(requests_per_window=0, window_seconds=10) + with pytest.raises(RateLimitExceededError): + await limiter.check(key="zero_limit", redis_client=None) + with pytest.raises(RateLimitExceededError): + await RateLimiter(requests_per_window=5).check( + key="zero_override", redis_client=None, limit=0 + ) + + @pytest.mark.asyncio async def test_rate_limit_api_endpoint_429_with_headers(fake_redis: Redis[Any]) -> None: test_settings = Settings( diff --git a/tests/unit/test_docs_accuracy.py b/tests/unit/test_docs_accuracy.py index 260392e..5b9f51d 100644 --- a/tests/unit/test_docs_accuracy.py +++ b/tests/unit/test_docs_accuracy.py @@ -38,6 +38,11 @@ def test_readme_does_not_claim_ast_validation_for_sql() -> None: assert "DATABASE_READONLY_URL" in sql_line +def test_readme_shows_the_default_gemini_model() -> None: + """The quick orientation page should state which Gemini model runs by default.""" + assert "gemini-3.1-flash-lite" in _read("README.md") + + def test_adr_006_is_marked_superseded_and_corrected() -> None: """ADR-006 asserted a base64 behaviour Pydantic never had and a stale model id.""" decisions = _read("docs/DECISIONS.md") diff --git a/tests/unit/test_tool_executor.py b/tests/unit/test_tool_executor.py index 6fa9c02..87072a4 100644 --- a/tests/unit/test_tool_executor.py +++ b/tests/unit/test_tool_executor.py @@ -109,5 +109,17 @@ async def test_execute_output_truncation(test_registry: ToolRegistry) -> None: result = await execute_tool(test_registry, call, max_chars=100) assert result.ok is True assert result.truncated is True - assert len(result.output) <= 150 # 100 chars + truncation marker + assert len(result.output) <= 100 assert "[Output truncated" in result.output + + +@pytest.mark.asyncio +async def test_execute_output_truncation_handles_tiny_character_budget( + test_registry: ToolRegistry, +) -> None: + """A truncation marker must never exceed a very small output budget.""" + call = ToolCall(id="call_tiny", name="verbose_tool", arguments={"length": 20}) + result = await execute_tool(test_registry, call, max_chars=4) + assert result.ok is True + assert result.truncated is True + assert len(result.output) <= 4 diff --git a/tests/unit/test_tools_resources_config.py b/tests/unit/test_tools_resources_config.py index aa722e5..e55154c 100644 --- a/tests/unit/test_tools_resources_config.py +++ b/tests/unit/test_tools_resources_config.py @@ -216,7 +216,7 @@ def spew() -> str: tool_messages = [m for m in llm.history[-1] if m.role.value == "tool"] assert tool_messages, "the tool result never reached the model" - assert tool_messages[0].content.startswith("x" * 50) + assert len(tool_messages[0].content) <= 50 assert "Output truncated" in tool_messages[0].content