Skip to content

v0.19.1

Choose a tag to compare

@thorrester thorrester released this 27 Mar 15:03
· 35 commits to main since this release
6f344b0

v0.19.1 release summary

Before this release, Agent was a stateless single-turn executor. It could run tasks, call tools, and return a response β€” but you had to orchestrate everything yourself. No loop, no memory, no way to compose agents together without writing your own plumbing.

This release adds all of that. Two PRs: #56 introduces the full agentic loop engine, and #57 layers potato-spec on top so you can define agents and workflows in YAML instead of code.


Breaking changes

None. All new fields on Agent are Option or empty Vec and default to no-op. Existing execute_task, execute, and execute_with_tools signatures are unchanged.

The one structural change worth noting: ToolCall gains an optional call_id: Option<String> field. If you're exhaustively destructuring ToolCall somewhere, you'll need to add that field.


Changes

potato-spec β€” define agents in YAML (#57)

New crate. SpecLoader reads a YAML file and builds fully-configured Agent, SequentialAgent, ParallelAgent, and DAG Workflow instances. The before/after is pretty stark:

Before:

let agent = AgentBuilder::new()
    .provider(Provider::Anthropic)
    .model("claude-haiku-4-5")
    .system_prompt("You are a summarization assistant.")
    .with_windowed_memory(10)
    .stop_on_keyword("DONE")
    .with_callback(Arc::new(LoggingCallback))
    .build()
    .await?;

After:

agents:
  - id: summarizer
    provider: anthropic
    model: claude-haiku-4-5
    system_prompt: You are a summarization assistant.
    memory:
      type: windowed
      window_size: 10
    criteria:
      - type: keyword
        keyword: "DONE"
    callbacks:
      - type: logging
let spec = SpecLoader::from_spec_path("agents.yaml").await?;
let agent = spec.agent("summarizer").unwrap();

Workflows support three types: sequential, parallel, and workflow (DAG). Sequential and parallel steps accept both ref: (a named agent defined elsewhere in the file) and inline agent definitions. DAG tasks use a dependencies list β€” SpecLoader topologically sorts them at load time and returns SpecError::CyclicDependency if there's a cycle. That's a load-time error, not a runtime one, which is the right call.

If you need tools or custom callbacks that can't be expressed in YAML, register them first:

let spec = SpecLoader::new()
    .register_async_tool(Arc::new(MyTool))
    .register_callback("my_hook", Arc::new(MyCallback))
    .load_file("agents.yaml")
    .await?;

SpecError has 7 variants covering the main failure modes: unknown provider, unsupported memory type, missing agent ref, cyclic dependency, YAML parse error, and file I/O.

Agentic loop β€” potato-agent (#56)

Agent now implements AgentRunner:

pub trait AgentRunner: Send + Sync + Debug {
    async fn run(
        &self,
        input: &str,
        session: &SessionState,
    ) -> Result<AgentRunOutcome, AgentError>;
}

AgentRunOutcome is either Complete(AgentRunResult) or NeedsInput { question, resume_context }. The NeedsInput arm is how human-in-the-loop works β€” the caller persists ResumeContext and calls resume() with the user's response when ready.

AgentRunResult has four fields: final_response, iterations, completion_reason (a human-readable string from whichever criteria fired), and combined_text (populated only by the CollectAll merge strategy in parallel runs).

AgentBuilder is the new construction path. It's fluent and handles wiring memory, criteria, callbacks, and stores in one chain. The old constructors still work.

Completion criteria

CompletionCriteria is a trait. Three built-ins:

Criteria Stops when
MaxIterationsCriteria ctx.iteration >= max
KeywordCriteria response text contains the keyword
StructuredOutputCriteria response parses as valid JSON (optionally schema-validated)

Multiple criteria are OR'd β€” the first one that returns true stops the loop. If you need AND semantics you'll need to write a custom implementation that wraps multiple criteria.

Lifecycle callbacks

AgentCallback fires at six points: before_model_call, after_model_call, on_tool_call, on_tool_result, on_complete, on_error. Each returns a CallbackAction:

  • Continue β€” keep going
  • OverrideResponse(String) β€” replace the model response and continue
  • Abort(String) β€” surface the string as AgentError and stop

LoggingCallback is built in and traces all six hooks via tracing::info!. It's the type: logging option in YAML specs.

Memory

Three implementations of the Memory trait:

  • InMemoryMemory β€” unbounded, no persistence
  • WindowedMemory β€” keeps the last N turns, evicts oldest on overflow
  • PersistentMemory β€” delegates to a MemoryStore backend, optionally windowed

Memory lives on Agent as Option<Arc<Mutex<Box<dyn Memory>>>> and is deliberately excluded from Clone. When you clone an agent (e.g., for parallel runs), it gets empty memory β€” shared mutable history across concurrent agents is not something you want by default.

Store layer

Four store traits, each with a SQLite implementation behind feature = "sqlite":

Trait SQLite impl Scope
MemoryStore SqliteMemoryStore Conversation turns, keyed by UUIDv7 for time-ordered queries
SessionStore SqliteSessionStore SessionSnapshot per session_id
UserStateStore SqliteUserStateStore Per (app_name, user_id)
AppStateStore SqliteAppStateStore Per app_name, spans all users

SessionState is a cheap-clone Arc<RwLock<HashMap<String, Value>>> for sharing mutable data across agents within a single run. SessionSnapshot is its serializable form for the store layer.

Agent-as-tool

AgentTool wraps any AgentRunner as an AsyncTool. The LLM calls it with {"input": "<string>"} and gets back the agent's final response. This is how you build hierarchical agent systems β€” a top-level agent calls sub-agents through the same tool-use loop it uses for everything else.

AgentToolPolicy lets you constrain what a sub-agent can do: set disallow_sub_agent_calls to block recursive AgentTool invocations, or populate disallowed_agent_ids to block specific agents by ID.

AsyncTool is a new trait in potato-type for non-blocking tool execution:

#[async_trait]
pub trait AsyncTool: Send + Sync + Debug {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn parameter_schema(&self) -> Value;
    async fn execute(&self, args: Value) -> Result<Value, TypeError>;
    fn as_any(&self) -> Option<&dyn std::any::Any> { None }
}

ToolRegistry gains register_async_tool, get_async_tool, and get_all_definitions. The existing sync execute path is unchanged.

Python stubs

Prompt.__init__ is replaced with @overload __new__ signatures. No behavior change β€” this fixes mypy/pyright resolving the multiple constructor forms.


Upgrading from v0.19.0

No changes required for existing code.

To use the SQLite store layer, add the feature flag:

potato-agent = { version = "0.19.1", features = ["sqlite"] }

If you're exhaustively destructuring ToolCall, add the new call_id: Option<String> field.