adachsoft/ai-agent

Stateless AI Agent Orchestrator Library for tool-calling chats.

Maintainers

Package info

gitlab.com/a.adach/AiAgent

Homepage

Issues

pkg:composer/adachsoft/ai-agent

Statistics

Installs: 41

Dependents: 3

Suggesters: 0

Stars: 0

v0.9.1 2026-04-21 10:52 UTC

README

Stateful AI Agent Orchestrator Library for tool-calling chats.

  • PHP: >= 8.3
  • Namespace: AdachSoft\\AiAgent\\

Overview

This library orchestrates tool-calling chats in stateful mode. The client identifies a conversation by conversationId, the library owns and persists full conversation history behind a ConversationRepositoryInterface, and each ask() call executes a single agent turn over the stored history.

High-level responsibilities:

  • maintain conversation history and correlate messages with tool calls,
  • control the tool-calling loop under retry and budget policies,
  • integrate exclusively with external PublicApi facades for chat and tools (no HTTP transport in this library),
  • expose a small, stable PublicApi facade for starting conversations and executing turns.

See detailed docs:

  • docs/PROJECT_OVERVIEW_AI_AGENT.md (EN)
  • docs/PRZEGLAD_PROJEKTU_AI_AGENT.md (PL)

Installation

Install via Composer:

composer require adachsoft/ai-agent

This library relies on external PublicApi facades for chat and tools (installed by you):

  • adachsoft/ai-integration (ToolCallingChatFacade)
  • adachsoft/ai-tool-call (AiToolCallFacade)

Quick Start (Stateful)

  1. Create ports (PublicApi facades from external libs):
    • ToolCallingChatFacade (e.g. Deepseek/OpenAI provider via their builders),
    • AiToolCallFacade (provides tool catalog and execution).
  2. Build the AiAgent facade via AiAgentBuilder with config and policies.
  3. Call startConversation($conversationId) once to create an empty conversation in the configured repository.
  4. For each user turn call ask($conversationId, AskRequestDto) and persist nothing yourself – the library appends new messages internally.

Minimal Example (stateful)

use AdachSoft\AiAgent\PublicApi\Builder\AiAgentBuilder;
use AdachSoft\AiAgent\PublicApi\Dto\AgentConfigDto;
use AdachSoft\AiAgent\PublicApi\Dto\AskRequestDto;
use AdachSoft\AiAgent\PublicApi\Dto\Collection\ToolIdCollection;
use AdachSoft\AiAgent\PublicApi\Dto\PoliciesConfigDto;
use AdachSoft\AiAgent\PublicApi\Dto\PortsConfigDto;
use AdachSoft\AiAgent\PublicApi\Vo\ToolId;
use AdachSoft\AiAgent\Spi\Conversation\ConversationRepositoryInterface as SpiConversationRepositoryInterface;

// 1) Create external facades (using your provider credentials)
$toolCalling = /* ToolCallingChatFacadeInterface from adachsoft/ai-integration */;
$aiTools = /* AiToolCallFacadeInterface from adachsoft/ai-tool-call */;

$ports = new PortsConfigDto(
    toolCallingChatFacade: $toolCalling,
    aiToolCallFacade: $aiTools,
);

// 2) Build AiAgent facade in stateful mode
$facade = (new AiAgentBuilder())
    ->withPorts($ports)
    ->withAgentConfig(new AgentConfigDto(
        name: 'MyAgent',
        description: 'Demo stateful agent',
        providerId: 'deepseek',
        modelId: 'deepseek-chat',
        systemPrompt: 'You are a helpful assistant.',
        parameters: [
            'temperature' => 0.2,
            'max_tokens' => 1024,
        ],
        timeoutSeconds: 60,
        tools: new ToolIdCollection([new ToolId('current_datetime')]),
    ))
    ->withPolicies(new PoliciesConfigDto(
        maxSteps: 6,
        maxToolCallsPerTurn: 2,
        maxDurationSeconds: 60,
    ))
    // Optional: provide your own SPI-backed repository implementation
    // ->withSpiConversationRepository($mySpiRepository)
    ->build();

$conversationId = 'example-conversation-1';

// 3) Start a new empty conversation once
try {
    $facade->startConversation($conversationId);
} catch (\AdachSoft\AiAgent\PublicApi\Exception\ConversationAlreadyExistsPublicApiException $e) {
    // conversation already exists - reuse it
}

// 4) Ask a question (one stateful turn)
$request = new AskRequestDto(prompt: 'What time is it in Warsaw? Reply as HH:MM.');
$response = $facade->ask($conversationId, $request);

$finalText = $response->finalText;       // assistant final message
$history = $response->fullConversation;  // full conversation as seen by the agent

Conversation Repository Configuration

By default the builder uses an in-memory repository (InMemoryConversationRepository). To provide a persistent implementation:

  • Implement AdachSoft\AiAgent\Spi\Conversation\ConversationRepositoryInterface
  • Register it via AiAgentBuilder::withSpiConversationRepository(SpiConversationRepositoryInterface $repository)

The builder automatically wraps your SPI implementation with SpiBackedConversationRepository (which implements the internal Domain\Conversation\ConversationRepositoryInterface). This prevents domain leakage into the PublicApi layer.

