Skip to content

agents.New — a composable sub-agent

Go · package github.com/muthuishere/toolnexus/golang/agents · SPEC §7D · golang/agents/agent.go

func New(name string, spec Spec) *Agent
type Spec struct {
Does string // routing description the delegating model sees
Tools []tn.Tool // the toolkit VIEW for this agent — scoping is the security model
Soul string // system prompt, inline
SoulFile string // system prompt, from a file (wins over Soul if readable)
Team []*Agent // delegation targets — no Team ⇒ no task tool
Budget *Budget
Model string // "" ⇒ "inherit"
WaitFor func(tn.Request) (tn.Answer, error)
// OnSpawn, OnClose, Hooks, OnMetric — see SPEC §7D
}
func (a *Agent) Run(opts Options, prompt string) (TaskResult, *Runtime)
func (a *Agent) AsTool(opts Options) tn.Tool

agents.New is the SPEC §7D axiom made concrete: an Agent is a Tool — identity (Soul) × a filtered toolkit view (Tools) × the shipped client loop, invocable either as a one-shot (Run) or, via AsTool, dropped straight into a classic toolkit’s ExtraTools.

Reach for agents.New to build a coding-agent-style team — a coordinator that delegates focused subtasks to specialists, each scoped to only the tools and soul it needs — without standing up a server, a URL, or a protocol. Use Run for a one-shot; use AsTool to make the agent callable from an ordinary Client.Run loop, exactly like any other tool.

All three use a scripted http.RoundTripper in place of a real model — the runtime’s documented hermetic seam (Options.Transport), the same mechanism the port’s own tests use. No network call is ever made, no real API key required.

1. The smallest useful call — one agent, one answer

Section titled “1. The smallest useful call — one agent, one answer”
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/muthuishere/toolnexus/golang/agents"
)
// textLLM answers every request with the same fixed text — no network involved.
type textLLM struct{ reply string }
func (t textLLM) RoundTrip(req *http.Request) (*http.Response, error) {
body, _ := json.Marshal(map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": t.reply}}},
"usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(body)),
}, nil
}
func main() {
greeter := agents.New("greeter", agents.Spec{Does: "says hello", Model: "m-greeter"})
r, _ := greeter.Run(agents.Options{Transport: textLLM{reply: "hello!"}}, "say hi")
if r.Status != "done" || r.Text != "hello!" {
log.Fatalf("unexpected: %+v", r)
}
fmt.Println("ok:", r.Text)
}

2. The realistic case — a coordinator delegating to a teammate

Section titled “2. The realistic case — a coordinator delegating to a teammate”

Team is what makes delegation possible: a def with a non-empty Team gets an auto-added task tool scoped to exactly those names (see runtime/task-tool).

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/muthuishere/toolnexus/golang/agents"
)
func openaiResp(msg map[string]any) *http.Response {
full := map[string]any{"role": "assistant"}
for k, v := range msg {
full[k] = v
}
b, _ := json.Marshal(map[string]any{
"choices": []any{map[string]any{"message": full}},
"usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10},
})
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(b))}
}
// teamLLM scripts BOTH agents by model name — the runtime's turn gate keeps
// this hermetic regardless of which agent is "in flight".
type teamLLM struct{}
func (teamLLM) RoundTrip(req *http.Request) (*http.Response, error) {
b, _ := io.ReadAll(req.Body)
var parsed struct {
Model string `json:"model"`
Messages []map[string]any `json:"messages"`
}
_ = json.Unmarshal(b, &parsed)
sawToolResult := false
for _, m := range parsed.Messages {
if m["role"] == "tool" {
sawToolResult = true
}
}
switch parsed.Model {
case "m-coordinator":
if !sawToolResult {
args, _ := json.Marshal(map[string]any{"agent": "explorer", "prompt": "find the answer"})
return openaiResp(map[string]any{"content": nil, "tool_calls": []any{
map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "task", "arguments": string(args)}},
}}), nil
}
return openaiResp(map[string]any{"content": "delegation complete"}), nil
case "m-explorer":
return openaiResp(map[string]any{"content": "found it"}), nil
default:
return openaiResp(map[string]any{"content": "ok"}), nil
}
}
func main() {
explorer := agents.New("explorer", agents.Spec{Does: "read-only research", Model: "m-explorer"})
coordinator := agents.New("coordinator", agents.Spec{
Does: "splits work and delegates", Model: "m-coordinator", Team: []*agents.Agent{explorer},
})
r, _ := coordinator.Run(agents.Options{Transport: teamLLM{}}, "answer the question")
if r.Status != "done" || !strings.Contains(r.Text, "delegation complete") {
log.Fatalf("unexpected: %+v", r)
}
fmt.Println("ok:", r.Text)
}

