--- /dev/null
+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
+}
--- /dev/null
+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))
+}
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(),
}
}
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 {
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},
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"])
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,
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),
}
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"`
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{
"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:".
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
)
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")
}
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})