The facade methods (startConversation, ask, getRawConversation, listConversationIds, deleteConversation) work with any repository backend.

Conversation Management

The AiAgentFacadeInterface now provides dedicated methods for managing conversations:

  • getRawConversation(string $conversationId): ChatMessageDtoCollection – returns the complete raw history without applying any context trimming strategy (useful for auditing, export, debugging or admin panels).
  • listConversationIds(): ConversationIdDtoCollection – returns all known conversation identifiers (empty collection when none exist).
  • deleteConversation(string $conversationId): void – permanently deletes a conversation and its entire history. Throws ConversationNotFoundPublicApiException when the conversation does not exist.

These methods work with any implementation of ConversationRepositoryInterface (in-memory or SPI-backed).

Example:

$rawHistory = $facade->getRawConversation($conversationId);
$allIds = $facade->listConversationIds();

foreach ($allIds as $id) {
    echo "Conversation: $id\n";
}

// Delete when no longer needed
$facade->deleteConversation('old-conversation-123');

Configuring system prompt and generation parameters

Agent behaviour is configured via two fields on AgentConfigDto:

  • systemPrompt – high level, stable instruction injected as the first system role message in every request to adachsoft/ai-integration.
  • parameters – free-form array of provider-specific generation knobs forwarded 1:1 to ToolCallingChatRequestDto::$parameters (no renaming or filtering).

Examples of parameters values:

  • OpenAI-style: ['temperature' => 0.7, 'max_tokens' => 200, 'top_p' => 0.9, 'presence_penalty' => 0.6, 'frequency_penalty' => 0.3]
  • Gemini-style: ['temperature' => 0.5, 'thinkingConfig' => ['includeThoughts' => false], 'maxOutputTokens' => 8192]

You are responsible for choosing parameter names that match the selected provider (providerId, modelId). The agent library treats this array as opaque data and passes it straight to adachsoft/ai-integration.

Examples

Runnable examples are provided in the examples/ directory. Each example has its own folder with run.php and README.md.

  • Quickstart (single stateful turn): php examples/agent-publicapi-quickstart/run.php "Your prompt"
  • Interactive chat (stateful over a single conversationId): php examples/agent-publicapi-chat/run.php

Note: bin/ scripts are thin wrappers that redirect to example entrypoints.

Environment variables:

  • DEEPSEEK_API_KEY

API Highlights

  • AiAgentFacadeInterface::startConversation(string $conversationId): void
  • AiAgentFacadeInterface::ask(string $conversationId, AskRequestDto $request): AskResponseDto
  • AiAgentFacadeInterface::getRawConversation(string $conversationId): ChatMessageDtoCollection
  • AiAgentFacadeInterface::listConversationIds(): ConversationIdDtoCollection
  • AiAgentFacadeInterface::deleteConversation(string $conversationId): void
  • AiAgentFacadeInterface::getConversationContext(string $conversationId): ChatMessageDtoCollection (applies configured trimming strategy)
  • AiAgentBuilder::withSpiConversationRepository(SpiConversationRepositoryInterface $repository): self (new – replaces previous domain-leaking method)

AskResponseDto contains:

  • fullConversation (ChatMessageDtoCollection) – full conversation as seen by the agent after the turn,
  • tokensUsed (TokensUsageDto: promptTokens, completionTokens, totalTokens),
  • status (enum value describing orchestration outcome),
  • finalText (string|null) – final assistant text for this turn.

SPI: Chat turn resolution

When the underlying provider returns an ambiguous chat turn (both finalText and at least one toolCall), the library uses a strategy to decide how to interpret the result.

Built-in strategies

You can select one of the built-in domain strategies via AiAgentBuilder::withChatTurnResolutionMode(string $mode):

  • prefer_final_text – use finalText, ignore toolCalls.
  • prefer_tool_calls – ignore finalText, continue with tool execution.
  • error_on_conflict – treat the situation as a domain error.

Custom SPI strategy

For full control you can implement the SPI:

  • Interface: AdachSoft\AiAgent\Spi\Conversation\ChatTurnResolutionStrategyInterface
  • Method: public function decide(ChatTurnResult $turn): string
  • Expected return values: 'use_final_text', 'use_tool_calls', 'error'

Register your SPI strategy via the builder (example omitted for brevity – see full docs).

Internally the library adapts the SPI decision to a domain enum and uses it inside the orchestrator; the public API of AiAgentFacadeInterface remains unchanged.

Design Notes

  • Stateful: the library owns conversation history keyed by conversationId and appends only new messages via ConversationRepositoryInterface.
  • Ports only: integrates via PublicApi facades (no HTTP details in this lib).
  • Collections: uses adachsoft/collection; no public arrays in DTOs. New collections (ConversationIdCollection, ConversationIdDtoCollection) follow the modern isValidItem() pattern.
  • No domain leakage: PublicApi builder now accepts only SPI interfaces for repositories (internal adapter is created automatically).

Changelog

See CHANGELOG.md. Source of truth is changelog.json (generated via adachsoft/changelog-linter).

License

MIT (see composer.json).