Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Before marking work as complete:
- When adding new features to the config:
- Update `./agent-schema.json` accordingly
- Create an example YAML that demonstrates the new feature
- Some config flags (`inject_memories`, `cache_response`) are wired by the runtime as auto-injected hooks rather than through `pkg/hooks/builtins`. Look in `pkg/runtime/inject_memories.go` and `pkg/runtime/cache.go` for examples.

# Git Practices

Expand Down
23 changes: 21 additions & 2 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
},
"runtime": {
"$ref": "#/definitions/RuntimeDefaults",
"description": "Execution-time defaults the agent author wants applied. Values act as defaults only explicit CLI flags or user-config settings always win."
"description": "Execution-time defaults the agent author wants applied. Values act as defaults only \u2014 explicit CLI flags or user-config settings always win."
}
},
"additionalProperties": false,
Expand Down Expand Up @@ -614,6 +614,25 @@
"type": "string"
}
},
"inject_memories": {
"type": "boolean",
"default": false,
"description": "Opt the agent into automatic memory retrieval at the start of every turn. When true, the runtime fetches relevant memories from the agent's configured memory toolset and injects them into the conversation as a transient system message (never persisted). Requires a memory toolset to be configured on the agent; otherwise the runtime emits a warning and the hook is a no-op."
},
"max_inject_memories": {
"type": "integer",
"minimum": 0,
"default": 10,
"description": "Maximum number of memories injected per turn. 0 means use the default (10)."
},
"inject_memories_strategy": {
"type": "string",
"enum": [
"local"
],
"default": "local",
"description": "Retrieval strategy. 'local' scores memories with an in-process BM25 ranker against the latest user message (cheap, deterministic, no extra model call). Note: an 'llm' strategy is planned for a future release."
},
"commands": {
"description": "Named prompts for /commands. Supports simple string format or advanced object format with description and instruction.",
"oneOf": [
Expand Down Expand Up @@ -1586,7 +1605,7 @@
"properties": {
"sandbox": {
"type": "boolean",
"description": "When true, run the agent inside a Docker sandbox by default equivalent to passing --sandbox on the command line. An explicit --sandbox=false on the CLI still wins."
"description": "When true, run the agent inside a Docker sandbox by default \u2014 equivalent to passing --sandbox on the command line. An explicit --sandbox=false on the CLI still wins."
},
"network_allowlist": {
"type": "array",
Expand Down
44 changes: 44 additions & 0 deletions docs/configuration/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ agents:
add_date: boolean # Optional: add date to context
add_environment_info: boolean # Optional: add env info to context
add_prompt_files: [list] # Optional: include additional prompt files
inject_memories: boolean # Optional: inject relevant memories at turn start
max_inject_memories: int # Optional: cap on injected memories (default: 10)
inject_memories_strategy: string # Optional: retrieval strategy, "local" (default)
add_description_parameter: bool # Optional: add description to tool schema
redact_secrets: boolean # Optional: scrub detected secrets out of tool args, outgoing chat messages, and tool output
code_mode_tools: boolean # Optional: enable code mode tool format
Expand Down Expand Up @@ -85,6 +88,9 @@ agents:
| `add_date` | boolean | ✗ | When `true`, injects the current date into the agent's context. |
| `add_environment_info` | boolean | ✗ | When `true`, injects working directory, OS, CPU architecture, and git info into context. |
| `add_prompt_files` | array | ✗ | List of file paths whose contents are appended to the system prompt. Useful for including coding standards, guidelines, or additional context. |
| `inject_memories` | boolean | ✗ | When `true`, the runtime fetches the most relevant stored memories at the start of every turn and injects them as a transient system message. Requires a `memory` toolset. See [Inject Memories](#inject-memories) below. |
| `max_inject_memories` | int | ✗ | Maximum number of memories injected per turn. Default: `10`. |
| `inject_memories_strategy` | string | ✗ | Retrieval strategy for `inject_memories`. `"local"` (default): in-process BM25 ranker against the user's message — cheap and deterministic. Note: an `"llm"` strategy is planned for a future release. |
| `add_description_parameter` | boolean | ✗ | When `true`, adds agent descriptions as a parameter in tool schemas. Helps with tool selection in multi-agent scenarios. |
| `redact_secrets` | boolean | ✗ | When `true`, scrubs detected secrets (API keys, tokens, private keys, etc.) out of tool-call arguments, outgoing chat messages, and tool output before they reach a tool, the model, or downstream consumers. See [Redacting Secrets](#redacting-secrets) below. |
| `code_mode_tools` | boolean | ✗ | When `true`, formats tool responses in a code-optimized format with structured output schemas. Useful for MCP gateway and programmatic access. |
Expand Down Expand Up @@ -151,6 +157,44 @@ Multiple processes can share the same `path:` cache file safely. Every `Store` t

`Lookup` watches the file's modification time and reloads the in-memory map when the file has advanced since its last load, so writes from a sibling process become visible without a restart. The `<path>.lock` sentinel file is created on first write and never deleted: removing it would let two processes lock different inodes and lose mutual exclusion.

## Inject Memories

When `inject_memories: true`, the runtime retrieves the most relevant memories from the agent's memory toolset at the start of every turn and injects them as a transient system message. The injection is **never persisted** to the session transcript — it is invisible in session replays by design, matching the behaviour of `add_date` and `add_environment_info`.

Requires a `memory` toolset on the same agent. If no memory toolset is configured, the runtime emits a warning and the hook is a no-op.

```yaml
agents:
assistant:
model: openai/gpt-4o-mini
instruction: |
You are a helpful assistant that remembers user preferences.
toolsets:
- type: memory
inject_memories: true
max_inject_memories: 5
inject_memories_strategy: local
```

### Retrieval strategies

| Strategy | Description |
| -------- | ----------- |
| `local` (default) | In-process BM25 ranker scores all stored memories against the user's message. Cheap, deterministic, no extra model call. Uses a per-turn snapshot cache; the cache is invalidated whenever a memory is written via the agent's memory tools. |

> **Note:** An `llm` strategy is planned for a future release.

### FAQ

**Why don't I see the injected memories in my session transcript?**
Injection is transient by design — matching the behaviour of `add_date` and `add_environment_info`. The memories are visible to the model during the turn but are not saved to the session file.

**When are memories refreshed from disk?**
The `local` strategy maintains an in-process snapshot cache per agent. The cache is refreshed whenever a memory write occurs through the agent's own memory tools (`add_memory`, `update_memory`, `delete_memory`). Each refresh issues a single `GetMemories` call to SQLite.

**What if an agent has multiple memory toolsets?**
Only the first memory toolset found is used for injection. Configure a single `type: memory` toolset per agent for predictable behaviour.

## Redacting Secrets

The `redact_secrets` flag is a single agent-level switch that scrubs accidentally leaked credentials, tokens, and private keys out of an agent's I/O. It wires up three complementary defenses:
Expand Down
4 changes: 4 additions & 0 deletions docs/tools/memory/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ Memories support an optional `category` field for organization and filtering. Co
</div>
<p>Memory is especially useful for long-running assistants that need to recall information across conversations — like coding preferences, project conventions, or context discovered during previous sessions.</p>
</div>

## See also

- [Inject Memories]({{ '/configuration/agents/#inject-memories' | relative_url }}) — automatically inject relevant memories at the start of each turn without the agent needing to call `get_memories` or `search_memories` explicitly.
28 changes: 28 additions & 0 deletions examples/inject_memories.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Demonstrates automatic memory injection at the start of every turn.
#
# When inject_memories is true, the runtime fetches the most relevant
# stored memories on each turn and injects them as a transient system
# message. The injection is never persisted to the session transcript.
#
# The "local" strategy (the only one available) uses an in-process BM25
# ranker to score all stored memories against the user's latest message.
# Cheap, deterministic, and requires no extra model call. A per-agent
# snapshot cache avoids a SQLite round-trip on every turn; the cache is
# invalidated whenever a memory is written through the agent's memory tools.
#
# Requires a memory toolset to be configured on the agent.

agents:
assistant:
model: openai/gpt-4o-mini
description: An assistant that remembers what you tell it
instruction: |
You are a helpful assistant. When the user shares a preference
or fact, store it with add_memory. Use stored memories to give
consistent, personalised answers.
toolsets:
- type: memory
path: ./inject_memories.db
inject_memories: true
max_inject_memories: 5
inject_memories_strategy: local
15 changes: 15 additions & 0 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ type Agent struct {
maxOldToolCallTokens int
numHistoryItems int
addPromptFiles []string
injectMemories bool
maxInjectMemories int
injectMemoriesStrategy string
tools []tools.Tool
commands types.Commands
harness *latest.HarnessConfig
Expand Down Expand Up @@ -116,6 +119,18 @@ func (a *Agent) AddPromptFiles() []string {
return a.addPromptFiles
}

func (a *Agent) InjectMemories() bool {
return a.injectMemories
}

func (a *Agent) MaxInjectMemories() int {
return a.maxInjectMemories
}

func (a *Agent) InjectMemoriesStrategy() string {
return a.injectMemoriesStrategy
}

// Description returns the agent's description
func (a *Agent) Description() string {
return a.description
Expand Down
8 changes: 8 additions & 0 deletions pkg/agent/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ func WithAddPromptFiles(addPromptFiles []string) Opt {
}
}

func WithInjectMemories(enabled bool, maxMemories int, strategy string) Opt {
return func(a *Agent) {
a.injectMemories = enabled
a.maxInjectMemories = maxMemories
a.injectMemoriesStrategy = strategy
}
}

func WithMaxIterations(maxIterations int) Opt {
return func(a *Agent) {
a.maxIterations = maxIterations
Expand Down
47 changes: 36 additions & 11 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ import (

const Version = "10"

const (
// InjectMemoriesStrategyLocal scores memories with an in-process BM25
// ranker against the latest user message. Cheap, deterministic, never
// calls the model.
InjectMemoriesStrategyLocal = "local"

// DefaultMaxInjectMemories is the default cap when MaxInjectMemories
// is unset or zero.
DefaultMaxInjectMemories = 10
)

// Config represents the entire configuration file
type Config struct {
Version string `json:"version,omitempty"`
Expand Down Expand Up @@ -446,17 +457,31 @@ type AgentConfig struct {
// Pointer (tri-state) so we can distinguish "unset" (nil → default
// on) from "explicitly disabled" (false). Use
// [AgentConfig.RedactSecretsEnabled] to read the effective value.
RedactSecrets *bool `json:"redact_secrets,omitempty"`
CodeModeTools bool `json:"code_mode_tools,omitempty"`
AddDescriptionParameter bool `json:"add_description_parameter,omitempty"`
MaxIterations int `json:"max_iterations,omitempty"`
MaxConsecutiveToolCalls int `json:"max_consecutive_tool_calls,omitempty"`
MaxOldToolCallTokens int `json:"max_old_tool_call_tokens,omitempty"`
NumHistoryItems int `json:"num_history_items,omitempty"`
AddPromptFiles []string `json:"add_prompt_files,omitempty" yaml:"add_prompt_files,omitempty"`
Commands types.Commands `json:"commands,omitempty"`
StructuredOutput *StructuredOutput `json:"structured_output,omitempty"`
Skills SkillsConfig `json:"skills,omitzero"`
RedactSecrets *bool `json:"redact_secrets,omitempty"`
CodeModeTools bool `json:"code_mode_tools,omitempty"`
AddDescriptionParameter bool `json:"add_description_parameter,omitempty"`
MaxIterations int `json:"max_iterations,omitempty"`
MaxConsecutiveToolCalls int `json:"max_consecutive_tool_calls,omitempty"`
MaxOldToolCallTokens int `json:"max_old_tool_call_tokens,omitempty"`
NumHistoryItems int `json:"num_history_items,omitempty"`
AddPromptFiles []string `json:"add_prompt_files,omitempty" yaml:"add_prompt_files,omitempty"`

// InjectMemories opts the agent into automatic memory retrieval at the
// start of every turn. The runtime fetches relevant memories from the
// agent's configured memory toolset and injects them as a transient
// system message (never persisted to the session).
//
// Requires a memory toolset to be configured on the agent.
// MaxInjectMemories caps the number of memories returned; defaults to
// DefaultMaxInjectMemories when zero. InjectMemoriesStrategy selects
// the retrieval strategy (see InjectMemoriesStrategy* constants).
InjectMemories bool `json:"inject_memories,omitempty" yaml:"inject_memories,omitempty"`
MaxInjectMemories int `json:"max_inject_memories,omitempty" yaml:"max_inject_memories,omitempty"`
InjectMemoriesStrategy string `json:"inject_memories_strategy,omitempty" yaml:"inject_memories_strategy,omitempty"`

Commands types.Commands `json:"commands,omitempty"`
StructuredOutput *StructuredOutput `json:"structured_output,omitempty"`
Skills SkillsConfig `json:"skills,omitzero"`
// UseCommands and UseSkills reference reusable groups defined in the
// top-level Config.Commands / Config.Skills sections. The referenced
// groups are merged into Commands / Skills during config resolution;
Expand Down
30 changes: 30 additions & 0 deletions pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func (t *Config) Validate() error {
return err
}
}
if err := agent.validateInjectMemories(); err != nil {
return err
}
if agent.Hooks != nil {
if err := agent.Hooks.Validate(); err != nil {
return err
Expand Down Expand Up @@ -170,6 +173,33 @@ func (a *AgentConfig) validateHarness() error {
return nil
}

// validateInjectMemories validates the inject_memories family of fields.
func (a *AgentConfig) validateInjectMemories() error {
if !a.InjectMemories {
// The companion fields are tolerated when inject_memories is
// false — matches the convention for max_iterations et al.
return nil
}

switch a.InjectMemoriesStrategy {
case "", InjectMemoriesStrategyLocal:
// ok; "" is normalised to local at apply time.
default:
return fmt.Errorf("agent %q: inject_memories_strategy %q is invalid (expected %q)",
a.Name, a.InjectMemoriesStrategy, InjectMemoriesStrategyLocal)
}

if a.MaxInjectMemories < 0 {
return fmt.Errorf("agent %q: max_inject_memories must be >= 0 (got %d)",
a.Name, a.MaxInjectMemories)
}

// Toolset presence is not validated here — config validation has no
// toolset semantics. The runtime emits a warning and falls back to a
// no-op when the memory toolset is missing.
return nil
}

func (t *Toolset) validate() error {
// Attributes used on the wrong toolset type.
if len(t.Shell) > 0 && t.Type != "script" {
Expand Down
75 changes: 75 additions & 0 deletions pkg/config/latest/validate_inject_memories_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package latest

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidateInjectMemories(t *testing.T) {
t.Parallel()

tests := []struct {
name string
cfg AgentConfig
wantErr string
}{
{
name: "disabled with no companion fields is valid",
cfg: AgentConfig{Name: "agent"},
},
{
name: "disabled with strategy set is valid",
cfg: AgentConfig{Name: "agent", InjectMemoriesStrategy: InjectMemoriesStrategyLocal},
},
{
name: "disabled with max set is valid",
cfg: AgentConfig{Name: "agent", MaxInjectMemories: 5},
},
{
name: "enabled with empty strategy is valid (defaults to local)",
cfg: AgentConfig{Name: "agent", InjectMemories: true},
},
{
name: "enabled with local strategy is valid",
cfg: AgentConfig{Name: "agent", InjectMemories: true, InjectMemoriesStrategy: InjectMemoriesStrategyLocal},
},
{
name: "enabled with max_inject_memories zero is valid",
cfg: AgentConfig{Name: "agent", InjectMemories: true, MaxInjectMemories: 0},
},
{
name: "enabled with positive max is valid",
cfg: AgentConfig{Name: "agent", InjectMemories: true, MaxInjectMemories: 20},
},
{
name: "enabled with invalid strategy is rejected",
cfg: AgentConfig{Name: "myagent", InjectMemories: true, InjectMemoriesStrategy: "bogus"},
wantErr: `agent "myagent": inject_memories_strategy "bogus" is invalid`,
},
{
name: "enabled with negative max is rejected",
cfg: AgentConfig{Name: "myagent", InjectMemories: true, MaxInjectMemories: -1},
wantErr: `agent "myagent": max_inject_memories must be >= 0 (got -1)`,
},
{
name: "enabled with llm strategy is rejected (not yet shipped)",
cfg: AgentConfig{Name: "myagent", InjectMemories: true, InjectMemoriesStrategy: "llm"},
wantErr: `agent "myagent": inject_memories_strategy "llm" is invalid`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.cfg.validateInjectMemories()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}
5 changes: 4 additions & 1 deletion pkg/runtime/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func (r *LocalRuntime) buildHooksExecutors() {
})
cfg = applyAutoInjectors(cfg, r.autoInjectors)
cfg = applyCacheDefault(cfg, a)
cfg = applyInjectMemoriesDefault(cfg, a)
if cfg == nil {
continue
}
Expand Down Expand Up @@ -153,7 +154,9 @@ func (r *LocalRuntime) executeSessionStartHooks(ctx context.Context, sess *sessi
// contents of a prompt file the user might be editing mid-session.
func (r *LocalRuntime) executeTurnStartHooks(ctx context.Context, sess *session.Session, a *agent.Agent, events EventSink) []chat.Message {
return contextMessages(r.dispatchHook(ctx, a, hooks.EventTurnStart, &hooks.Input{
SessionID: sess.ID,
SessionID: sess.ID,
AgentName: a.Name(),
LastUserMessage: sess.GetLastUserMessageContent(),
}, events))
}

Expand Down
Loading
Loading