From: Anderson A. Giacomolli Date: Fri, 6 Nov 2026 22:05:03 +0000 (-0300) Subject: Give each agent a config and user instructions X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/4a605fa39078354654ae648e489c117c7a445197 Give each agent a config and user instructions 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 --- diff --git a/agent.go b/agent.go new file mode 100644 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 index 0000000..dabecd2 --- /dev/null +++ b/agent_test.go @@ -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)) +} diff --git a/compactor.go b/compactor.go index 16303b0..e899c09 100644 --- a/compactor.go +++ b/compactor.go @@ -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}, diff --git a/compactor_test.go b/compactor_test.go index 35e09eb..4573fdd 100644 --- a/compactor_test.go +++ b/compactor_test.go @@ -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, diff --git a/probe.go b/probe.go index bbce24f..6e0b81e 100644 --- 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"` diff --git a/probe_test.go b/probe_test.go index 0651281..f791084 100644 --- a/probe_test.go +++ b/probe_test.go @@ -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{ diff --git a/prompt.go b/prompt.go index dc0fb06..e48c5a6 100644 --- 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 diff --git a/prompt_test.go b/prompt_test.go index 3e66fde..4c88ad8 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -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})