]> git.giacomolli.uk - onechat/commitdiff
Define child agent areas and reporting rules
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 04:27:07 +0000 (01:27 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 04:49:06 +0000 (01:49 -0300)
Child configuration names its parent and responsibility together. Its
prompt limits work to that area, reports only what needs attention,
allows an empty report, and requires drafts instead of WhatsApp sends.
Each child reads its own instructions file at each call.

agent.go
agent_test.go
prompt.go

index 0d97cdf4af176d51fcde931e38d7288f003be1ca..790e18be79c788140a6bf1944aba4e3c90c459ef 100644 (file)
--- a/agent.go
+++ b/agent.go
@@ -96,6 +96,10 @@ func (a *Agent) Prompt() (string, error) {
                return "", fmt.Errorf("find instructions path: %w", err)
        }
 
                return "", fmt.Errorf("find instructions path: %w", err)
        }
 
+       if a.config.Parent != "" {
+               return promptChild(a.config.Name, a.config.Parent, a.config.Responsibility, path, instructions), nil
+       }
+
        return prompt(a.config.Name, path, instructions), nil
 }
 
        return prompt(a.config.Name, path, instructions), nil
 }
 
@@ -125,13 +129,15 @@ func (a *Agent) instructions() (string, error) {
 // made once when the file is made, and never changes. The cache key comes from
 // the ID, so a rename keeps the prompt cache and two agents with one name do
 // not share a key. Workdir is the directory where the commands of the agent
 // made once when the file is made, and never changes. The cache key comes from
 // the ID, so a rename keeps the prompt cache and two agents with one name do
 // not share a key. Workdir is the directory where the commands of the agent
-// run.
+// run. Responsibility and Parent are set together for a child agent.
 type AgentConfig struct {
 type AgentConfig struct {
-       Name    string `json:"name"`
-       ID      string `json:"id"`
-       Model   string `json:"model"`
-       Effort  string `json:"effort"`
-       Workdir string `json:"workdir"`
+       Name           string `json:"name"`
+       ID             string `json:"id"`
+       Model          string `json:"model"`
+       Effort         string `json:"effort"`
+       Workdir        string `json:"workdir"`
+       Responsibility string `json:"responsibility,omitempty"`
+       Parent         string `json:"parent,omitempty"`
 }
 
 // validate makes sure that each setting has a valid value.
 }
 
 // validate makes sure that each setting has a valid value.
@@ -149,6 +155,14 @@ func (c AgentConfig) validate() error {
                return errors.New("agent needs a model and an effort")
        }
 
                return errors.New("agent needs a model and an effort")
        }
 
+       if (strings.TrimSpace(c.Responsibility) == "") != (c.Parent == "") {
+               return errors.New("a child needs both responsibility and parent")
+       }
+
+       if strings.ContainsAny(c.Parent, "\r\n") {
+               return errors.New("parent must be one line of text")
+       }
+
        if !filepath.IsAbs(c.Workdir) {
                return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
        }
        if !filepath.IsAbs(c.Workdir) {
                return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
        }
index be74b402186a1a806eeb02e2faa59c446990deb4..8b6775f957cc2088c796b2140e48e58c7f2b099d 100644 (file)
@@ -4,6 +4,7 @@ import (
        "encoding/json"
        "os"
        "path/filepath"
        "encoding/json"
        "os"
        "path/filepath"
+       "strings"
        "testing"
 
        "github.com/stretchr/testify/require"
        "testing"
 
        "github.com/stretchr/testify/require"
@@ -126,6 +127,66 @@ func TestAgentPromptUsesAnAbsoluteInstructionsPath(t *testing.T) {
        require.Contains(t, text, "The user's instructions live in the file "+filepath.Join(dir, agentInstructionsFileName)+",")
 }
 
        require.Contains(t, text, "The user's instructions live in the file "+filepath.Join(dir, agentInstructionsFileName)+",")
 }
 
