diff --git a/agentkit/api/deps.py b/agentkit/api/deps.py index 121b2ee..68daba0 100644 --- a/agentkit/api/deps.py +++ b/agentkit/api/deps.py @@ -182,6 +182,7 @@ def get_agent( max_steps=settings.MAX_STEPS, run_timeout_s=settings.RUN_TIMEOUT_S, tool_timeout_s=settings.TOOL_TIMEOUT_S, + tool_output_max_chars=settings.TOOL_OUTPUT_MAX_CHARS, ) return Agent( llm=llm, diff --git a/docker-compose.yml b/docker-compose.yml index c880a60..9d1eef8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,15 @@ services: - LLM_MODEL=${LLM_MODEL:-gemini-3.1-flash-lite} - GEMINI_API_KEY=${GEMINI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - SEARCH_API_KEY=${SEARCH_API_KEY:-} + - RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-30} + - MAX_STEPS=${MAX_STEPS:-10} + - TOOL_TIMEOUT_S=${TOOL_TIMEOUT_S:-15} + - SESSION_TTL_S=${SESSION_TTL_S:-3600} + - TOOL_OUTPUT_MAX_CHARS=${TOOL_OUTPUT_MAX_CHARS:-2000} + - LLM_MAX_RETRIES=${LLM_MAX_RETRIES:-3} + - LLM_RETRY_BASE_DELAY_S=${LLM_RETRY_BASE_DELAY_S:-0.5} + - LLM_RETRY_MAX_DELAY_S=${LLM_RETRY_MAX_DELAY_S:-10} volumes: - sandbox_data:/tmp/agentkit_sandbox healthcheck: diff --git a/docs/AGENTKIT_SPEC.md b/docs/AGENTKIT_SPEC.md index 636e16f..a68b573 100644 --- a/docs/AGENTKIT_SPEC.md +++ b/docs/AGENTKIT_SPEC.md @@ -222,7 +222,7 @@ Add an index on `steps(run_id, step_no)` and `runs(session_id)`. Manage schema c All configuration comes from environment variables, loaded through a Pydantic `BaseSettings` class. Provide a `.env.example` file. Never commit secrets. -Required or supported variables include: `DATABASE_URL`, `REDIS_URL`, `LLM_PROVIDER`, `LLM_MODEL`, `GEMINI_API_KEY`, `OPENAI_API_KEY`, `SEARCH_API_KEY`, `API_KEYS` (comma-separated), `MAX_STEPS`, `RUN_TIMEOUT_S`, `TOOL_TIMEOUT_S`, `RATE_LIMIT_PER_MIN`, `FILE_TOOL_BASE_DIR`, `SESSION_TTL_S`, `TOOL_OUTPUT_MAX_CHARS`. +Required or supported variables include: `DATABASE_URL`, `REDIS_URL`, `LLM_PROVIDER`, `LLM_MODEL`, `GEMINI_API_KEY`, `OPENAI_API_KEY`, `SEARCH_API_KEY`, `LLM_MAX_RETRIES`, `LLM_RETRY_BASE_DELAY_S`, `LLM_RETRY_MAX_DELAY_S`, `FILE_TOOL_BASE_DIR`, `API_KEYS` (comma-separated), `RATE_LIMIT_PER_MIN`, `MAX_STEPS`, `RUN_TIMEOUT_S`, `TOOL_TIMEOUT_S`, `SESSION_TTL_S`, `TOOL_OUTPUT_MAX_CHARS`. ## 9. Repository layout diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2c7b115..4f289db 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -61,6 +61,7 @@ This document tracks execution progress across all backlog tasks, including pull | AUD-09 | AUD | Live End-to-End Infrastructure Verification & Documentation Proof | `docs/e2e-verification-report` | #53 | 3 | Done | Standardized model, verified live stack, and documented in E2E_REPORT.md | | AUD-10 | AUD | Rate Limiter Outage Fallback & Provider-Valid Session History | `fix/ratelimit-and-session-repair` | #56 | 3 | Done | In-memory fallback on Redis connection failures; synthetic tool results repair aborted sessions | | M13-T06 | M13 | Whole-Project Audit Remediation | `fix/audit-hardening` | #47 | 3 | Done | Fail-closed config and LLM resolution, SSRF post-connect check, atomic fallback | +| AUDIT-12 | AUD | Wire TOOL_OUTPUT_MAX_CHARS and Documented Compose Settings | `fix/wire-documented-settings` | #82 | 1 | Done | Wire tool_output_max_chars in get_agent, expose missing settings in docker-compose.yml | --- @@ -407,3 +408,10 @@ This document tracks execution progress across all backlog tasks, including pull - **PR**: #47 - **Commits**: 3 - **Notes**: Remediated 17 findings from a full-codebase audit, rebased onto the AUD-01/AUD-04/AUD-05 follow-ups. Critical: removed the `ak_test_key_12345` default from `Settings.API_KEYS` and made the field required; replaced the `FakeLLMClient` fallback in `get_llm_client` with a `ServiceUnavailableError` (503) boundary around provider construction; kept the AUD-04 factory-level `RetryingLLMClient` wrapping and made it configurable through new `LLM_MAX_RETRIES`/`LLM_RETRY_BASE_DELAY_S`/`LLM_RETRY_MAX_DELAY_S` settings; made the per-request `Agent` assembly's override explicit as `app.state.agent_override`; forwarded `get_run_trace`/`list_runs` through `TieredMemoryStore`; moved `http_fetch` DNS off the event loop with `asyncio.to_thread`, added a post-connect `server_addr` check against the validated addresses to close the DNS-rebinding TOCTOU window, and replaced full-body buffering with a byte-budgeted stream; replaced the rate limiter's non-scripting fallback with `WATCH`/`MULTI`/`EXEC`, leaving the AUD-10 in-memory fallback for Redis outages intact. Moderate: corrected the `RedisMemoryStore.truncate_messages` off-by-one that returned `max_messages + 1` entries when a system prompt was present; switched generated `tool_call_id`s to full UUIDs; wrapped response parsing in both LLM adapters inside the error boundary so malformed responses and malformed tool-argument JSON raise `LLMProviderError`; guarded `RetryingLLMClient.__getattr__` against recursion on a partially constructed instance; sanitized `/health` details so the unauthenticated endpoint no longer echoes driver error strings; made `sql_readonly` mask string literals and quoted identifiers before its keyword scan, allow `#>`/`#>>`/`#-`, apply the row limit in SQL and push `statement_timeout` to PostgreSQL. Minor: stopped logging raw run goals at INFO (length only, content at DEBUG); replaced `split(";")` in `scripts/seed.py` with a statement-aware splitter handling literals, comments and dollar-quotes; bounded calculator exponentiation by result bit length. Added `tests/unit/test_audit_regressions.py` and a real-Redis concurrency regression test pinning the fallback fix. Recorded in ADR-009, ADR-010, ADR-011. Suite: 298 passed, 25 skipped; coverage 92.86% on core/tools/llm; `ruff check`, `ruff format --check` and `mypy agentkit tests` all clean. + + +### AUDIT-12: Wire TOOL_OUTPUT_MAX_CHARS and Documented Compose Settings +- **Status**: Done +- **PR**: #82 +- **Commits**: 1 +- **Notes**: In `agentkit/api/deps.py`, wired `settings.TOOL_OUTPUT_MAX_CHARS` into `AgentConfig(tool_output_max_chars=...)` within `get_agent()`. In `docker-compose.yml`, exposed all documented settings (`SEARCH_API_KEY`, `RATE_LIMIT_PER_MIN`, `MAX_STEPS`, `TOOL_TIMEOUT_S`, `SESSION_TTL_S`, `TOOL_OUTPUT_MAX_CHARS`, `LLM_MAX_RETRIES`, `LLM_RETRY_BASE_DELAY_S`, `LLM_RETRY_MAX_DELAY_S`) under `services.api.environment`. Aligned `docs/SPEC.md` and `docs/AGENTKIT_SPEC.md` with the full configuration set. Added unit tests in `tests/unit/test_config.py` verifying dependency wiring, tool truncation behavior, and full compose environment parity. diff --git a/docs/SPEC.md b/docs/SPEC.md index 636e16f..a68b573 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -222,7 +222,7 @@ Add an index on `steps(run_id, step_no)` and `runs(session_id)`. Manage schema c All configuration comes from environment variables, loaded through a Pydantic `BaseSettings` class. Provide a `.env.example` file. Never commit secrets. -Required or supported variables include: `DATABASE_URL`, `REDIS_URL`, `LLM_PROVIDER`, `LLM_MODEL`, `GEMINI_API_KEY`, `OPENAI_API_KEY`, `SEARCH_API_KEY`, `API_KEYS` (comma-separated), `MAX_STEPS`, `RUN_TIMEOUT_S`, `TOOL_TIMEOUT_S`, `RATE_LIMIT_PER_MIN`, `FILE_TOOL_BASE_DIR`, `SESSION_TTL_S`, `TOOL_OUTPUT_MAX_CHARS`. +Required or supported variables include: `DATABASE_URL`, `REDIS_URL`, `LLM_PROVIDER`, `LLM_MODEL`, `GEMINI_API_KEY`, `OPENAI_API_KEY`, `SEARCH_API_KEY`, `LLM_MAX_RETRIES`, `LLM_RETRY_BASE_DELAY_S`, `LLM_RETRY_MAX_DELAY_S`, `FILE_TOOL_BASE_DIR`, `API_KEYS` (comma-separated), `RATE_LIMIT_PER_MIN`, `MAX_STEPS`, `RUN_TIMEOUT_S`, `TOOL_TIMEOUT_S`, `SESSION_TTL_S`, `TOOL_OUTPUT_MAX_CHARS`. ## 9. Repository layout diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 3309f91..2e4ae55 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,9 +1,20 @@ """Unit tests for configuration management via Pydantic BaseSettings.""" +from pathlib import Path +from types import SimpleNamespace + import pytest +import yaml from pydantic import ValidationError +from agentkit.api.deps import get_agent from agentkit.config import Settings +from agentkit.core.trace import InMemoryTraceSink +from agentkit.llm.base import LLMResponse +from agentkit.llm.fake import FakeLLMClient +from agentkit.memory.base import InMemoryMemoryStore +from agentkit.tools.models import ToolCall +from agentkit.tools.registry import ToolRegistry def test_default_settings() -> None: @@ -60,3 +71,104 @@ def test_non_positive_steps_raises_validation_error() -> None: API_KEYS="key", MAX_STEPS=0, ) + + +def test_get_agent_wires_tool_output_max_chars() -> None: + """AUDIT-12: get_agent wires Settings.TOOL_OUTPUT_MAX_CHARS to AgentConfig.""" + settings = Settings( + DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/agentkit", + REDIS_URL="redis://localhost:6379/0", + API_KEYS="secret_key", + TOOL_OUTPUT_MAX_CHARS=50, + ) + + class _DummyApp: + state = SimpleNamespace(agent_override=None) + + request = SimpleNamespace(app=_DummyApp()) + + agent = get_agent( + request=request, # type: ignore[arg-type] + settings=settings, + llm=FakeLLMClient(), + registry=ToolRegistry(), + memory=InMemoryMemoryStore(), + trace=InMemoryTraceSink(), + ) + assert agent.config.tool_output_max_chars == 50 + + +@pytest.mark.asyncio +async def test_tool_output_truncation_with_configured_max_chars() -> None: + """AUDIT-12: Tool outputs exceeding configured max_chars are truncated.""" + settings = Settings( + DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/agentkit", + REDIS_URL="redis://localhost:6379/0", + API_KEYS="secret_key", + TOOL_OUTPUT_MAX_CHARS=40, + ) + + registry = ToolRegistry() + + def long_output_tool() -> str: + return "A" * 120 + + registry.register(long_output_tool, name="long_tool", description="Returns long string.") + + fake_llm = FakeLLMClient( + responses=[ + LLMResponse( + tool_calls=[ToolCall(id="call_long", name="long_tool", arguments={})], + ), + LLMResponse(text="Done"), + ] + ) + + class _DummyApp: + state = SimpleNamespace(agent_override=None) + + request = SimpleNamespace(app=_DummyApp()) + + agent = get_agent( + request=request, # type: ignore[arg-type] + settings=settings, + llm=fake_llm, + registry=registry, + memory=InMemoryMemoryStore(), + trace=InMemoryTraceSink(), + ) + + await agent.run("Run long tool") + + # fake_llm.history records list of messages passed on each turn + all_messages = [msg for turn in fake_llm.history for msg in turn] + tool_msg = next(m for m in all_messages if m.role.value == "tool") + assert tool_msg.content.startswith("A" * 40) + assert "[Output truncated: exceeded 40 character limit]" in tool_msg.content + + +def test_docker_compose_environment_covers_all_settings() -> None: + """AUDIT-12: docker-compose api environment declares all settings in Settings.""" + compose_path = Path(__file__).parents[2] / "docker-compose.yml" + with open(compose_path, encoding="utf-8") as f: + compose_data = yaml.safe_load(f) + + api_env = compose_data["services"]["api"]["environment"] + env_keys: set[str] = set() + for item in api_env: + key = item.split("=")[0].strip() + env_keys.add(key) + + settings_fields = set(Settings.model_fields.keys()) + missing = settings_fields - env_keys + assert not missing, f"Missing settings in docker-compose.yml: {missing}" + + +def test_spec_documents_all_settings() -> None: + """AUDIT-12: docs/SPEC.md and docs/AGENTKIT_SPEC.md document all settings.""" + spec_dir = Path(__file__).parents[2] / "docs" + for filename in ["SPEC.md", "AGENTKIT_SPEC.md"]: + path = spec_dir / filename + content = path.read_text(encoding="utf-8") + for field in Settings.model_fields: + assert f"`{field}`" in content, f"Setting {field} missing from {filename}"