mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
The chat's network view, the administrator view and the line the model reads about the network of its commands now take their name from the capabilities.network an add-on declares, or from the title of its commands tool when it declares none. The sentences no longer say "Sandbox" or "the sandbox". The API gains a network object with the title and the preset titles next to the existing addon field. The text of the built-in bash and of the host-exec setting names an add-on that provides bash instead of one add-on. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
663 lines
24 KiB
Go
663 lines
24 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/agents"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/store"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
)
|
|
|
|
// maxBackgroundChildren is how many sub-agents one conversation may run in the
|
|
// background at once.
|
|
const maxBackgroundChildren = 4
|
|
|
|
// Sub-agent errors, returned to the model as the result of the tool call.
|
|
var (
|
|
// ErrSubAgentDepth means a sub-agent tried to start another. Sub-agents
|
|
// nest one level only.
|
|
ErrSubAgentDepth = errors.New("a sub-agent cannot start another sub-agent")
|
|
// ErrSubAgentLimit means the conversation already runs the most background
|
|
// sub-agents it may.
|
|
ErrSubAgentLimit = errors.New("the limit of background sub-agents for this conversation is reached; wait for one to finish")
|
|
// errNoTurnAccess means the hook got an access that has no folder: the
|
|
// chat's access was not resolved, so it gets no tools.
|
|
// ErrAgentOutsideFolder means spawn_agent named a saved agent whose folder
|
|
// is not the folder of the chat's own agent or inside it. A sub-agent never
|
|
// has more reach than its parent, so the agent is refused, not widened.
|
|
ErrAgentOutsideFolder = errors.New("this agent works in another folder than this chat")
|
|
errNoTurnAccess = errors.New("studio: the chat's access was not resolved; no tools this turn")
|
|
)
|
|
|
|
type child struct {
|
|
id string
|
|
title string
|
|
background bool
|
|
}
|
|
|
|
// chatTools is the hook that gives every chat its host tools. A conversation
|
|
// gets the host tools plus spawn_agent; a sub-agent gets only the tools its
|
|
// parent chose and never spawn_agent. A sub-agent started from a saved agent
|
|
// gets what the agent grants instead, read from the store like any chat with
|
|
// an agent, so it needs no tool list in memory. Every tool is built from the
|
|
// chat's access for the turn: its actor is fenced when the chat's agent has a
|
|
// folder.
|
|
func (s *Service) chatTools(ctx context.Context, x session.Access) (session.TurnTools, error) {
|
|
a := x.Actor()
|
|
if s.d.Files.Available() && x.Place().Primary() == "" {
|
|
// An access without a folder was never resolved for this chat
|
|
// (chatAccess gives every chat at least its home or its agent's
|
|
// folder). Refuse the tools rather than build them on a guess.
|
|
return session.TurnTools{}, errNoTurnAccess
|
|
}
|
|
rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
|
|
if err != nil {
|
|
return session.TurnTools{}, err
|
|
}
|
|
sub := rec.Kind == session.KindSubAgent
|
|
if sub && rec.AgentID == "" {
|
|
s.mu.Lock()
|
|
tools := s.allowed[key(a.Principal, a.ChatID)]
|
|
s.mu.Unlock()
|
|
var prompt, work string
|
|
cmds := s.withHostBash(s.commandsOf(ctx, a.Principal, tools, nil, nil), tools)
|
|
if cmds.State != CommandsAvailable {
|
|
prompt = subAgentCommandsBlock
|
|
}
|
|
if canWriteFiles(tools, cmds) {
|
|
work = s.subWorkBlock(ctx, rec, x)
|
|
}
|
|
prompt = joinBlocks(prompt, webBlock(tools), work)
|
|
return session.TurnTools{Tools: tools, Prompt: prompt}, nil
|
|
}
|
|
tools, missing, err := s.scopedHostTools(ctx, a)
|
|
if err != nil {
|
|
var policyErr *providerPolicyError
|
|
if errors.As(err, &policyErr) {
|
|
return session.TurnTools{}, err
|
|
}
|
|
s.log.Warn("studio: host tools unavailable", "chat", a.ChatID, "error", err)
|
|
}
|
|
sc := s.scopeOf(ctx, rec)
|
|
// The tools the chat's switches take away (a sub-agent follows its
|
|
// parent's): scopedHostTools dropped the add-on ones already.
|
|
hidden := s.hiddenFor(ctx, rec)
|
|
cmds := s.commandsOf(ctx, a.Principal, tools, rec.AddonsOff, sc)
|
|
// The built-in tools no add-on serves: bash on this machine when the
|
|
// Sandbox does not serve it and the setting allows it.
|
|
tools = s.applyBuiltins(tools, s.builtinsFor(ctx, a, rec, sub))
|
|
cmds = shellSwitched(s.withHostBash(cmds, tools), hidden)
|
|
// The file tools are the one file toolset of the chat. A sub-agent gets
|
|
// them from subset, built for the parent chat; one from a saved agent gets
|
|
// them here, built for the parent chat too, so the parent's Files pane
|
|
// sees its changes.
|
|
// Every file and media tool takes the access of this turn, resolved
|
|
// once by chatAccess: none builds a view of its own.
|
|
fx := x
|
|
if sub {
|
|
if fx, err = x.ForChat(rec.ParentID); err != nil {
|
|
return session.TurnTools{}, err
|
|
}
|
|
}
|
|
fa := fx.Actor()
|
|
nudge := s.newRulesNudge(ctx, x, rec)
|
|
tools = append(tools, nudge.wrap(s.fileNudgeTools(fx, s.announceFileChanges(fa, s.skillHintTools(fa, s.binaryHintTools(fa, cmds.State == CommandsAvailable, dropHidden(s.d.Files.Tools(fx), hidden))))))...)
|
|
if !sub {
|
|
tools = append(tools, s.attachTools(ctx, fx, rec)...)
|
|
}
|
|
if s.d.Media != nil {
|
|
tools = append(tools, s.skillHintTools(fa, dropHidden(s.d.Media.Tools(s.d.Files, x, rec.Model), hidden))...)
|
|
}
|
|
// What the prompt may tell the model to do with the files follows the
|
|
// tools it has now: a chat with the file tools switched off hears of none.
|
|
canRead, canWrite := canReadFiles(tools, cmds), canWriteFiles(tools, cmds)
|
|
skillsOff := 0
|
|
prompt, note, key, fallback, err := s.skillTurn(ctx, a.Principal, rec.SkillsOff, rec.AddonsOff, sc, cmds, execGate(cmds, &skillsOff))
|
|
if err != nil {
|
|
return session.TurnTools{}, err
|
|
}
|
|
if !canWrite {
|
|
prompt = withoutWriteHint(prompt)
|
|
}
|
|
// The persona and the facts about the user come after the chat's prompt
|
|
// and the agent's instructions, which take precedence, and before the
|
|
// index of skills. A change to either file restarts the harness at the
|
|
// next turn, because the prompt changes.
|
|
var persona, personaKey string
|
|
if !sub {
|
|
persona, personaKey = s.WellKnown().TurnPrompt(ctx, a.Principal)
|
|
}
|
|
// The agent's profile and the index of the user's notes follow the persona.
|
|
// The first is the user's standing rules for this agent; the second points
|
|
// the model at memory_read.
|
|
var notes, notesKey string
|
|
if !sub {
|
|
notes, notesKey = s.contextTurn(ctx, rec)
|
|
tools = append(tools, s.contextTools(rec)...)
|
|
}
|
|
if lead := joinBlocks(persona, notes); lead != "" {
|
|
prompt = strings.TrimSpace(lead + "\n\n" + prompt)
|
|
}
|
|
if sc != nil {
|
|
// Granted skills the user lacks come back from skillTurn as a note of
|
|
// their own; fold all the missing grants into one line.
|
|
var skillMissing []string
|
|
if note != "" {
|
|
skillMissing = strings.Split(note, "; ")
|
|
}
|
|
note = unavailable(missing, skillMissing)
|
|
if sc.instructions != "" {
|
|
// The instructions go first: the prompt is cut at its end.
|
|
prompt = strings.TrimSpace(sc.instructions + "\n\n" + prompt)
|
|
}
|
|
}
|
|
if fallback != "" {
|
|
note = strings.TrimSpace(note + " " + fallback)
|
|
}
|
|
if !sub && !hidden[spawnAgentName] {
|
|
// The agent argument is offered only when list_agents is: the chat
|
|
// switched the saved agents on and its user may use them.
|
|
tools = append(tools, s.spawnTool(x, hasTool(tools, listAgentsName)))
|
|
}
|
|
// The assistant tools are not part of hostTools, so a sub-agent never gets
|
|
// them, whatever its parent names.
|
|
aTools, aPrompt, aNote := s.assistantTurn(ctx, rec)
|
|
tools = append(tools, aTools...)
|
|
// The tools of a self-paced loop, while the chat has one.
|
|
tools = append(tools, s.loopTools(ctx, a, rec)...)
|
|
if aPrompt != "" {
|
|
prompt = strings.TrimSpace(aPrompt + "\n\n" + prompt)
|
|
}
|
|
if aNote != "" {
|
|
note = strings.TrimSpace(note + " " + aNote)
|
|
}
|
|
// What the user did with the triggers this chat proposed: the tool result
|
|
// said they were waiting, so a later turn must hear how they ended.
|
|
if s.triggers != nil {
|
|
if line := s.triggers.ProposalNote(ctx, a.Principal, a.ChatID); line != "" {
|
|
note = strings.TrimSpace(note + " " + line)
|
|
}
|
|
}
|
|
// An offer from the assistant is allowed in few turns (see hintsvc.go); the
|
|
// model learns it from one line of the note, so other turns cost nothing.
|
|
if line := s.hintLine(ctx, a, rec); line != "" {
|
|
note = strings.TrimSpace(line + " " + note)
|
|
}
|
|
// The network of the commands an add-on runs, in one line: it follows the
|
|
// chat's preset at each turn and needs no restart of the harness.
|
|
if cmds.State == CommandsAvailable && cmds.Isolation == "isolated" && cmds.Provider != BuiltinProvider {
|
|
line, err := s.egressLine(ctx, rec, hasTool(tools, webSearchName) || hasTool(tools, webFetchName), s.networkOf(ctx, a.Principal, cmds).Title)
|
|
if err != nil {
|
|
return session.TurnTools{}, err
|
|
}
|
|
note = strings.TrimSpace(line + " " + note)
|
|
}
|
|
// The AGENTS.md files of the chat's folder and the home: a pointer, first,
|
|
// so the cut of a long note keeps it. A chat that cannot read them is
|
|
// not told to.
|
|
if canRead {
|
|
if line := nudge.turnLine(ctx, rec); line != "" {
|
|
note = strings.TrimSpace(line + " " + note)
|
|
}
|
|
}
|
|
// What the chat can run comes before the other blocks: the prompt is cut at
|
|
// its end, and this block must reach the model.
|
|
// What the chat can see follows it, for the same reason. Images reach a
|
|
// chat only as files, so a chat without files has no such block.
|
|
head := commandsBlock(cmds, hasTool(tools, "write_file"), hasTool(tools, "read_file"), skillsOff > 0, x.Place())
|
|
if s.d.Files.Available() {
|
|
sees := s.d.Modalities != nil && s.d.Modalities(rec.Model).Vision
|
|
head += "\n\n" + imagesBlock(sees, s.d.Media.VisionModel(rec.Model), hasTool(tools, "describe_image"))
|
|
}
|
|
var goal string
|
|
if !sub && goalActive(rec) {
|
|
// The goal comes right after what the chat can run: the prompt is cut
|
|
// at its end, and the model must not lose the goal.
|
|
goal = goalBlock(rec.Goal)
|
|
}
|
|
prompt = joinBlocks(head, goal, s.connectorBlock(ctx, rec, canWrite), webBlock(tools), prompt)
|
|
if sp := strings.TrimSpace(rec.Settings.SystemPrompt); sp != "" {
|
|
// The chat's own instructions come first: the prompt is cut at its end.
|
|
prompt = strings.TrimSpace(sp + "\n\n" + prompt)
|
|
}
|
|
// The files go last: the prompt is cut at its end, and a block that
|
|
// changes between turns leaves the start of the prompt as it was.
|
|
if fp := s.filesPrompt(ctx, x); fp != "" {
|
|
if !canRead {
|
|
fp += "\n" + filesNote
|
|
}
|
|
prompt = strings.TrimSpace(prompt + "\n\n" + fp)
|
|
}
|
|
// A conversation names its own work folder (the session takes the folder
|
|
// name); a sub-agent saves in its parent's files, so it names the parent's.
|
|
// A chat that cannot save a file has no use for one.
|
|
work := !sub && s.workFolderOn() && s.d.Files.Available() && canWrite
|
|
if sub && canWrite {
|
|
prompt = joinBlocks(prompt, s.subWorkBlock(ctx, rec, x))
|
|
}
|
|
// load_skill learns from the final list whether the chat can read a
|
|
// skill's files and run what a skill needs.
|
|
tools = s.fitLoadSkill(a, tools)
|
|
return session.TurnTools{Tools: tools, Prompt: prompt, Note: note, WorkFolder: work, RefreshKey: key + "\x00" + rec.Settings.SystemPrompt + "\x00" + personaKey + "\x00" + notesKey}, nil
|
|
}
|
|
|
|
// spawnTool is spawn_agent for the chat of x: a generic sub-agent gets
|
|
// tools built from x, so it reaches what the parent's turn reaches.
|
|
func (s *Service) spawnTool(x session.Access, savedAgents bool) session.Tool {
|
|
desc := "Start a sub-agent that works on a task and reports back. Use it for a self-contained piece of work. " +
|
|
"By default you wait for its answer. With wait=false it runs in the background and its result arrives later as a message."
|
|
agent := ""
|
|
if savedAgents {
|
|
desc += " With agent, the sub-agent is one of the user's saved agents (see " + listAgentsName + "): it works with that agent's instructions, model and tools."
|
|
agent = `"agent":{"type":"string","description":"The id or name of a saved agent from ` + listAgentsName + `. Omit for a plain sub-agent with your tools. Cannot be combined with tools."},`
|
|
}
|
|
return session.Tool{
|
|
Name: "spawn_agent",
|
|
Description: desc,
|
|
Schema: json.RawMessage(`{"type":"object","properties":{` +
|
|
`"task":{"type":"string","description":"What the sub-agent should do, with everything it needs to know."},` +
|
|
agent +
|
|
`"tools":{"type":"array","items":{"type":"string"},"description":"Names of your tools the sub-agent may use. Omit to give it all of them; [] gives it none."},` +
|
|
`"wait":{"type":"boolean","description":"true (default): wait for the answer. false: carry on now."}},` +
|
|
`"required":["task"]}`),
|
|
Run: func(ctx context.Context, raw json.RawMessage) (string, error) { return s.spawn(ctx, x, raw) },
|
|
}
|
|
}
|
|
|
|
func (s *Service) spawn(ctx context.Context, x session.Access, raw json.RawMessage) (string, error) {
|
|
a := x.Actor()
|
|
var args struct {
|
|
Task string `json:"task"`
|
|
Agent string `json:"agent"`
|
|
Tools *[]string `json:"tools"`
|
|
Wait *bool `json:"wait"`
|
|
}
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return "", fmt.Errorf("bad arguments: %w", err)
|
|
}
|
|
task := strings.TrimSpace(args.Task)
|
|
if task == "" {
|
|
return "", errors.New("task is required")
|
|
}
|
|
wait := args.Wait == nil || *args.Wait
|
|
parent, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if parent.Kind == session.KindSubAgent {
|
|
return "", ErrSubAgentDepth
|
|
}
|
|
if s.hiddenFor(ctx, parent)[spawnAgentName] {
|
|
return "", ErrSubAgentsOff
|
|
}
|
|
|
|
var (
|
|
subset []session.Tool
|
|
agent agents.Agent
|
|
)
|
|
if ref := strings.TrimSpace(args.Agent); ref != "" {
|
|
// A saved agent: the permission is checked again at the call, since
|
|
// the setting, the chat's switch or the user's feature may have
|
|
// changed since the turn started.
|
|
if args.Tools != nil {
|
|
return "", ErrSavedAgentWithTools
|
|
}
|
|
if err := s.savedAgentsAllowed(ctx, parent); err != nil {
|
|
return "", err
|
|
}
|
|
if agent, err = s.resolveAgent(ctx, a.Principal, ref); err != nil {
|
|
return "", err
|
|
}
|
|
if err := s.agentFitsChat(ctx, parent, agent); err != nil {
|
|
return "", err
|
|
}
|
|
} else {
|
|
if subset, err = s.subset(ctx, x, args.Tools); err != nil {
|
|
return "", err
|
|
}
|
|
// A sub-agent has the parent's tools or fewer: when none of them runs
|
|
// commands, a task that asks to run something cannot work. Say so now
|
|
// instead of spending a turn of the sub-agent on it.
|
|
if !s.runsCommands(subset) && asksToRun(task) {
|
|
return "", ErrSubAgentCannotRun
|
|
}
|
|
}
|
|
if again, err := s.repeatedTask(ctx, a, task); err != nil {
|
|
return "", err
|
|
} else if again {
|
|
return "", ErrSubAgentRepeat
|
|
}
|
|
|
|
parentKey := key(a.Principal, a.ChatID)
|
|
childID := newID()
|
|
title := "Sub-agent: " + truncate(task, 60)
|
|
model := parent.Model
|
|
if agent.ID != "" {
|
|
title = "Sub-agent " + agent.Name + ": " + truncate(task, 60)
|
|
if agent.Model != "" {
|
|
model = agent.Model
|
|
}
|
|
}
|
|
if err := s.registerChild(ctx, a.Principal, a.ChatID, &child{id: childID, title: title, background: !wait}); err != nil {
|
|
return "", err
|
|
}
|
|
now := parent.Updated
|
|
rec := session.ChatRecord{ID: childID, Principal: a.Principal, Title: title, Created: now, Updated: now,
|
|
Kind: session.KindSubAgent, ParentID: a.ChatID, Model: model, AgentID: agent.ID,
|
|
// The child is refused, never given the whole home, when the folder
|
|
// it works in can no longer be found (chatScopes).
|
|
ScopedAgent: parent.ScopedAgent || agent.HasFolder()}
|
|
if err := s.d.Store.CreateChat(ctx, rec); err != nil {
|
|
s.forgetChild(parentKey, childID)
|
|
return "", err
|
|
}
|
|
s.bus.Publish(parentKey, session.Event{Kind: session.KindSubAgentStarted, IDs: []string{childID}, Text: title})
|
|
childKey := key(a.Principal, childID)
|
|
if agent.ID == "" {
|
|
s.mu.Lock()
|
|
s.allowed[childKey] = s.fitLoadSkill(a, subset)
|
|
s.mu.Unlock()
|
|
}
|
|
done := s.waitFor(childKey)
|
|
if _, err := s.send(context.WithoutCancel(ctx), a.Principal, childID, session.Message{Text: task}); err != nil {
|
|
// The child never ran: drop what was set up for it, or the waiter and
|
|
// an empty sub-agent conversation would stay behind.
|
|
s.forgetWaiter(childKey, done)
|
|
s.forgetChild(parentKey, childID)
|
|
if derr := s.d.Store.DeleteChat(context.WithoutCancel(ctx), a.Principal, childID); derr != nil {
|
|
s.log.Warn("studio: could not remove a sub-agent that did not start", "chat", childID, "error", derr)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
if !wait {
|
|
go s.reportBack(a, childID, title, done) // #nosec G118 -- a background sub-agent reports after the request ended
|
|
return fmt.Sprintf("Started sub-agent %s in the background. Its result will arrive as a message.", childID), nil
|
|
}
|
|
select {
|
|
case st := <-done:
|
|
s.forgetChild(parentKey, childID)
|
|
return s.childResult(ctx, a, childID, st)
|
|
case <-ctx.Done():
|
|
_ = s.Stop(context.WithoutCancel(ctx), a.Principal, childID)
|
|
s.forgetChild(parentKey, childID)
|
|
return "", ctx.Err()
|
|
}
|
|
}
|
|
|
|
// agentFitsChat checks that a saved agent may run as a sub-agent of the
|
|
// chat: when the chat's own agent has a folder, the agent has none or has one
|
|
// that is the same or inside it. The check is made from the stores at the
|
|
// call, before any chat is made for the child.
|
|
func (s *Service) agentFitsChat(ctx context.Context, chat session.ChatRecord, ag agents.Agent) error {
|
|
outer, err := s.agentFolderScope(ctx, chat, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !s.agentFitsScope(ctx, chat.Principal, outer, ag) {
|
|
return ErrAgentOutsideFolder
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// agentFitsScope says whether ag may work under outer, the folder of the
|
|
// chat's agent (nil for a chat with none). An agent whose folder cannot be
|
|
// found does not fit a folder: nothing says it is inside.
|
|
func (s *Service) agentFitsScope(ctx context.Context, p string, outer *folderScope, ag agents.Agent) bool {
|
|
if outer == nil || !ag.HasFolder() {
|
|
return true
|
|
}
|
|
inner, err := s.liveFolder(ctx, p, ag)
|
|
return err == nil && workspace.Within(inner.path, outer.path)
|
|
}
|
|
|
|
// subset picks the tools a sub-agent gets from the parent's host tools and
|
|
// file tools, built from x, the parent's access for this turn. Nil asks for
|
|
// all of them but the named-only ones; a name the parent does not have is an
|
|
// error.
|
|
func (s *Service) subset(ctx context.Context, x session.Access, names *[]string) ([]session.Tool, error) {
|
|
a := x.Actor()
|
|
have, err := s.hostTools(ctx, a)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not list your tools: %w", err)
|
|
}
|
|
// The built-in file tools act for the parent chat: what a sub-agent changes
|
|
// is an agent change of the parent, and the parent's Files pane refreshes.
|
|
parent, perr := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
|
|
fileTools := s.d.Files.Tools(x)
|
|
if perr == nil {
|
|
// A sub-agent never gets a tool its parent switched off.
|
|
fileTools = dropHidden(fileTools, s.hiddenFor(ctx, parent))
|
|
}
|
|
have = append(have, s.fileNudgeTools(x, s.announceFileChanges(a, s.skillHintTools(a, fileTools)))...)
|
|
if perr == nil {
|
|
// Built for a sub-agent: bash on this machine only when no card is
|
|
// needed, since nobody watches the sub-agent's chat.
|
|
have = s.applyBuiltins(have, s.builtinsFor(ctx, a, parent, true))
|
|
}
|
|
if names == nil {
|
|
kept := have[:0:0]
|
|
for _, t := range have {
|
|
if !namedOnlyTools[t.Name] {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
return kept, nil
|
|
}
|
|
byName := make(map[string]session.Tool, len(have))
|
|
for _, t := range have {
|
|
byName[t.Name] = t
|
|
}
|
|
out := make([]session.Tool, 0, len(*names))
|
|
for _, n := range *names {
|
|
t, ok := byName[n]
|
|
if !ok {
|
|
return nil, fmt.Errorf("the tool %q is not one of your tools, so a sub-agent cannot have it", n)
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// registerChild records a running sub-agent, refusing a background one past
|
|
// the limit. The limit counts the parent's running sub-agents in the store, so
|
|
// it holds across frontends: a sub-agent that a turn on another frontend
|
|
// started still counts. Children registered here but not yet running count too.
|
|
func (s *Service) registerChild(ctx context.Context, p, conv string, c *child) error {
|
|
parentKey := key(p, conv)
|
|
running := map[string]bool{}
|
|
if c.background {
|
|
kids, err := s.runningChildren(ctx, p, conv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, k := range kids {
|
|
running[k.ID] = true
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if c.background {
|
|
n := len(running)
|
|
for id, k := range s.children[parentKey] {
|
|
if k.background && !running[id] {
|
|
n++
|
|
}
|
|
}
|
|
if n >= maxBackgroundChildren {
|
|
return ErrSubAgentLimit
|
|
}
|
|
}
|
|
if s.children[parentKey] == nil {
|
|
s.children[parentKey] = map[string]*child{}
|
|
}
|
|
s.children[parentKey][c.id] = c
|
|
return nil
|
|
}
|
|
|
|
// runningChildren returns the sub-agent chats of a conversation whose turn
|
|
// runs now, on any frontend.
|
|
func (s *Service) runningChildren(ctx context.Context, p, conv string) ([]session.ChatRecord, error) {
|
|
kids, err := s.d.Store.ListChildren(ctx, p, conv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []session.ChatRecord
|
|
for _, k := range kids {
|
|
info, err := s.d.Store.Turn(ctx, p, k.ID)
|
|
if err != nil {
|
|
if errors.Is(err, session.ErrNotFound) {
|
|
continue // deleted meanwhile
|
|
}
|
|
return nil, err
|
|
}
|
|
if info.Running {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) forgetChild(parentKey, id string) {
|
|
s.mu.Lock()
|
|
// The child's tool list is needed only while its turn runs.
|
|
if p, _, ok := strings.Cut(parentKey, "\x00"); ok {
|
|
delete(s.allowed, key(p, id))
|
|
}
|
|
delete(s.children[parentKey], id)
|
|
if len(s.children[parentKey]) == 0 {
|
|
delete(s.children, parentKey)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// waitFor returns a channel that receives how the next turn of the
|
|
// conversation ends. Register it before starting the turn.
|
|
func (s *Service) waitFor(k string) <-chan store.TurnStatus {
|
|
ch := make(chan store.TurnStatus, 1)
|
|
s.mu.Lock()
|
|
s.waiters[k] = append(s.waiters[k], ch)
|
|
s.mu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
// forgetWaiter removes a channel that waitFor returned and nobody will read.
|
|
func (s *Service) forgetWaiter(k string, ch <-chan store.TurnStatus) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
ws := s.waiters[k]
|
|
if i := slices.IndexFunc(ws, func(w chan store.TurnStatus) bool { return (<-chan store.TurnStatus)(w) == ch }); i >= 0 {
|
|
// A copy: notifyWaiters may hold the old slice.
|
|
s.waiters[k] = slices.Delete(slices.Clone(ws), i, i+1)
|
|
}
|
|
if len(s.waiters[k]) == 0 {
|
|
delete(s.waiters, k)
|
|
}
|
|
}
|
|
|
|
func (s *Service) notifyWaiters(k string, status store.TurnStatus) {
|
|
s.mu.Lock()
|
|
ws := s.waiters[k]
|
|
delete(s.waiters, k)
|
|
s.mu.Unlock()
|
|
for _, w := range ws {
|
|
w <- status
|
|
}
|
|
}
|
|
|
|
// childResult is what the parent's tool call returns for a finished sub-agent.
|
|
func (s *Service) childResult(ctx context.Context, a workspace.Actor, childID string, st store.TurnStatus) (string, error) {
|
|
switch st {
|
|
case store.TurnDone, store.TurnPaused:
|
|
return s.finalText(ctx, childID), nil
|
|
case store.TurnStopped:
|
|
return "", errors.New("the sub-agent was stopped")
|
|
default:
|
|
return "", errors.New("the sub-agent failed")
|
|
}
|
|
}
|
|
|
|
// finalText is the last thing the sub-agent said. A long run has more entries
|
|
// than one page, so it reads to the end.
|
|
func (s *Service) finalText(ctx context.Context, convID string) string {
|
|
const page = 1000
|
|
var after int64
|
|
last := ""
|
|
for {
|
|
entries, err := s.d.Store.Entries(ctx, convID, after, page)
|
|
if err != nil {
|
|
return last
|
|
}
|
|
for _, e := range entries {
|
|
if e.Role == session.RoleAssistant {
|
|
last = e.Text
|
|
}
|
|
}
|
|
if len(entries) < page {
|
|
return last
|
|
}
|
|
after = entries[len(entries)-1].Seq
|
|
}
|
|
}
|
|
|
|
// reportBack waits for a background sub-agent and tells the parent. A finished
|
|
// one reaches the parent as a message, which starts a turn when the parent is
|
|
// idle. One that was stopped or failed only raises the event.
|
|
func (s *Service) reportBack(a workspace.Actor, childID, title string, done <-chan store.TurnStatus) {
|
|
var st store.TurnStatus
|
|
select {
|
|
case st = <-done:
|
|
case <-s.closed:
|
|
return
|
|
}
|
|
ctx := context.Background()
|
|
parentKey := key(a.Principal, a.ChatID)
|
|
s.forgetChild(parentKey, childID)
|
|
s.bus.Publish(parentKey, session.Event{Kind: session.KindSubAgentDone, IDs: []string{childID}, Text: title})
|
|
if !st.Finished() {
|
|
return
|
|
}
|
|
msg := fmt.Sprintf("[Sub-agent finished: %s]\n%s", title, s.finalText(ctx, childID))
|
|
if _, err := s.send(ctx, a.Principal, a.ChatID, session.Message{Text: msg}); err != nil {
|
|
s.log.Warn("studio: could not tell the parent that a sub-agent finished", "chat", a.ChatID, "error", err)
|
|
}
|
|
}
|
|
|
|
// stopChildren stops every running sub-agent of a conversation, wherever its
|
|
// turn runs: the store knows the children, and Stop reaches a turn on another
|
|
// frontend over the bus.
|
|
func (s *Service) stopChildren(p, conv string) {
|
|
go func() {
|
|
ctx := context.Background()
|
|
kids, err := s.runningChildren(ctx, p, conv)
|
|
if err != nil {
|
|
s.log.Warn("studio: could not list the sub-agents to stop", "chat", conv, "error", err)
|
|
return
|
|
}
|
|
for _, k := range kids {
|
|
if err := s.Stop(ctx, p, k.ID); err != nil {
|
|
s.log.Warn("studio: could not stop a sub-agent", "chat", k.ID, "error", err)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return session.CapText(s, n) + "…"
|
|
}
|
|
|
|
// hasTool says whether the list has a tool of that name.
|
|
func hasTool(tools []session.Tool, name string) bool {
|
|
return slices.ContainsFunc(tools, func(t session.Tool) bool { return t.Name == name })
|
|
}
|