3. The full surface — AsTool bridging into the classic client loop

Section titled “3. The full surface — AsTool bridging into the classic client loop”

AsTool is the axiom’s other direction: an Agent dropped into an ordinary toolkit’s ExtraTools, invisible from an LLM’s point of view — the model just sees one more tool named explorer.

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
"github.com/muthuishere/toolnexus/golang/agents"
)
// subLLM answers the inner agent's own model directly, no tool calls needed.
type subLLM struct{}
func (subLLM) RoundTrip(req *http.Request) (*http.Response, error) {
b, _ := json.Marshal(map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "the answer is 42"}}},
"usage": map[string]any{"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4},
})
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(b))}, nil
}
func main() {
explorer := agents.New("explorer", agents.Spec{Does: "finds facts", Model: "m-explorer"})
explorerTool := explorer.AsTool(agents.Options{Transport: subLLM{}})
// The TOP-LEVEL model/loop is a completely separate, ordinary httptest stub
// (same pattern as Client.Run) — it just sees "explorer" as one more tool.
turn := 0
topLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
turn++
w.Header().Set("Content-Type", "application/json")
if turn == 1 {
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"explorer","arguments":"{\"prompt\":\"what is the answer?\"}"}}]}}],"usage":{"prompt_tokens":5,"completion_tokens":5,"total_tokens":10}}`))
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`))
}))
defer topLLM.Close()
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{
Builtins: false, ExtraTools: []toolnexus.Tool{explorerTool},
})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: topLLM.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "k"})
res, err := client.Run(context.Background(), "what is the answer?", tk)
if err != nil {
log.Fatal(err)
}
if len(res.ToolCalls) != 1 || res.ToolCalls[0].Name != "explorer" {
log.Fatalf("expected the top loop to call explorer as a plain tool, got %+v", res.ToolCalls)
}
if res.ToolCalls[0].Output != "the answer is 42" {
log.Fatalf("unexpected sub-agent output: %q", res.ToolCalls[0].Output)
}
if res.ToolCalls[0].Metadata["agent"] != "explorer" {
log.Fatalf("expected metadata.agent = explorer, got %v", res.ToolCalls[0].Metadata)
}
fmt.Println("ok:", res.Text, "via sub-agent output:", res.ToolCalls[0].Output)
}
Field Type What it does
Does string The routing description the delegating model (or a parent’s task tool) sees.
Tools []tn.Tool The scoped toolkit view — scoping IS the security model; builtins are never added implicitly.
Soul / SoulFile string The system prompt, inline or from a file (SoulFile wins when both are set and readable).
Team []*Agent Delegation targets. Empty ⇒ no task tool — recursion is opt-in.
Budget *Budget Caps this agent’s subtree — see agents.Budget.
Model string "" ⇒ "inherit" (the runtime’s LLM default).
WaitFor func(tn.Request) (tn.Answer, error) This agent’s §10 interpreter authority for its subtree.

Agent.Registry() collects the agent plus the TRANSITIVE CLOSURE of its team graph — unreachable agents are never present.

  • NewRuntime — The six host verbs that drive sub-agents, plus the read-only list and inspect views.
  • agents.Handle — The state machine for one spawned agent: pending, running, suspended, done.
  • agents.Budget — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight.
  • agents.New (§7A, remote) — the REMOTE counterpart: call a peer over HTTP instead of composing one in-process.