Skip to content

feat: agentic-core public API — composable step functions per ADR-03 #42

Description

@ashwing

Proposal: agentic-core public API — composable step functions per ADR-03

Context

ADR-03 defines the core crate's public API as a set of composable async functions that implement the agentic loop. Each function is a standalone step that can be:

  • Called directly in standalone mode (execute() composes them)
  • Wrapped in a Praxis HttpFilter for gateway mode
  • Tested in isolation without HTTP infrastructure

The ADR intentionally left the function parameters as (...) placeholders. This issue proposes concrete type signatures, informed by:

Design Goals

  1. Each step is independently testable — no hidden coupling between steps
  2. serde_json::Value at boundaries, typed internally — the gateway is model-agnostic; we don't enforce a fixed item schema at the API level
  3. Trait-based backends — store, inference client, and tool dispatch are swappable
  4. Streaming-first — inference returns a stream, not a collected response
  5. Tool loop is explicit — the caller decides iteration policy, not the library
  6. Future-proof for RFC Epic: Wasm Runtime praxis-proxy/praxis#18ExecutionContext carries optional cache hints without breaking the API

Proposed Types

ExecutionContext — shared state across steps

pub struct ExecutionContext {
    /// Pluggable response/conversation store (ADR-02).
    pub store: Arc<dyn ResponseStore>,

    /// Inference backend caller.
    pub inference: Arc<dyn InferenceClient>,

    /// Tool registry for dispatch.
    pub tools: Arc<dyn ToolRegistry>,

    /// Gateway configuration (LLM base URL, timeouts, etc.)
    pub config: CoreConfig,
}

Store trait (ADR-02 alignment)

#[async_trait]
pub trait ResponseStore: Send + Sync {
    /// Load a response by ID, returning its metadata and history_item_ids.
    async fn get_response(&self, response_id: &str) -> Result<StoredResponse, Error>;

    /// Bulk-fetch items by IDs. Caller is responsible for re-ordering.
    async fn get_items(&self, item_ids: &[String]) -> Result<Vec<StoredItem>, Error>;

    /// Persist a completed response with its items.
    async fn persist(
        &self,
        response_id: &str,
        conversation_id: Option<&str>,
        previous_response_id: Option<&str>,
        items: &[Item],
        metadata: &ResponseMetadata,
    ) -> Result<(), Error>;
}

Inference trait

#[async_trait]
pub trait InferenceClient: Send + Sync {
    /// Send a request to the LLM and return a stream of output chunks.
    /// The stream yields typed events (delta text, tool calls, done).
    async fn call(
        &self,
        request: &InferenceRequest,
    ) -> Result<Pin<Box<dyn Stream<Item = Result<InferenceEvent, Error>> + Send>>, Error>;
}

Tool dispatch trait

#[async_trait]
pub trait ToolRegistry: Send + Sync {
    /// Execute a tool call, returning the result.
    async fn dispatch(&self, call: &ToolCall) -> Result<ToolResult, Error>;

    /// Check if a tool is registered and available.
    fn has_tool(&self, name: &str) -> bool;
}

Proposed Step Functions

1. rehydrate_conversation

/// Reconstruct conversation history from a previous_response_id.
///
/// Loads the stored response, fetches referenced items, restores ordering,
/// and returns the full conversation ready for the next inference call.
pub async fn rehydrate_conversation(
    previous_response_id: &str,
    ctx: &ExecutionContext,
) -> Result<Conversation, Error>

Returns: Conversation containing ordered history items + metadata from the prior turn.

2. call_inference

/// Send the conversation to the LLM backend and collect the response.
///
/// For non-streaming consumers, collects the full response.
/// For streaming, returns a stream handle.
pub async fn call_inference(
    conversation: &Conversation,
    request: &InferenceRequest,
    ctx: &ExecutionContext,
) -> Result<InferenceResult, Error>

Returns: InferenceResult containing output items + any tool calls detected.

3. dispatch_tools

/// Execute all tool calls from an inference result.
///
/// Dispatches each call through the ToolRegistry, collecting results.
/// Returns the tool outputs ready to be appended to the conversation.
pub async fn dispatch_tools(
    tool_calls: &[ToolCall],
    ctx: &ExecutionContext,
) -> Result<Vec<ToolResult>, Error>

4. assemble_response

/// Build the final API response from inference output and tool results.
///
/// Produces the ResponsesResponse object with stable item IDs,
/// proper lifecycle events, and correct output typing.
pub async fn assemble_response(
    request_id: &str,
    output: &InferenceResult,
    model: &str,
    metadata: &ResponseMetadata,
) -> Result<ResponsesResponse, Error>

5. persist_response

/// Save the completed response and its items to the store.
///
/// Only persists when store is enabled and the request requires it.
pub async fn persist_response(
    response: &ResponsesResponse,
    conversation_id: Option<&str>,
    previous_response_id: Option<&str>,
    ctx: &ExecutionContext,
) -> Result<(), Error>

6. execute — the convenience orchestrator

