Let the attendant act on what the meeting decided

A meeting that decides something leaves the decision in the transcript, and
there it stays until a person copies it somewhere — and the copying is the part
that does not happen. The attendant now offers the model the tools the caller's
org already has, runs the ones it asks for, and then says what it did.

The tools are not named here. api.hanzo.ai reports what an org offers and runs
them, so an org holding todo gets todo_create and one holding more gets more,
and this binary never learns which. Only dispatchable and activated tools are
offered: the registry reports everything an org COULD have, and offering one it
has not turned on earns a refusal the room hears as the attendant being broken.

Two things the tests caught rather than the author:

`arguments` arrives as a JSON-encoded STRING, not an object — the model writes
the call's input as text and the transport carries it as text. Reading it as an
object failed before the call was ever made, so the item was never filed and
nothing said so. An object is still accepted; one shape must not be the only one
that works.

The loop was unbounded. Withholding tools on the last round is not the same as
stopping, and a model that keeps calling them is a model not answering while the
room listens to silence. It ends in words now, always.

A catalog it cannot read is not fatal: an attendant that only talks is what it
was before this, and losing the follow-up beats losing the meeting.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-20 20:07:54 -07:00
co-authored by Hanzo Dev
parent a7f31e9e15
commit fa64283508
3 changed files with 328 additions and 24 deletions
+79 -24
View File
@@ -63,6 +63,13 @@ type Answer struct {
// Turns already answered. Owned by the answering loop — one goroutine, so
// no lock, and no second answerer to share it with.
answered int
// What this org can do, read once from /v1/tools. Owned by the same single
// goroutine as `answered`, so a nil kit means "not read yet" and an empty one
// means "read, and there is nothing" — two different answers that must not be
// re-asked on every turn.
can kit
read bool
}
// Turn records the turn as the notes do, and says so when it ends. The nudge is
@@ -194,34 +201,82 @@ func until(ctx context.Context, f *Floor, want func(holder string) bool) error {
return nil
}
// reply asks our model what to say about the meeting so far.
// reply asks our model what to say about the meeting so far, and lets it act on
// what it heard before it speaks.
//
// A meeting that decides something leaves the decision in the transcript, and
// there it stays. The tools the caller's org already offers are how it leaves:
// the model files the item itself, and then says what it did. The loop is bounded
// because a model still calling tools is a model not answering, and the room is
// listening to silence while it does.
func (a *Answer) reply(ctx context.Context, meeting string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": a.model,
"max_tokens": most,
"messages": []map[string]string{
{"role": "system", "content": a.prompt},
{"role": "user", "content": meeting},
},
})
got, err := ask(ctx, a.key, http.MethodPost, a.ai+"/v1/chat/completions", "application/json", body)
if err != nil {
return "", err
if !a.read {
a.can, a.read = offered(ctx, a.ai, a.key), true
}
var answered struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
messages := []map[string]any{
{"role": "system", "content": a.prompt},
{"role": "user", "content": meeting},
}
if err := json.Unmarshal(got, &answered); err != nil {
return "", fmt.Errorf("model: %w", err)
for round := 0; ; round++ {
req := map[string]any{
"model": a.model,
"max_tokens": most,
"messages": messages,
}
// The last round is asked WITHOUT tools, so the turn ends in words rather
// than in another call nobody hears.
if len(a.can) > 0 && round < rounds-1 {
req["tools"] = []map[string]any(a.can)
}
body, _ := json.Marshal(req)
got, err := ask(ctx, a.key, http.MethodPost, a.ai+"/v1/chat/completions", "application/json", body)
if err != nil {
return "", err
}
var answered struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(got, &answered); err != nil {
return "", fmt.Errorf("model: %w", err)
}
if len(answered.Choices) == 0 {
return "", nil
}
msg := answered.Choices[0].Message
// No calls, or no rounds left to make one in: whatever it said is the turn.
// The room is owed words, and a call it cannot hear is not words.
if len(msg.ToolCalls) == 0 || round >= rounds-1 {
return strings.TrimSpace(msg.Content), nil
}
// The assistant turn goes back verbatim — a tool result with no call
// preceding it is a conversation the model cannot read.
var calls []map[string]any
for _, c := range msg.ToolCalls {
calls = append(calls, map[string]any{
"id": c.ID, "type": "function",
"function": map[string]any{"name": c.Function.Name, "arguments": string(c.Function.Arguments)},
})
}
messages = append(messages, map[string]any{"role": "assistant", "content": msg.Content, "tool_calls": calls})
for _, c := range msg.ToolCalls {
messages = append(messages, map[string]any{
"role": "tool",
"tool_call_id": c.ID,
"content": call(ctx, a.ai, a.key, c.Function.Name, c.Function.Arguments),
})
}
}
if len(answered.Choices) == 0 {
return "", nil
}
return strings.TrimSpace(answered.Choices[0].Message.Content), nil
}
// speak gives the answer a voice: Ogg/Opus, which is what a room carries and what
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
// What the attendant can DO about what it heard.
//
// Answering out loud is the whole of a conversation and none of the follow-up. A
// meeting that decides something leaves the decision in the transcript, where it
// stays until a person copies it somewhere — and the copying is the part that
// does not happen.
//
// The tools are not named here. api.hanzo.ai already reports what the caller's
// org offers (GET /v1/tools) and runs them (POST /v1/tools/call), so the
// attendant offers whatever that answer holds and nothing it does not: an org
// that has todo gets todo_create, one that has more gets more, and this file
// never learns which. Hardcoding a tool would put the catalog in two places and
// make the second one wrong the first time either moved.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// rounds bounds the tool loop. A model that keeps calling tools is a model that
// is not answering, and a room waiting on it hears silence — so the loop is
// short and the last word is always spoken, not called.
const rounds = 3
// kit is what this org can do, already in the shape the model is asked with.
type kit []map[string]any
// offered reads the caller's own tools. A failure is not fatal: an attendant
// that cannot list tools is an attendant that only talks, which is what it did
// before this file existed.
func offered(ctx context.Context, ai, key string) kit {
got, err := ask(ctx, key, http.MethodGet, ai+"/v1/tools", "", nil)
if err != nil {
return nil
}
var listed struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
Schema json.RawMessage `json:"inputSchema"`
Dispatchable bool `json:"dispatchable"`
Activated bool `json:"activated"`
} `json:"tools"`
}
if err := json.Unmarshal(got, &listed); err != nil {
return nil
}
var out kit
for _, t := range listed.Tools {
// Dispatchable AND activated: the registry reports everything the org
// could have, and offering one it has not turned on earns a refusal the
// room would hear as the attendant being broken.
if !t.Dispatchable || !t.Activated || len(t.Schema) == 0 {
continue
}
out = append(out, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": t.Schema,
},
})
}
return out
}
// call runs one tool the model asked for and returns what to tell it back.
//
// An error is returned AS THE RESULT rather than raised: the model asked a
// question and a sentence saying why it cannot be answered is something it can
// act on, where an aborted turn just loses the meeting's follow-up silently.
func call(ctx context.Context, ai, key, name string, args json.RawMessage) string {
// `arguments` arrives as a JSON-encoded STRING, not an object — the model
// writes the call's input as text and the transport carries it as text. Read
// it as an object first anyway: a provider that sends the object directly is
// not wrong, and one shape must not be the only one that works.
var parsed map[string]any
if len(args) > 0 {
if err := json.Unmarshal(args, &parsed); err != nil {
var text string
if err := json.Unmarshal(args, &text); err != nil {
return fmt.Sprintf("could not read the arguments for %s: %v", name, err)
}
if text = strings.TrimSpace(text); text != "" {
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
return fmt.Sprintf("could not read the arguments for %s: %v", name, err)
}
}
}
}
body, _ := json.Marshal(map[string]any{"name": name, "arguments": parsed})
got, err := ask(ctx, key, http.MethodPost, ai+"/v1/tools/call", "application/json", body)
if err != nil {
return fmt.Sprintf("%s did not run: %v", name, err)
}
return strings.TrimSpace(string(got))
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// estate stands in for api.hanzo.ai: it reports what the org can do, runs what is
// asked, and answers as the model. One host, because that is how the real one is
// reached.
type estate struct {
mu sync.Mutex
ran []string // tools actually dispatched, in order
sent []json.RawMessage // the arguments each was given
turns int // completions served
tools string // the /v1/tools body
reply []string // one completion body per turn
}
func (e *estate) serve() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
e.mu.Lock()
defer e.mu.Unlock()
switch {
case r.URL.Path == "/v1/tools":
io.WriteString(w, e.tools)
case r.URL.Path == "/v1/tools/call":
var in struct {
Name string `json:"name"`
Args json.RawMessage `json:"arguments"`
}
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &in)
e.ran = append(e.ran, in.Name)
e.sent = append(e.sent, in.Args)
io.WriteString(w, `{"name":"todo_create","content":"ENG-41 created"}`)
case r.URL.Path == "/v1/chat/completions":
i := e.turns
e.turns++
if i < len(e.reply) {
io.WriteString(w, e.reply[i])
return
}
io.WriteString(w, `{"choices":[{"message":{"content":"done"}}]}`)
}
}))
}
const oneTool = `{"tools":[{"name":"todo_create","description":"File an item",
"inputSchema":{"type":"object","properties":{"title":{"type":"string"}}},
"dispatchable":true,"activated":true}]}`
// The point of the whole file: a meeting that decides something leaves the
// decision somewhere a person will see it, without a person copying it.
func TestWhatTheMeetingDecidedIsFiled(t *testing.T) {
e := &estate{tools: oneTool, reply: []string{
`{"choices":[{"message":{"content":"","tool_calls":[{"id":"c1","type":"function",
"function":{"name":"todo_create","arguments":"{\"title\":\"ship the relay fix\"}"}}]}}]}`,
`{"choices":[{"message":{"content":"Filed ENG-41."}}]}`,
}}
srv := e.serve()
defer srv.Close()
a := answering(Record(srv.URL, "whisper", "en"), srv.URL)
said, err := a.reply(context.Background(), "we agreed to ship the relay fix")
if err != nil {
t.Fatal(err)
}
if said != "Filed ENG-41." {
t.Errorf("the room heard %q, not the answer that followed the filing", said)
}
if len(e.ran) != 1 || e.ran[0] != "todo_create" {
t.Fatalf("tools dispatched = %v, want one todo_create", e.ran)
}
if !strings.Contains(string(e.sent[0]), "ship the relay fix") {
t.Errorf("the item was filed without what was decided: %s", e.sent[0])
}
}
// A tool the org has not turned on is not offered. Offering one earns a refusal
// the room hears as the attendant being broken.
func TestOnlyWhatTheOrgTurnedOnIsOffered(t *testing.T) {
e := &estate{tools: `{"tools":[
{"name":"on","description":"","inputSchema":{"type":"object"},"dispatchable":true,"activated":true},
{"name":"off","description":"","inputSchema":{"type":"object"},"dispatchable":true,"activated":false},
{"name":"undispatchable","description":"","inputSchema":{"type":"object"},"dispatchable":false,"activated":true}]}`}
srv := e.serve()
defer srv.Close()
got := offered(context.Background(), srv.URL, "")
if len(got) != 1 {
t.Fatalf("offered %d tools, want only the activated dispatchable one", len(got))
}
fn := got[0]["function"].(map[string]any)
if fn["name"] != "on" {
t.Errorf("offered %q", fn["name"])
}
}
// An attendant that cannot read the catalog still talks. Losing the follow-up is
// a smaller failure than losing the meeting.
func TestAnUnreadableCatalogStillAnswers(t *testing.T) {
e := &estate{tools: `not json`}
srv := e.serve()
defer srv.Close()
a := answering(Record(srv.URL, "whisper", "en"), srv.URL)
said, err := a.reply(context.Background(), "hello")
if err != nil {
t.Fatal(err)
}
if said != "done" {
t.Errorf("said %q — the answer did not survive an unreadable catalog", said)
}
}
// A model that keeps calling tools is a model that is not answering, and the room
// is listening to silence. The last round is asked without them so the turn ends
// in words.
func TestTheLoopEndsInWordsNotAnotherCall(t *testing.T) {
call := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"c","type":"function",
"function":{"name":"todo_create","arguments":"{}"}}]}}]}`
e := &estate{tools: oneTool, reply: []string{call, call, call, call, call}}
srv := e.serve()
defer srv.Close()
a := answering(Record(srv.URL, "whisper", "en"), srv.URL)
if _, err := a.reply(context.Background(), "loop"); err != nil {
t.Fatal(err)
}
if e.turns > rounds {
t.Errorf("asked the model %d times, bound is %d", e.turns, rounds)
}
}