Letting the model act
Tools turn a generation into something that can read and change data, which makes the ceiling and the approval gate part of the feature rather than an afterthought.
Tools, MaxSteps and Approve
Tools built once at startup, a step ceiling because a loop with no ceiling can bill without end, and an Approve policy that reads the arguments — a denied call never reaches the handler but stays in resp.ToolCalls as the audit trail.
go
package main
import (
"context"
"encoding/json"
"fmt"
core "gitlab.finema.co/finema/idin-core/v2"
"gitlab.finema.co/finema/idin-core/v2/llm"
)
// --- Example 4: tools the model calls ---------------------------------------
//
// A tool is ordinary Go code that happens to be reachable by a model's
// decision. llm.Tool[In] derives the JSON schema from In and unmarshals the
// model's arguments into it, so the function signature is the contract.
//
// Two things carry the whole design: MaxSteps, because a loop with no ceiling
// is a loop that can bill without end, and Approve, because the model decides
// what to call and the decision about what may *run* has to stay ours.
// SupportDesk holds the tools. They are built once at startup, not per request:
// building them is reflection over the input types, and a type that cannot be
// described as a schema is a bug that should stop the process, not the tenth
// request of the day.
type SupportDesk struct {
tools []core.LLMTool
}
func NewSupportDesk() (*SupportDesk, core.IError) {
tools, err := llm.NewToolSet().
// The description is the only thing the model reads when deciding. Say
// *when* to call it, not just what it is: "looks up orders" gets called
// at random or never.
Add(llm.Tool("get_order_status",
"Delivery status of one order. Call this when the user asks where their parcel is, when it will arrive, or for a tracking number.",
getOrderStatus)).
Add(llm.Tool("list_couriers",
"Couriers available for a destination. Call this before quoting a delivery time.",
listCouriers)).
Add(llm.Tool("refund_order",
"Refund an order in full. Only for an order that is already cancelled.",
refundOrder)).
Build()
if err != nil {
return nil, err
}
return &SupportDesk{tools: tools}, nil
}
func getOrderStatus(_ context.Context, in struct {
OrderID string `json:"order_id" jsonschema:"description=Order code such as TH-1042"`
}) (string, core.IError) {
// A real one queries: repository.New[Order](ctx).FindOne("code = ?", in.OrderID)
return fmt.Sprintf(`{"order":%q,"status":"shipped","courier":"Kerry"}`, in.OrderID), nil
}
func listCouriers(_ context.Context, in struct {
Province string `json:"province" jsonschema:"description=Destination province in English"`
}) (string, core.IError) {
return fmt.Sprintf(`{"province":%q,"couriers":["Kerry","Flash","ThaiPost"]}`, in.Province), nil
}
func refundOrder(_ context.Context, in struct {
OrderID string `json:"order_id"`
Amount float64 `json:"amount" jsonschema:"description=Amount in THB"`
}) (string, core.IError) {
// Whatever this returns — result or error — is sent back to the model, so
// it leaves the process. An error message naming a host, a DSN or an
// internal id is an error message handed to the provider.
return fmt.Sprintf(`{"order":%q,"refunded":%.2f}`, in.OrderID, in.Amount), nil
}
// approve is the gate. It runs before every call, and returning an error denies
// it: the handler is never reached, the message goes back to the model as the
// tool result, and the generation continues rather than failing.
//
// That last part is deliberate. A model told why it was refused explains itself
// to the user; a model told nothing calls the same tool until MaxSteps runs out.
func (d *SupportDesk) approve(_ context.Context, call core.LLMToolCall) core.IError {
switch call.Name {
case "get_order_status", "list_couriers":
return nil // read-only: the model may call these freely
case "refund_order":
// The arguments are readable here, so the policy can be about the
// amount rather than about the tool. Approving a whole tool is a much
// blunter instrument than it looks.
var p struct {
Amount float64 `json:"amount"`
}
_ = json.Unmarshal(call.Input, &p)
if p.Amount > 10_000 {
return core.New(403, "NEEDS_APPROVAL", "refunds over THB 10,000 need an operator")
}
return core.New(403, "NEEDS_HUMAN", "refunds are approved by an operator, not automatically")
default:
// A tool nobody wrote a rule for is denied. The opposite default means
// adding a tool silently grants the model permission to run it.
return core.Newf(403, "NOT_ALLOWED", "tool %q has no approval policy", call.Name)
}
}
// Answer runs the loop. Steps are what gets billed: one model turn plus its
// tool results is one step, and each step resends the whole conversation, so
// cost grows faster than the step count does.
func (d *SupportDesk) Answer(ctx core.IContext, question string) (string, core.IError) {
resp, err := core.LLM(ctx).Generate(core.LLMRequest{
System: `You are a delivery support agent.
Use the tools to check facts — never state a status you did not look up.
Answer in the language the customer wrote in, in at most three sentences.`,
CacheSystem: true,
Messages: []core.LLMMessage{core.LLMUser(question)},
Tools: d.tools,
// Without MaxSteps the model's calls come back unexecuted, with
// FinishReason == tool_use. That is the deliberate default: a caller who
// forgot the ceiling gets an obvious non-answer instead of a silent loop.
MaxSteps: 5,
MaxTokens: 1024,
Approve: d.approve,
})
if err != nil {
return "", err
}
d.audit(ctx, resp)
if resp.FinishReason == core.LLMFinishToolUse {
// The ceiling was reached with the model still asking. resp.Text is not
// an answer, and raising MaxSteps is usually the wrong fix — a loop that
// cannot finish in five steps normally has a tool that describes itself
// badly.
return "", core.Newf(500, "AGENT_INCOMPLETE", "gave up after %d steps", resp.Steps)
}
return resp.Text, nil
}
// audit records what the loop actually did. resp.ToolCalls holds every call in
// order — including the denied ones, with the reason the model was given — and
// it is the only thing that answers "why did it say that" after the fact.
func (d *SupportDesk) audit(ctx core.IContext, resp core.LLMResponse) {
for _, call := range resp.ToolCalls {
ctx.Log().Info("agent tool call",
"tool", call.Name,
"outcome", string(call.Outcome), // ran · denied · failed · unknown_tool
"steps", resp.Steps)
// A tool that touches real data deserves a row rather than a log line:
// repository.New[AgentAudit](ctx).Create(&AgentAudit{...})
}
}The agent belongs in a job
A long loop needs a timeout, retries with backoff, a concurrency ceiling, a cancel button and a record of what happened. The job runner has all five; a go func() from an HTTP handler has none, and it is holding something that bills per step.
go
package main
import (
"context"
"time"
core "gitlab.finema.co/finema/idin-core/v2"
"gitlab.finema.co/finema/idin-core/v2/utils"
)
// --- Example 5: the agent belongs in a job ----------------------------------
//
// An "agent loop" is the tool loop from 04_tools.go with more steps and tools
// that do real work. There is no separate API for it, and there is no separate
// runtime for it either — the thing it needs is what the job runner already
// has: a timeout that is enforced, retries with backoff, a concurrency ceiling,
// a cancel button, and a run row that says what happened.
//
// The alternative people reach for is `go func()` from an HTTP handler. That
// loop has no deadline, cannot be cancelled, retries nothing, and leaves no
// trace when it ends — and it is holding a model that bills per step.
// AgentParams is the job's input, validated on the way in exactly like an HTTP
// payload, so a bad trigger fails the run up front instead of after two billed
// steps.
type AgentParams struct {
ConversationID *string `json:"conversation_id"`
Question *string `json:"question"`
}
func registerAgentJobs(reg *core.JobRegistry, desk *SupportDesk) {
_ = core.RegisterJob(reg, core.JobDef{
Name: "support-agent",
Description: "answer one support conversation with the tool loop",
// A queue of its own, because the constraint is the provider's rate
// limit rather than this process's CPU. Fifty agents racing into the
// same 429 is slower than four of them taking turns.
Queue: "ai",
MaxConcurrent: 4,
Concurrency: core.ConcurrencyEnqueue,
// Generous, because a long loop genuinely takes minutes — and finite,
// because a model that keeps calling a failing tool will keep going
// until something stops it. This is that something.
Timeout: 10 * time.Minute,
// The retryable failures are the provider's: 429 and 5xx. Anything the
// handler returns as a 4xx is the same answer next time, so the runner
// should not be asked to find out twice.
MaxAttempts: 3,
// Worth persisting here where it is not for a chatty sync job: an agent
// run is infrequent, expensive, and the thing someone asks about later.
Logs: core.LogPolicyPtr(core.LogOnFailure),
Params: core.Params(
core.StringParam("conversation_id").Required().Desc("Conversation to answer"),
core.StringParam("question").Required().Multiline().Max(4000),
),
}, runSupportAgent(desk))
}
// runSupportAgent closes over the desk so the tools are the ones built at
// startup. Rebuilding them per run would move a schema bug from boot to
// whichever run happened to hit it.
func runSupportAgent(desk *SupportDesk) func(core.ICronjobContext, AgentParams) error {
return func(c core.ICronjobContext, p AgentParams) error {
question := utils.ToNonPointerOr(p.Question, "")
conversation := utils.ToNonPointerOr(p.ConversationID, "")
c.Progress(10, "thinking")
// c is an IContext, so core.LLM(c) is bound to this run: cancelling the
// run cancels the generation, and the job's Timeout is the deadline the
// provider call actually observes.
answer, err := desk.Answer(c, question)
if err != nil {
// Returned, not logged-and-returned. The runner records it on the
// JobRun and reports it once — logging it here too makes one
// incident look like two.
return err
}
c.Progress(90, "replying")
c.SetResult(map[string]any{"conversation_id": conversation, "chars": len(answer)})
return nil
}
}
// triggerAgent is what an HTTP handler does instead of running the loop itself:
// hand the work to the runner and answer immediately with the run to poll.
//
// IdemKey is what makes a double-clicked button one run rather than two
// conversations answered twice — and two bills.
func triggerAgent(ctx context.Context, runner *core.JobRunner, userID, conversationID, question string) (*core.JobRun, core.IError) {
return runner.Trigger(ctx, "support-agent", &AgentParams{
ConversationID: utils.ToPointer(conversationID),
Question: utils.ToPointer(question),
}, core.TriggerOptions{
By: userID,
IdemKey: "support-agent:" + conversationID,
})
}
// demoAgentJob is the wiring main.go runs: a registry, a runner with a queue of
// its own for the model, one triggered run, then a clean stop.
func demoAgentJob(app *core.App, desk *SupportDesk) error {
reg := core.NewJobRegistry()
registerAgentJobs(reg, desk)
runner := core.NewJobRunner(app, reg,
core.WithWorkers(2),
// The AI queue is bounded by the provider's rate limit rather than by
// this process, so it gets a ceiling of its own instead of sharing the
// default one with everything else.
core.WithQueues(core.DefaultQueue, "ai"),
core.WithQueueLimit("ai", 2),
)
runner.Start()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
run, err := runner.TriggerAndWait(ctx, "support-agent", &AgentParams{
ConversationID: utils.ToPointer("conv-1"),
Question: utils.ToPointer("ของ TH-1042 ถึงเมื่อไหร่"),
})
if err != nil {
return err
}
app.Log().Info("agent job finished",
"run_id", run.ID, "status", string(run.Status), "duration_ms", run.DurationMS)
return runner.Stop(ctx)
}
// proposeOnly is the safest shape for anything with side effects, and the one
// to reach for before writing an Approve policy at all: give the agent
// read-only tools, keep what it suggests, and let a person press the button.
//
// A model that is right ninety-nine times and wrong once has still created one
// refund somebody has to chase.
func proposeOnly(ctx core.IContext, readOnly []core.LLMTool, incident string) (core.LLMResponse, core.IError) {
return core.LLM(ctx).Generate(core.LLMRequest{
System: "Diagnose the incident and propose what should be done. Do not act.",
CacheSystem: true,
Messages: []core.LLMMessage{core.LLMUser(incident)},
Tools: readOnly, // nothing here can write
MaxSteps: 8,
MaxTokens: 4096,
})
}