/// Run the full agentic loop: rehydrate → infer → (tool loop) → assemble → persist.
///
/// This is the default composition of the step functions with standard
/// iteration policy (max_iterations capped, tool results fed back).
pub async fn execute(
    request: ResponsesRequest,
    ctx: &ExecutionContext,
) -> Result<ResponsesResponse, Error>

Key Domain Types

pub struct Conversation {
    pub id: Option<String>,
    pub history: Vec<Item>,
    pub metadata: ConversationMetadata,
}

pub struct Item {
    pub id: String,
    pub data: serde_json::Value,  // model-agnostic
}

pub struct InferenceRequest {
    pub model: String,
    pub input: Vec<serde_json::Value>,  // history + new input
    pub tools: Vec<ToolConfig>,
    pub stream: bool,
    pub params: serde_json::Map<String, serde_json::Value>,  // passthrough
}

pub struct InferenceResult {
    pub output: Vec<OutputItem>,
    pub tool_calls: Vec<ToolCall>,
    pub usage: Option<Usage>,
}

pub struct ToolCall {
    pub id: String,
    pub call_id: String,
    pub name: String,
    pub arguments: String,
}

pub struct ToolResult {
    pub call_id: String,
    pub output: String,
}

Composability Example

Standalone mode (default execute())

let response = agentic_core::execute(request, &ctx).await?;

Custom loop with guardrails

let conversation = rehydrate_conversation(&prev_id, &ctx).await?;
let mut inference_req = build_request(&conversation, &request);

for _ in 0..max_iterations {
    let result = call_inference(&conversation, &inference_req, &ctx).await?;
    
    if result.tool_calls.is_empty() {
        return assemble_response(&req_id, &result, &model, &meta).await;
    }
    
    // Custom guardrail: validate tool calls before dispatch
    validate_tool_calls(&result.tool_calls)?;
    
    let tool_results = dispatch_tools(&result.tool_calls, &ctx).await?;
    append_tool_results(&mut inference_req, &result, &tool_results);
}

Praxis filter chain

filter_chains:
  - name: agentic-loop
    filters:
      - filter: rehydrate
      - filter: inference
      - filter: tool_dispatch
        branch_chains:
          - name: tool-loop
            on_result: { filter: tool_dispatch, key: action, result: loop }
            rejoin: inference
            max_iterations: 10
      - filter: assemble
      - filter: persist

Implementation Plan

Phase 1: Type definitions + traits (this issue)

Phase 2: rehydrate_conversation + persist_response

Phase 3: call_inference

  • Implement against InferenceClient trait
  • Default implementation wraps the existing proxy logic (reqwest to vLLM)
  • Handle streaming vs non-streaming

Phase 4: dispatch_tools + execute

Phase 5: Praxis filter wrappers

  • Thin HttpFilter impls in agentic-praxis that delegate to step functions

Relationship to Existing PRs

PR Relationship
praxis-proxy/praxis#33 (store CRUD) Becomes a ResponseStore trait implementation. Types align with StoredResponse/StoredItem.
praxis-proxy/praxis#34 (OGX loop) OgxStore becomes a ResponseStore impl. The agentic loop in agentic.rs becomes the reference for execute().
praxis-proxy/praxis#35 (module reloc) Reverted. Proxy stays in core for now; step functions live alongside it.
RFC praxis-proxy/praxis#18 (KV cache) ExecutionContext is extensible — a session_cache: Option<Arc<dyn SessionCache>> field can be added without breaking the API.

Open Questions

  1. Streaming + tool loop tension: SSE streaming to clients requires forwarding chunks in real-time. But the tool loop needs to collect the full output to detect tool calls before iterating. vLLM solves this with a ConversationContext that accumulates output while yielding each chunk to the SSE stream — effectively "tee-ing" the stream. Should call_inference return a stream that the caller both forwards and accumulates, or should we have separate streaming/non-streaming paths? Proposal: call_inference returns a Stream<InferenceEvent>. For the tool-loop path, execute() collects internally. For direct streaming, the server layer can forward events as they arrive and buffer for tool detection simultaneously.

  2. serde_json::Value vs typed items: PR Cache upstream TLS certificates at config time praxis-proxy/praxis#34 uses Vec<serde_json::Value> for input items (maximum flexibility, no schema enforcement). PR Epic: WebSocket and HTTP Upgrade support praxis-proxy/praxis#33 defines typed InOutItem enum. Proposal: core API uses serde_json::Value at boundaries; typed wrappers are optional convenience in a types module.

  3. Error granularity: Should each step have its own error type, or share a unified Error enum? Proposal: single Error enum with variants per failure domain (Store, Inference, ToolDispatch, Serialization).

  4. Where does SSE assembly live? assemble_response produces the final API object, but SSE event framing (data: ...\n\n) is HTTP-level. Proposal: assembly produces the typed response; SSE framing lives in agentic-server.


cc @leseb — as discussed, happy to drive the implementation. Would appreciate feedback on the trait boundaries and type choices before I start coding.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions