Skip to content

Focus — Structured Single-Purpose LLM Calls

Focus is a lightweight primitive for making standalone, single-purpose LLM calls outside the main agent conversation loop. It's designed for classification, judgment, and structured extraction — anytime you need the LLM to make one focused decision and return a typed result.

Why Focus?

In a normal agent conversation, the LLM manages tools, context, and multi-turn reasoning all at once. But sometimes you just need an answer to one specific question:

  • "Is this terminal output normal, or does it indicate an error?"
  • "Classify this user request as: question, command, or chitchat."
  • "Extract the key entities from this text as structured JSON."

Throwing these judgments into the main agent loop adds noise. Focus decomposes them into isolated calls — one system prompt, one input, one typed output. If the decomposition is good, even a weak model can do one thing well.

Quick Example

use std::sync::Arc;
use std::time::Duration;
use phi_agent::{Focus, OpenAiClient};
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Sentiment {
    sentiment: String,  // "positive", "negative", or "neutral"
    confidence: f64,    // 0.0 to 1.0
}

async fn classify_sentiment(client: Arc<OpenAiClient>, text: String) -> Result<Sentiment, FocusError> {
    let focus = Focus::new(
        client,
        "You are a sentiment classifier. Analyze the text and return JSON: \
         {\"sentiment\": \"positive|negative|neutral\", \"confidence\": 0.0-1.0}",
    );

    let output = focus.ask::<Sentiment>(&text, Duration::from_secs(10)).await?;
    Ok(output.result)
}

That's it. No agent loop, no tool registration — just a focused call with a typed return value.

Core Concepts

1. Focus

Focus binds an LLM client to a system prompt at creation time. The system prompt describes the role and expected output format. Once created, the system prompt never changes — a Focus instance does one job.

pub struct Focus {
    // Holds Arc<dyn LlmClient> + system_prompt (private)
}

impl Focus {
    pub fn new(client: Arc<dyn LlmClient>, system_prompt: impl Into<String>) -> Self;
    pub async fn ask<T: DeserializeOwned>(&self, input: &impl FocusInput, timeout: Duration)
        -> Result<FocusOutput<T>, FocusError>;
}
  • new() is cheap — multiple Focus instances can share the same LLM client.
  • ask::<T>() sends system prompt + user input, forces JSON output mode, and deserializes into your type T.
  • Timeout is explicit — you control how long to wait.

2. FocusInput

Anything that can be formatted into the user prompt:

Input When to Use
&str / String Single piece of text to classify or judge
FocusContext Multiple related fields (e.g., terminal output + elapsed time + command)

3. FocusContext (structured input)

When you need to send multiple labeled fields:

use phi_agent::FocusContext;

let ctx = FocusContext::new()
    .add("command", "apt install nginx")
    .add("elapsed", "30s")
    .add("screen", "Reading package lists...\nBuilding dependency tree...");

let output = focus.ask::<TaskStatus>(&ctx, Duration::from_secs(5)).await?;

Fields are formatted as 【key】\nvalue before being sent to the LLM, with each label acting as context for the model.

4. FocusOutput\<T>

The return value contains both the structured result and the raw response:

pub struct FocusOutput<T> {
    pub result: T,           // Deserialized from JSON
    pub raw_response: String, // Raw LLM output (for debugging)
}

Keep raw_response for logging — when parsing fails, it tells you exactly what the LLM returned.

5. FocusError

Three failure modes, all explicit:

pub enum FocusError {
    Timeout(Duration),              // LLM didn't respond in time
    Llm(String),                    // Network error, API error, etc.
    Parse { error: String, raw: String }, // LLM didn't return valid JSON matching T
}

When to Use Focus vs. the Agent

Scenario Use
Multi-turn conversation with tools Agent (PhiAgent::run_turn)
One-off classification or judgment Focus
Structured extraction from text Focus
Pre/post processing outside agent loop Focus
Simple "is this done?" / "what state is this?" checks Focus

A common pattern: use Focus as a sidecar inside a tool implementation. Your tool does the mechanical work (run a command, fetch data), then uses Focus to interpret the result.

Full Example

See examples/focus-demo.rs for a complete runnable example.

API Reference

Focus types are re-exported from phi-agent:

pub use agent_works::focus::{
    Context as FocusContext,
    Focus,
    FocusError,
    FocusInput,
    FocusOutput,
};

For detailed API docs, see docs.rs/phi-agent.