+func TestChildPromptDefinesItsAreaAndReports(t *testing.T) {
+       dir := t.TempDir()
+       agent, err := openAgent(dir)
+       require.NoError(t, err)
+
+       config := agent.config
+       config.Name = "Garden"
+       config.Parent = "OneChat"
+       config.Responsibility = "Keep the garden plans and decisions."
+       agentWriteTestConfig(t, dir, config)
+       require.NoError(t, os.WriteFile(filepath.Join(dir, agentInstructionsFileName), []byte("Ask before editing plans."), 0o600))
+
+       agent, err = openAgent(dir)
+       require.NoError(t, err)
+
+       text, err := agent.Prompt()
+       require.NoError(t, err)
+       require.Contains(t, text, "You are Garden, a child agent of OneChat.")
+       require.Contains(t, text, "You are responsible for: Keep the garden plans and decisions.")
+       require.Contains(t, text, "Work only in this area.")
+       require.Contains(t, text, "Report to OneChat only what needs the parent or the")
+       require.Contains(t, text, "End your turn with an empty reply when nothing needs the parent or the user.")
+       require.Contains(t, text, "Send nothing to WhatsApp. Draft replies in your report instead")
+       require.Contains(t, text, filepath.Join(dir, agentInstructionsFileName))
+       require.True(t, strings.HasSuffix(text, "Ask before editing plans."))
+       require.NotContains(t, text, "You act again only when the user writes.")
+}
+
+func TestChildConfigNeedsAParentAndAnArea(t *testing.T) {
+       testCases := []struct {
+               name           string
+               parent         string
+               responsibility string
+       }{
+               {
+                       name:   "a parent needs an area",
+                       parent: "OneChat",
+               },
+               {
+                       name:           "an area needs a parent",
+                       responsibility: "Keep the garden plans.",
+               },
+       }
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       dir := t.TempDir()
+                       agent, err := openAgent(dir)
+                       require.NoError(t, err)
+
+                       config := agent.config
+                       config.Parent = tc.parent
+                       config.Responsibility = tc.responsibility
+                       agentWriteTestConfig(t, dir, config)
+
+                       _, err = openAgent(dir)
+                       require.ErrorContains(t, err, "a child needs both responsibility and parent")
+               })
+       }
+}
+
 // agentWriteTestConfig writes config as the config.json of the agent in dir.
 func agentWriteTestConfig(t *testing.T, dir string, config AgentConfig) {
        t.Helper()
 // agentWriteTestConfig writes config as the config.json of the agent in dir.
 func agentWriteTestConfig(t *testing.T, dir string, config AgentConfig) {
        t.Helper()
index 694ad68cfd5777496739033b5af28b39f0535c7f..934491f3e642cf5b3842430009ab1de110f47f0f 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -192,6 +192,24 @@ func prompt(name, instructionsPath, instructions string) string {
        return promptWithInstructions(text, promptTaskSentence, promptInstructionsClause, instructions)
 }
 
        return promptWithInstructions(text, promptTaskSentence, promptInstructionsClause, instructions)
 }
 
+// promptChild returns the prompt of a child with its area and reporting rules.
+// Its instructions file belongs to the child, not to the parent.
+func promptChild(name, parent, responsibility, instructionsPath, instructions string) string {
+       text := prompt(name, instructionsPath, "")
+       text = strings.Replace(text, "You are "+name+", an AI agent that works for one user in a single chat that never\nends.", "You are "+name+", a child agent of "+parent+".", 1)
+       text = strings.Replace(text, promptTaskSentence, `Do the tasks of your area yourself, with your tools.
+You are responsible for: `+responsibility+`
+Work only in this area. Report to `+parent+` only what needs the parent or the
+user: a decision, a deadline, a problem, a question you cannot settle, or a
+message outside your area. Your final reply is your report to the parent.
+End your turn with an empty reply when nothing needs the parent or the user.
+Send nothing to WhatsApp. Draft replies in your report instead, so the parent
+can ask the user before sending.`, 1)
+       text = strings.Replace(text, "You act again only when the user writes.", "You act again when a message or your own schedule wakes you.", 1)
+
+       return promptWithInstructions(text, "Do the tasks of your area yourself, with your tools.", promptInstructionsClause, instructions)
+}
+
 // promptSubagent returns the system prompt of a subagent of the agent name,
 // with the instructions of the user at its end, as prompt does.
 func promptSubagent(name, instructions string) string {
 // promptSubagent returns the system prompt of a subagent of the agent name,
 // with the instructions of the user at its end, as prompt does.
 func promptSubagent(name, instructions string) string {