Skip to content
Merged
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
102 changes: 62 additions & 40 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,64 +282,86 @@ mission "expensive_research" {

The scheduler lives in `scheduler/` but its lifecycle (creation, config updates, shutdown) is managed by `cmd/serve.go`, not wsbridge. The wsbridge client receives a `ConcurrencyTracker` interface for enforcing `max_parallel` on all mission starts. The cron library used is `robfig/cron/v3`.

### Folders
### Memory + Scratchpad

Folders are sandboxed filesystem locations that agents access via the `file_list`,
`file_read`, `file_create`, `file_delete`, `file_search`, and `file_grep` tools.
The `folder` parameter is required on every tool call — there is no implicit
default.
Two kinds of file storage:

Three kinds exist, with strict naming rules:
- **Memory** — persistent storage that survives across runs. Declared with
a top-level `memory "name" { description = "..." }` (shared, multiple)
or a mission-scoped `memory { description = "..." }` (private to the
mission, at most one). The two block forms take the same fields. Every
memory is writable.
- **Scratchpad** — ephemeral per-run working space. A mission opts in
with `scratchpad = true`; auto-deleted 7 days after the run starts.
Nothing to configure.

| Kind | HCL | Registered name | Scope | Persistence |
|------|-----|-----------------|-------|-------------|
| Shared | top-level `shared_folder "name" { ... }` | user-chosen name | referenced by any mission via `folders = [shared_folders.name]` | persists |
| Mission | `folder { ... }` inside a mission | literal `"mission"` | one per mission | persists across runs |
| Run | `run_folder { ... }` inside a mission | literal `"run"` | one per mission per run | ephemeral; path is `<base>/<missionID>/` |
Agents access both kinds via the same `file_list`, `file_read`, `file_create`,
`file_delete`, `file_search`, `file_grep` tools. Each call takes a required
`slot` parameter naming which slot to operate in — there is no implicit
default. If a mission declares no `memory { ... }` and no `scratchpad = true`,
agents only see the shared memories listed in `memories = [...]`.

The names `"mission"` and `"run"` are reserved — `shared_folder` cannot use them
(enforced in `config/shared_folder.go`).
Squadron owns the on-disk paths; the HCL never accepts a `path` attribute:

| Kind | HCL | `slot` agents pass | On-disk path |
|------|-----|---------------------|--------------|
| Shared memory | top-level `memory "name" { ... }` | the HCL label | `<squadron_home>/memories/shared/<name>/` |
| Mission memory | `memory { ... }` inside a mission | literal `"memory"` | `<squadron_home>/memories/mission/<mission_name>/` |
| Mission scratchpad | `scratchpad = true` inside a mission | literal `"scratchpad"` | `<squadron_home>/scratchpads/<mission_name>/<instance_id>/` |

The slot names `"memory"` and `"scratchpad"` are reserved — a top-level
`memory "memory"` or `memory "scratchpad"` block is rejected.

The old DSL surfaces — `shared_folder` blocks, `folder` / `run_folder`
blocks, the `folders = ...` attribute — are **not** accepted. The parser
surfaces a clear error pointing at the new syntax. Path attributes anywhere
on a memory block are rejected too.

```hcl
shared_folder "reference" {
path = "./data/reference"
editable = false # default read-only
memory "reference" {
description = "Shared reference materials"
}

mission "analyze" {
folders = [shared_folders.reference]
memories = [memories.reference]

folder {
path = "./analyses"
memory {
description = "Cumulative reports — persists across runs"
}

run_folder {
base = "./runs" # optional, default ".squadron/runs"
description = "Per-run scratch"
cleanup = 7 # optional, auto-delete after N days; defaults to 7, set 0 to keep forever
}
scratchpad = true
}
```

**Implementation:**

- `config.SharedFolder`, `config.MissionFolder`, `config.MissionRunFolder` in
[config/shared_folder.go](config/shared_folder.go) and [config/mission.go](config/mission.go).
- Shared folders are parsed in Stage 1.5 (with `vars` context). The mission's
`folder` and `run_folder` blocks are parsed inside the mission block in Stage 5.
- [mission/folder_store.go](mission/folder_store.go) is the authoritative
runtime resolver. `buildFolderStore(mission, sharedFolders, missionID)` must be
called **after** the mission ID is assigned in `Runner.Run()`, because the run
folder path depends on it. There is no implicit default folder — every
tool call must name the folder explicitly.
- Run folders are materialized at `<base>/<missionID>/` and a sidecar
`.squadron-run.json` records `created_at` + `cleanup_days`.
- `mission.SweepExpiredRunFolders(base)` walks a base directory and deletes any
subfolder whose sidecar says it is past its cleanup deadline. It runs
opportunistically at the start of every `Runner.Run()` (for that mission's
base) and on an hourly ticker in `cmd/engage.go` (across every mission in the
current config).
- `config.Memory` (top-level shared) and `config.MissionMemory` (mission's
persistent) both live in [config/memory.go](config/memory.go); both have
a required `Description` field. On a parsed mission they land as
`Memories []string`, `Memory *MissionMemory`, and `Scratchpad bool`
(`config.Mission` in [config/mission.go](config/mission.go)).
- Top-level `memory "name"` blocks are parsed in Stage 1.5 (with `vars`
context); each mission's `memory { ... }` block and `scratchpad = bool`
attribute are parsed inside the mission block in Stage 5. The
`memories.NAME` HCL namespace exposes the shared-memory labels to
mission attributes.
- [mission/memory_store.go](mission/memory_store.go) is the authoritative
runtime resolver. Paths are derived from `paths.SquadronHome()`:
`SharedMemoryPath(name)`, `MissionMemoryPath(missionName)`, and
`MissionScratchpadPath(missionName, missionInstanceID)`.
`buildMemoryStore(mission, memories, missionInstanceID)` must be called
**after** the mission instance ID is assigned in `Runner.Run()` because
the scratchpad path depends on it. `MemoryStore.ResolvePath` returns
just `(string, error)` — there is no read-only mode at this layer.
- Each scratchpad directory gets a sidecar `.squadron-run.json` recording
`created_at` + `cleanup_days` (always `config.ScratchpadCleanupDays = 7`)
so the sweep can find expired ones.
- `mission.SweepExpiredScratchpads()` walks
`<squadron_home>/scratchpads/*/*`, deleting any per-run directory whose
sidecar is past its 7-day deadline. It runs opportunistically at the
start of every `Runner.Run()` (for missions with a scratchpad) and on
an hourly ticker in `cmd/engage.go`. No config lookup needed — the
filesystem layout is self-describing.

### Mission-Scoped Agents

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Honest tradeoffs on each comparison page.
- **[MCP host](https://docs.squadron.sh/config/mcp_host)** — run Squadron as an MCP server for Claude Desktop, Claude Code, Cursor, and other MCP clients.
- **[Schedules & triggers](https://docs.squadron.sh/missions/schedules)** — cron-based or HTTP-triggered missions, with per-mission concurrency limits.
- **[Budgets](https://docs.squadron.sh/missions/budgets)** — token + dollar caps per mission or per task.
- **[Folders](https://docs.squadron.sh/missions/folders)** — sandboxed filesystem locations agents can read/write, with shared, mission-scoped, and ephemeral run-scoped variants.
- **[Memory & Scratchpad](https://docs.squadron.sh/missions/folders)** — sandboxed filesystem locations agents can read/write. Persistent memory (shared with `memory "name"` or per-mission with `memory { }`) plus per-run `scratchpad = true` opt-in. Squadron owns the paths; scratchpads auto-clean after 7 days.
- **[Gateways](https://docs.squadron.sh/config/gateways)** — managed subprocesses that bridge Squadron to Slack, Discord, Teams, or any custom system via the Gateway SDK.
- **Encrypted vault** — secrets stored at rest with AES-256-GCM + Argon2id; passphrase in the OS keychain.
- **Single binary, no runtime deps.** Docker images on every release.
Expand Down
28 changes: 14 additions & 14 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ type Options struct {
SecretInfos []SecretInfo
// SecretValues contains actual secret values for tool call injection
SecretValues map[string]string
// FolderStore provides file folder access for the mission (optional)
FolderStore aitools.FolderStore
// MemoryStore provides file memory access for the mission (optional)
MemoryStore aitools.MemoryStore
// OnCompaction is called when context compaction occurs (optional, mission context only)
OnCompaction func(inputTokens int, tokenLimit int, messagesCompacted int, turnRetention int)
// OnSessionTurn is called after each LLM turn with telemetry data (optional)
Expand Down Expand Up @@ -178,14 +178,14 @@ func New(ctx context.Context, opts Options) (*Agent, error) {
}
}

// Add folder tools if FolderStore is available
if opts.FolderStore != nil {
tools["file_list"] = &aitools.FolderListTool{Store: opts.FolderStore}
tools["file_read"] = &aitools.FolderReadTool{Store: opts.FolderStore}
tools["file_create"] = &aitools.FolderCreateTool{Store: opts.FolderStore}
tools["file_delete"] = &aitools.FolderDeleteTool{Store: opts.FolderStore}
tools["file_search"] = &aitools.FolderSearchTool{Store: opts.FolderStore}
tools["file_grep"] = &aitools.FolderGrepTool{Store: opts.FolderStore}
// Add memory tools if MemoryStore is available
if opts.MemoryStore != nil {
tools["file_list"] = &aitools.MemoryListTool{Store: opts.MemoryStore}
tools["file_read"] = &aitools.MemoryReadTool{Store: opts.MemoryStore}
tools["file_create"] = &aitools.MemoryCreateTool{Store: opts.MemoryStore}
tools["file_delete"] = &aitools.MemoryDeleteTool{Store: opts.MemoryStore}
tools["file_search"] = &aitools.MemorySearchTool{Store: opts.MemoryStore}
tools["file_grep"] = &aitools.MemoryGrepTool{Store: opts.MemoryStore}
}

// Resolve skills and add load_skill tool
Expand Down Expand Up @@ -243,10 +243,10 @@ func New(ctx context.Context, opts Options) (*Agent, error) {
}
}

// Add folder context if FolderStore is available
if opts.FolderStore != nil {
if folderPrompt := prompts.FormatFolderContext(opts.FolderStore); folderPrompt != "" {
systemPrompts = append(systemPrompts, folderPrompt)
// Add memory context if MemoryStore is available
if opts.MemoryStore != nil {
if memoryPrompt := prompts.FormatMemoryContext(opts.MemoryStore); memoryPrompt != "" {
systemPrompts = append(systemPrompts, memoryPrompt)
}
}

Expand Down
8 changes: 4 additions & 4 deletions agent/agent_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type AgentManager struct {
cfg *config.Config
secretInfos []SecretInfo
secretValues map[string]string
folderStore aitools.FolderStore
memoryStore aitools.MemoryStore
sessionLogger SessionLogger
taskID string
missionID string
Expand All @@ -55,7 +55,7 @@ type AgentManagerConfig struct {
Config *config.Config
SecretInfos []SecretInfo
SecretValues map[string]string
FolderStore aitools.FolderStore
MemoryStore aitools.MemoryStore
SessionLogger SessionLogger
TaskID string
MissionID string
Expand Down Expand Up @@ -83,7 +83,7 @@ func NewAgentManager(cfg AgentManagerConfig) *AgentManager {
cfg: cfg.Config,
secretInfos: cfg.SecretInfos,
secretValues: cfg.SecretValues,
folderStore: cfg.FolderStore,
memoryStore: cfg.MemoryStore,
sessionLogger: cfg.SessionLogger,
taskID: cfg.TaskID,
missionID: cfg.MissionID,
Expand Down Expand Up @@ -278,7 +278,7 @@ func (m *AgentManager) createAgent(ctx context.Context, agentCfg *config.Agent)
DatasetStore: datasetStore,
SecretInfos: m.secretInfos,
SecretValues: m.secretValues,
FolderStore: m.folderStore,
MemoryStore: m.memoryStore,
OnCompaction: onCompaction,
OnSessionTurn: onSessionTurn,
PricingOverrides: m.pricingOverrides,
Expand Down
30 changes: 15 additions & 15 deletions agent/commander.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ type CommanderOptions struct {
// SequentialDataset contains all items for sequential iteration processing
// When set, the commander handles all items in a single session using dataset_next/submit_output tools
SequentialDataset []cty.Value
// FolderStore provides file folder access for the mission (optional)
FolderStore aitools.FolderStore
// MemoryStore provides file memory access for the mission (optional)
MemoryStore aitools.MemoryStore
// Compaction settings for the commander session (nil if disabled)
Compaction *CompactionConfig
// PruneOn triggers pruning when conversation reaches this many turns (0 = disabled)
Expand Down Expand Up @@ -342,7 +342,7 @@ type Commander struct {
agentMgr *AgentManager // Manages agent lifecycle (creation, session, resume)
pricingOverrides map[string]*llm.ModelPricing
subtasksSet bool // Whether set_subtasks has been called
folderStore aitools.FolderStore // Folder access for missions (nil if not configured)
memoryStore aitools.MemoryStore // Memory access for missions (nil if not configured)
compaction *CompactionConfig // Compaction settings (nil if disabled)
pruneOn int // Trigger pruning at this many turns (0 = disabled)
pruneTo int // Prune down to this many turns
Expand Down Expand Up @@ -482,17 +482,17 @@ func NewCommander(ctx context.Context, opts CommanderOptions) (*Commander, error
sup.tools["result_keys"] = &aitools.ResultKeysTool{Store: resultStore}
sup.tools["result_chunk"] = &aitools.ResultChunkTool{Store: resultStore}

// Add folder tools if FolderStore is available
if opts.FolderStore != nil {
sup.folderStore = opts.FolderStore
sup.tools["file_list"] = &aitools.FolderListTool{Store: opts.FolderStore}
sup.tools["file_read"] = &aitools.FolderReadTool{Store: opts.FolderStore}
sup.tools["file_create"] = &aitools.FolderCreateTool{Store: opts.FolderStore}
sup.tools["file_delete"] = &aitools.FolderDeleteTool{Store: opts.FolderStore}
sup.tools["file_search"] = &aitools.FolderSearchTool{Store: opts.FolderStore}
sup.tools["file_grep"] = &aitools.FolderGrepTool{Store: opts.FolderStore}
if folderPrompt := prompts.FormatFolderContext(opts.FolderStore); folderPrompt != "" {
session.AddSystemPrompt(folderPrompt)
// Add memory tools if MemoryStore is available
if opts.MemoryStore != nil {
sup.memoryStore = opts.MemoryStore
sup.tools["file_list"] = &aitools.MemoryListTool{Store: opts.MemoryStore}
sup.tools["file_read"] = &aitools.MemoryReadTool{Store: opts.MemoryStore}
sup.tools["file_create"] = &aitools.MemoryCreateTool{Store: opts.MemoryStore}
sup.tools["file_delete"] = &aitools.MemoryDeleteTool{Store: opts.MemoryStore}
sup.tools["file_search"] = &aitools.MemorySearchTool{Store: opts.MemoryStore}
sup.tools["file_grep"] = &aitools.MemoryGrepTool{Store: opts.MemoryStore}
if memoryPrompt := prompts.FormatMemoryContext(opts.MemoryStore); memoryPrompt != "" {
session.AddSystemPrompt(memoryPrompt)
}
}

Expand Down Expand Up @@ -725,7 +725,7 @@ func (s *Commander) SetToolCallbacks(callbacks *CommanderToolCallbacks, depSumma
Config: s.cfg,
SecretInfos: s.secretInfos,
SecretValues: s.secretValues,
FolderStore: s.folderStore,
MemoryStore: s.memoryStore,
SessionLogger: s.sessionLogger,
TaskID: s.callbacksTaskID,
MissionID: s.callbacksMissionID,
Expand Down
26 changes: 11 additions & 15 deletions agent/internal/prompts/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,38 +231,34 @@ func formatAgents(agents []AgentInfo) string {
return sb.String()
}

// FormatFolderContext builds a system prompt section describing available folders.
func FormatFolderContext(store aitools.FolderStore) string {
// FormatMemoryContext builds a system prompt section describing available memory slots.
func FormatMemoryContext(store aitools.MemoryStore) string {
if store == nil {
return ""
}
infos := store.FolderInfos()
infos := store.MemoryInfos()
if len(infos) == 0 {
return ""
}

var sb strings.Builder
sb.WriteString("## Available Folders\n\n")
sb.WriteString("You have access to file folders via the file_list, file_read, file_create, file_delete, file_search, and file_grep tools.\n")
sb.WriteString("The `folder` parameter is required on every call — pick one of the names below.\n\n")
sb.WriteString("## Available Slots\n\n")
sb.WriteString("You have access to file storage slots via the file_list, file_read, file_create, file_delete, file_search, and file_grep tools.\n")
sb.WriteString("The `slot` parameter is required on every call — pick one of the slot names below.\n\n")

for _, info := range infos {
access := "read-only"
if info.Writable {
access = "read/write"
}
label := ""
switch info.Name {
case aitools.MissionFolderName:
label = " (persistent mission folder — survives across runs)"
case aitools.RunFolderName:
label = " (per-run folder — fresh directory for this mission run)"
case aitools.MemorySlotName:
label = " (persistent mission memory — survives across runs)"
case aitools.ScratchpadSlotName:
label = " (ephemeral per-run scratchpad — fresh for this mission run)"
}
desc := ""
if info.Description != "" {
desc = " — " + info.Description
}
sb.WriteString(fmt.Sprintf("- **%s**%s%s (%s)\n", info.Name, label, desc, access))
sb.WriteString(fmt.Sprintf("- **%s**%s%s\n", info.Name, label, desc))
}

return sb.String()
Expand Down
Loading
Loading