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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,20 @@ agentkit/
- **Atomic Commits**: One logical change per commit.
- **Branching Strategy**:
- Feature/task branches only: `feat/<slug>`, `fix/<slug>`, `test/<slug>`, `docs/<slug>`, `chore/<slug>`.
- 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)
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# AgentKit

<img src="docs/assets/agentkit-logo.png" alt="AgentKit logo" width="96">

[![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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
```
Expand Down
19 changes: 15 additions & 4 deletions agentkit/api/ratelimit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions agentkit/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion agentkit/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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."
Expand Down
18 changes: 10 additions & 8 deletions agentkit/tools/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Binary file added docs/assets/agentkit-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/test_api_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,30 @@
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:
app = create_app()
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()
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/test_api_ratelimit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/test_docs_accuracy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 13 additions & 1 deletion tests/unit/test_tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion tests/unit/test_tools_resources_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading