]> git.giacomolli.uk - onechat/commitdiff
Give each agent a config and user instructions
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Fri, 6 Nov 2026 22:05:03 +0000 (19:05 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Fri, 6 Nov 2026 22:05:03 +0000 (19:05 -0300)
Each agent has its own directory with a config.json: the name, a random
ID, the model, the effort, and the working directory of its commands.
The first open writes the file with the defaults (OneChat, gpt-6.1-sol
at medium effort, the home directory) and a new 128-bit ID, and no later
open changes the ID. The prompt cache key comes from the ID, so a rename
keeps the prompt cache and two agents with one name do not share a key.
An unknown field is an error, and the ID must be there.

The user's instructions are in instructions.md in the agent's directory.
The prompt puts them at its end and gets back the clause of the
reference design that points to them. Without instructions the prompt
has neither, so it describes only what exists. The agent reads the file
on each call, and the compactor takes the prompt from the agent at each
build, so turns and compactions keep one cached prompt.

Issue: 5daf23e

agent.go [new file with mode: 0644]
agent_test.go [new file with mode: 0644]
compactor.go
compactor_test.go
probe.go
probe_test.go
prompt.go
prompt_test.go

diff --git a/agent.go b/agent.go
new file mode 100644 (file)
index 0000000..1b4873a
--- /dev/null
+++ b/agent.go
@@ -0,0 +1,176 @@
+package main
+
+import (
+       "bytes"
+       "crypto/rand"
+       "encoding/hex"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "os"
+       "path/filepath"
+       "strings"
+)
+
+const (
+       // instructionsFileName is the file of the user's instructions in the
+       // directory of an agent.
+       instructionsFileName = "instructions.md"
+
+       // agentIDBytes is the number of random bytes of an agent ID.
+       agentIDBytes = 16
+)
+
+// AgentConfig holds the settings of the config.json of an agent. ID is random,
+// 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.
+type AgentConfig struct {
+       Name    string `json:"name"`
+       ID      string `json:"id"`
+       Model   string `json:"model"`
+       Effort  string `json:"effort"`
+       Workdir string `json:"workdir"`
+}
+
+// Agent is one agent and its directory. The directory holds config.json, the
+// user's instructions in instructions.md, the chat, and the usage file.
+type Agent struct {
+       dir    string
+       config AgentConfig
+}
+
+// openAgent reads the config.json of the agent in dir. When the file does not
+// exist, it makes the directory and writes the file with the defaults and a new
+// ID. A missing field of an existing file takes the default, but the ID must be
+// there. An unknown field is an error, so a typo does not become a silent
+// default.
+func openAgent(dir string) (*Agent, error) {
+       err := os.MkdirAll(dir, 0o700)
+       if err != nil {
+               return nil, fmt.Errorf("create agent directory: %w", err)
+       }
+
+       path := filepath.Join(dir, "config.json")
+       data, err := os.ReadFile(path)
+       if errors.Is(err, os.ErrNotExist) {
+               return createAgent(dir)
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("read agent config: %w", err)
+       }
+
+       config, err := defaultAgentConfig()
+       if err != nil {
+               return nil, err
+       }
+
+       decoder := json.NewDecoder(bytes.NewReader(data))
+       decoder.DisallowUnknownFields()
+       err = decoder.Decode(&config)
+       if err == nil && !errors.Is(decoder.Decode(&struct{}{}), io.EOF) {
+               err = errors.New("data after the JSON object")
+       }
+
+       if err == nil {
+               err = config.validate()
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("%s: %w", path, err)
+       }
+
+       return &Agent{
+               dir:    dir,
+               config: config,
+       }, nil
+}
+
+// createAgent writes the config.json of a new agent in dir with the defaults
+// and a new ID.
+func createAgent(dir string) (*Agent, error) {
+       config, err := defaultAgentConfig()
+       if err != nil {
+               return nil, err
+       }
+
+       id := make([]byte, agentIDBytes)
+       _, err = rand.Read(id)
+       if err != nil {
+               return nil, fmt.Errorf("make agent ID: %w", err)
+       }
+
+       config.ID = hex.EncodeToString(id)
+       data, err := json.MarshalIndent(config, "", "  ")
+       if err != nil {
+               return nil, fmt.Errorf("encode agent config: %w", err)
+       }
+
+       err = writeFileAtomic(filepath.Join(dir, "config.json"), append(data, '\n'))
+       if err != nil {
+               return nil, fmt.Errorf("write agent config: %w", err)
+       }
+
+       return &Agent{
+               dir:    dir,
+               config: config,
+       }, nil
+}
+
+// defaultAgentConfig returns the settings of a new agent, without an ID. The
+// commands run in the home directory of the user.
+func defaultAgentConfig() (AgentConfig, error) {
+       home, err := os.UserHomeDir()
+       if err != nil {
+               return AgentConfig{}, fmt.Errorf("find working directory: %w", err)
+       }
+
+       return AgentConfig{
+               Name:    "OneChat",
+               Model:   "gpt-6.1-sol",
+               Effort:  "medium",
+               Workdir: home,
+       }, nil
+}
+
+// validate makes sure that each setting has a valid value.
+func (c AgentConfig) validate() error {
+       if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
+               return errors.New("name must be one line of text")
+       }
+
+       id, err := hex.DecodeString(c.ID)
+       if err != nil || len(id) != agentIDBytes {
+               return fmt.Errorf("id must be %d hexadecimal digits", 2*agentIDBytes)
+       }
+
+       if c.Model == "" || c.Effort == "" {
+               return errors.New("agent needs a model and an effort")
+       }
+
+       if !filepath.IsAbs(c.Workdir) {
+               return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
+       }
+
+       return nil
+}
+
+// CacheKey returns the prompt cache key of every model call of the agent.
+func (a *Agent) CacheKey() string {
+       return "onechat-" + a.config.ID
+}
+
+// Prompt returns the system prompt of the agent with the user's instructions
+// from instructions.md. It reads the file on each call, so a change applies at
+// the next call. A missing file means no instructions.
+func (a *Agent) Prompt() (string, error) {
+       data, err := os.ReadFile(filepath.Join(a.dir, instructionsFileName))
+       if err != nil && !errors.Is(err, os.ErrNotExist) {
+               return "", fmt.Errorf("read instructions: %w", err)
+       }
+
+       return prompt(a.config.Name, string(data)), nil
+}
diff --git a/agent_test.go b/agent_test.go
new file mode 100644 (file)
index 0000000..dabecd2
--- /dev/null
@@ -0,0 +1,118 @@
+package main
+
+import (
+       "encoding/json"
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestNewAgentKeepsItsIDAcrossOpens(t *testing.T) {
+       dir := filepath.Join(t.TempDir(), "agent")
+       agent, err := openAgent(dir)
+       require.NoError(t, err)
+       require.Len(t, agent.config.ID, 2*agentIDBytes)
+       require.Equal(t, "gpt-6.1-sol", agent.config.Model)
+       require.True(t, filepath.IsAbs(agent.config.Workdir))
+
+       again, err := openAgent(dir)
+       require.NoError(t, err)
+       require.Equal(t, agent.config, again.config)
+
+       info, err := os.Stat(dir)
+       require.NoError(t, err)
+       require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
+}
+
+func TestNewAgentsGetDifferentIDs(t *testing.T) {
+       first, err := openAgent(t.TempDir())
+       require.NoError(t, err)
+
+       second, err := openAgent(t.TempDir())
+       require.NoError(t, err)
+       require.Equal(t, first.config.Name, second.config.Name)
+       require.NotEqual(t, first.CacheKey(), second.CacheKey())
+}
+
+func TestRenamedAgentKeepsItsCacheKey(t *testing.T) {
+       dir := t.TempDir()
+       agent, err := openAgent(dir)
+       require.NoError(t, err)
+
+       key := agent.CacheKey()
+       require.Equal(t, "onechat-"+agent.config.ID, key)
+
+       config := agent.config
+       config.Name = "Lena"
+       writeAgentConfig(t, dir, config)
+
+       renamed, err := openAgent(dir)
+       require.NoError(t, err)
+       require.Equal(t, "Lena", renamed.config.Name)
+       require.Equal(t, key, renamed.CacheKey())
+}
+
+func TestAgentConfigErrors(t *testing.T) {
+       testCases := []struct {
+               name   string
+               config string
+               err    string
+       }{
+               {
+                       name:   "unknown field",
+                       config: `{"id": "00112233445566778899aabbccddeeff", "nmae": "Lena"}`,
+                       err:    `unknown field "nmae"`,
+               },
+               {
+                       name:   "missing id",
+                       config: `{"name": "Lena"}`,
+                       err:    "id must be 32 hexadecimal digits",
+               },
+               {
+                       name:   "relative workdir",
+                       config: `{"id": "00112233445566778899aabbccddeeff", "workdir": "src"}`,
+                       err:    `workdir "src" is not an absolute path`,
+               },
+               {
+                       name:   "name of two lines",
+                       config: `{"id": "00112233445566778899aabbccddeeff", "name": "Le\nna"}`,
+                       err:    "name must be one line of text",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       dir := t.TempDir()
+                       require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(tc.config), 0o600))
+
+                       _, err := openAgent(dir)
+                       require.ErrorContains(t, err, tc.err)
+               })
+       }
+}
+
+func TestAgentPromptReadsTheInstructionsEachTime(t *testing.T) {
+       dir := t.TempDir()
+       agent, err := openAgent(dir)
+       require.NoError(t, err)
+
+       text, err := agent.Prompt()
+       require.NoError(t, err)
+       require.Equal(t, prompt(agent.config.Name, ""), text)
+
+       require.NoError(t, os.WriteFile(filepath.Join(dir, instructionsFileName), []byte("I am Lena.\n"), 0o600))
+       text, err = agent.Prompt()
+       require.NoError(t, err)
+       require.Equal(t, prompt(agent.config.Name, "I am Lena."), text)
+}
+
+// writeAgentConfig writes config as the config.json of the agent in dir.
+func writeAgentConfig(t *testing.T, dir string, config AgentConfig) {
+       t.Helper()
+
+       data, err := json.Marshal(config)
+       require.NoError(t, err)
+       require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), data, 0o600))
+}
index 16303b0c342cc7a45784754076501e53344918e2..e899c094ee58ad0036cdd7443fac632a9b903df9 100644 (file)
@@ -30,17 +30,18 @@ var ruler = strings.Repeat("-", nodeBytes)
 type Compactor struct {
        client       *Client
        model        ModelConfig
-       instructions string
+       instructions func() (string, error)
        tools        []Tool
 }
 
-// newCompactor returns the compactor of the agent name, which sends its calls
-// through client with model.
-func newCompactor(client *Client, name string, model ModelConfig) *Compactor {
+// newCompactor returns the compactor of an agent, which sends its calls through
+// client with model. instructions gives the system prompt of the agent at each
+// build, so a build sends the same prompt as the turns of that time.
+func newCompactor(client *Client, instructions func() (string, error), model ModelConfig) *Compactor {
        return &Compactor{
                client:       client,
                model:        model,
-               instructions: prompt(name),
+               instructions: instructions,
                tools:        agentTools(),
        }
 }
@@ -53,6 +54,11 @@ func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
                return "", err
        }
 
+       instructions, err := c.instructions()
+       if err != nil {
+               return "", fmt.Errorf("compact node (%d, %d): %w", task.Level, task.Index, err)
+       }
+
        var lines []string
        note := ""
        for len(lines) < compactionTries {
@@ -65,7 +71,7 @@ func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) {
                response, err = c.client.Create(ctx, Request{
                        Model:        c.model.Model,
                        Effort:       c.model.Effort,
-                       Instructions: c.instructions,
+                       Instructions: instructions,
                        Tools:        c.tools,
                        Input:        []any{userMessage(parts...)},
                        Node:         &nodeKey{task.Level, task.Index},
index 35e09eb28b4562530af8dae474fa13d62b49ad8f..4573fddbdc90107db0dbfdd1565e9fa0a66bf08e 100644 (file)
@@ -28,7 +28,7 @@ func TestCompactorSendsThePromptToolsAndTask(t *testing.T) {
        require.Equal(t, 1, server.Calls())
 
        body := server.Call(0).body
-       require.Equal(t, prompt("Ada"), body["instructions"])
+       require.Equal(t, prompt("Ada", ""), body["instructions"])
        require.Equal(t, "gpt-test", body["model"])
        require.Equal(t, map[string]any{"effort": "high"}, body["reasoning"])
        require.Equal(t, "agent-key", body["prompt_cache_key"])
@@ -198,12 +198,17 @@ func newTestCompactor(t *testing.T, server *fakeResponses) *Compactor {
 
        client := newClient(server.URL, apiKey(testKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl"))
 
-       return newCompactor(client, "Ada", ModelConfig{
+       return newCompactor(client, adaPrompt, ModelConfig{
                Model:  "gpt-test",
                Effort: "high",
        })
 }
 
+// adaPrompt returns the prompt of the agent Ada, without instructions.
+func adaPrompt() (string, error) {
+       return prompt("Ada", ""), nil
+}
+
 func leafTask() Task {
        return Task{
                Level:   0,
index bbce24f65541d455b05796a4d30c58fdf5903e6d..6e0b81eaa48188e87af08d973864f92cb8ec3034 100644 (file)
--- a/probe.go
+++ b/probe.go
@@ -91,7 +91,7 @@ func probe(ctx context.Context, args []string, out io.Writer) error {
        client := newClient(address, auth, "onechat-"+probeName, usagePath)
        client.LimitCalls(probeCallLimit)
        summarizer := slotSummarizer{
-               inner: newCompactor(client, probeName, config.Compactor),
+               inner: newCompactor(client, probePrompt, config.Compactor),
                slots: make(chan struct{}, builds),
        }
 
@@ -235,6 +235,12 @@ func (s slotSummarizer) Summarize(ctx context.Context, task Task) (string, error
        return s.inner.Summarize(ctx, task)
 }
 
+// probePrompt returns the system prompt of a probe run: the prompt of the agent
+// probeName, without instructions.
+func probePrompt() (string, error) {
+       return prompt(probeName, ""), nil
+}
+
 // probeEntry is one message of a probe log.
 type probeEntry struct {
        Kind string `json:"kind"`
index 0651281368c3e194ab15711986384c226fbc484e..f7910840927c7b18c0244e50c5ec8007d5036890 100644 (file)
@@ -419,7 +419,7 @@ func newTestProbeRun(t *testing.T, server *fakeResponses) *probeRun {
        root := t.TempDir()
        usagePath := filepath.Join(root, "usage.jsonl")
        client := newClient(server.URL, apiKey(testKey), "onechat-"+probeName, usagePath)
-       memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probeName, defaultConfig().Compactor))
+       memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probePrompt, defaultConfig().Compactor))
        require.NoError(t, err)
 
        return &probeRun{
index dc0fb06dcf8138cd3161574b1a92e2757db31d9c..e48c5a61b714350b61dc3bcca68b025c40b05383 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -5,15 +5,30 @@ import (
        "strings"
 )
 
-// promptName stands for the name of the agent in promptText.
-const promptName = "{name}"
+const (
+       // promptName stands for the name of the agent in promptText.
+       promptName = "{name}"
+
+       // taskSentence is the sentence of promptText that gets instructionsClause
+       // when the user has instructions.
+       taskSentence = "Do the user's tasks yourself, with your tools."
+
+       // instructionsClause points to the instructions of the user at the end
+       // of the prompt.
+       instructionsClause = ", following the user's instructions\nat the end of this prompt: who they are, how their files are organized and how\nthey want work done"
+
+       // instructionsHeading starts the instructions of the user after
+       // promptText.
+       instructionsHeading = "\n\n# The user's instructions\n\n"
+)
 
 // promptText is the system prompt of the reference design, nearly word for
 // word. The reference design names its own agent, so promptText has the name of
 // the agent and the kind reply in its place. It describes only what OneChat can
-// do, so it leaves out the spec text on images, subagents, computers,
-// background tasks, and the instructions of the user. It holds no date and no
-// state, so turns and compactions share its prompt cache.
+// do, so it leaves out the spec text on images, subagents, computers, and
+// background tasks. prompt adds the clause on the instructions of the user only
+// when there are instructions. It holds no date and no state, so turns and
+// compactions share its prompt cache.
 const promptText = `You are {name}, an AI agent that works for one user in a single chat that never
 ends. Each call to you is a turn or a compaction: the view below is followed by
 the user's new message, or by a task starting "Compaction:".
@@ -92,11 +107,20 @@ exactly. Tag each item with its kind ("user: ...; echo: ..."), and credit quoted
 text to its real author. Never make anything look further along than it was. If
 told the line is too long, shorten it. Non-ASCII characters cost 2-4 bytes.`
 
-// prompt returns the system prompt of the agent name. Turns and compactions
-// send the same prompt and tools, so compactions read the prompt cache of the
-// turns.
-func prompt(name string) string {
-       return strings.ReplaceAll(promptText, promptName, name)
+// prompt returns the system prompt of the agent name with the instructions of
+// the user at its end. Without instructions it has neither them nor the clause
+// that points to them. Turns and compactions send the same prompt and tools, so
+// compactions read the prompt cache of the turns.
+func prompt(name, instructions string) string {
+       text := strings.ReplaceAll(promptText, promptName, name)
+       instructions = strings.TrimSpace(instructions)
+       if instructions == "" {
+               return text
+       }
+
+       text = strings.Replace(text, taskSentence, strings.TrimSuffix(taskSentence, ".")+instructionsClause+".", 1)
+
+       return text + instructionsHeading + instructions
 }
 
 // agentTools returns the function tools of every call: zoom and date. A
index 3e66fde72cd3e2d2cf2b8f4c4cac54633ad584e4..4c88ad8a80d2683573ccc877523488691f6de5ac 100644 (file)
@@ -11,7 +11,7 @@ import (
 )
 
 func TestPromptNamesTheAgent(t *testing.T) {
-       text := prompt("Ada")
+       text := prompt("Ada", "")
        require.Contains(t, text, "You are Ada, an AI agent")
        require.Contains(t, text, "- reply: Ada's replies")
        require.Contains(t, text, "Goal: let Ada work later")
@@ -24,12 +24,28 @@ func TestPromptNamesTheAgent(t *testing.T) {
 }
 
 func TestPromptLeavesOutAbsentFeatures(t *testing.T) {
-       text := prompt("Ada")
+       text := prompt("Ada", "")
        for _, absent := range []string{"image", "ubagent", "computer", "device", `zoom("Name")`, "instructions at the end"} {
                require.NotContains(t, text, absent)
        }
 }
 
+func TestPromptEndsWithTheInstructions(t *testing.T) {
+       text := prompt("Ada", "\nI am Lena. Keep replies short.\n\n")
+       require.Contains(t, text, `Do the user's tasks yourself, with your tools, following the user's instructions
+at the end of this prompt: who they are, how their files are organized and how
+they want work done.`)
+       require.True(t, strings.HasSuffix(text, "\n\n# The user's instructions\n\nI am Lena. Keep replies short."))
+}
+
+func TestPromptWithoutInstructionsHasNoClause(t *testing.T) {
+       for _, instructions := range []string{"", " \n\t"} {
+               text := prompt("Ada", instructions)
+               require.Contains(t, text, "Do the user's tasks yourself, with your tools.\n")
+               require.NotContains(t, text, "instructions")
+       }
+}
+
 func TestAgentToolsAreStrictAndStable(t *testing.T) {
        tools := agentTools()
        require.Equal(t, []string{"zoom", "date"}, []string{tools[0].Name, tools[1].Name})