Part 3: tools and an agent

“Where is my order?” has an answer the model cannot know. This part gives the assistant two tools, an order lookup and the refund policy, and an agent that decides when to call them.

The code is Part3Tools.scala.

A typed tool

Tool.of builds a tool from a function over a case class: the parameters’ schema is derived from OrderQuery, the call’s arguments are read into it before your function runs, and the result is written as JSON. A Left is the error the model sees, so it can tell the customer the order number looks wrong.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.llm4s.schema.*
import org.llm4s.toolapi.ToolFunction
import org.llm4s.types.Result
import upickle.default.ReadWriter

@description("Which order to look up")
case class OrderQuery(@description("The order number, for example A-1001") orderId: String)
  derives SchemaOf, ReadWriter

case class OrderStatus(orderId: String, status: String, total: Double) derives ReadWriter

val orders: Map[String, OrderStatus] = Map(
  "A-1001" -> OrderStatus("A-1001", "shipped", 42.50),
  "A-1002" -> OrderStatus("A-1002", "processing", 18.00)
)

val orderTool: Result[ToolFunction[OrderQuery, OrderStatus]] =
  Tool.of[OrderQuery, OrderStatus]("lookup_order", "The status and total of a customer's order") { query =>
    orders.get(query.orderId).toRight(s"There is no order ${query.orderId}")
  }

A real shop would read its order database here; the map keeps the tutorial offline.

A second tool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@description("Which policy the customer is asking about")
case class PolicyQuery(@description("One of: refund, return, exchange") topic: String) derives SchemaOf, ReadWriter

case class Policy(topic: String, text: String) derives ReadWriter

val policies: Map[String, String] = Map(
  "refund"   -> "Refunds go back to the original payment method within 5 working days.",
  "return"   -> "Items can be returned within 30 days of delivery, unused and in their packaging.",
  "exchange" -> "Exchanges are free within 30 days of delivery."
)

val policyTool: Result[ToolFunction[PolicyQuery, Policy]] =
  Tool.of[PolicyQuery, Policy]("refund_policy", "Acme Shop's policy on refunds, returns and exchanges") { query =>
    val topic = query.topic.toLowerCase
    policies
      .get(topic)
      .map(Policy(topic, _))
      .toRight(s"No policy on ${query.topic}: ask for refund, return or exchange")
  }

An agent that uses them

An agent sends the question with the tools’ schemas, runs each tool the model calls, sends the results back, and repeats until the model answers. You build it once and run it per question:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.llm4s.agent.Agent
import org.llm4s.llmconnect.LLMClient
import org.llm4s.toolapi.ToolRegistry

def supportAgent(client: LLMClient): Result[Agent] =
  for {
    order  <- orderTool
    policy <- policyTool
    agent <- Agent
      .builder("support", client)
      .withSystemPrompt("You are the support assistant of Acme Shop. Use the tools; never guess an order's status.")
      .withTools(new ToolRegistry(Seq(order, policy)))
      .build()
  } yield agent

def answer(client: LLMClient, question: String): Result[String] =
  for {
    agent  <- supportAgent(client)
    result <- agent.run(question)
  } yield result.answer.getOrElse(s"The run ended without an answer: ${result.status}")

result.answer is the final answer when the run completed. A run can also end blocked by a guardrail or at its step limit; result.status says which, and Part 5 adds the guardrails.

Run the part. The scripted model calls lookup_order, then refund_policy, then answers:

1
2
sbt "samples/runMain org.llm4s.samples.tutorial.supportassistant.Part3Tools"
sbt "samples/runMain org.llm4s.samples.tutorial.supportassistant.Part3Tools --live"

Part3Spec checks more than the answer: that both tools were offered to the model, and that the tool results the agent sent back contain the order’s status and the policy.

Exercise 3: a delivery estimate

Write Part3Exercise.deliveryTool in Exercises.scala: a tool named delivery_estimate, built with Tool.of over the declared DeliveryQuery and DeliveryEstimate, that answers from Part3Tools.orders. An order that has shipped arrives in 2 working days, one still processing in 4, and an unknown order number is the error There is no order <orderId>. Then run

1
sbt "samples/testOnly *Part3Spec"

For a bonus, add your tool to supportAgent and ask “When will A-1002 arrive?” with --live. The solution is Part3Solution in the solutions package.

Previous: Part 2, typed answers. The next parts (help-centre answers with RAG, safety and memory, testing, serving) are tracked in #2292.