Skip to content

Commit e36129b

Browse files
committed
v0.15.0: sandbox serve flags, configurable max_concurrency, memory durability docs + cheat sheet
- Add --sandbox and --sandbox-* flags to odek serve (Web UI) - Add max_concurrency config key + ODEK_MAX_CONCURRENCY env var - Wire max_concurrency through builtinTools() to delegateTasksTool - Document go-vector RP durability model (ephemeral, stateless by design) - Create docs/CHEATSHEET.md (commands, config, memory, subagents, env vars) - Update landing page: fix stale cheatsheet commands, add CHEATSHEET.md link, update memory feature card - Update docs/MEMORY.md: Durability & Statefulness section, correct Cold Start
1 parent 496756f commit e36129b

14 files changed

Lines changed: 471 additions & 39 deletions

File tree

cmd/odek/main.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ func run(args []string) error {
716716

717717
// Sandbox setup
718718
var sandboxCleanup func() error
719-
tools := builtinTools(resolved.Dangerous, sm, nil)
719+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
720720

721721
// MCP server tools
722722
var mcpCleanup func()
@@ -991,14 +991,14 @@ func buildSandboxArgs(cfg sandboxConfig, containerName, workdir, image string) [
991991
return args
992992
}
993993

994-
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver) []odek.Tool {
994+
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver, maxConcurrency int) []odek.Tool {
995995
tools := []odek.Tool{
996996
&shellTool{
997997
dangerousConfig: dc,
998998
approver: approver,
999999
},
10001000
&delegateTasksTool{
1001-
maxConcurrency: 3,
1001+
maxConcurrency: maxConcurrency,
10021002
odekPath: os.Args[0],
10031003
timeout: 120 * time.Second,
10041004
},
@@ -1397,7 +1397,7 @@ func continueCmd(args []string) error {
13971397
"./.odek/skills",
13981398
)
13991399
}
1400-
tools := builtinTools(resolved.Dangerous, sm, nil)
1400+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
14011401
var sandboxCleanup func() error
14021402

14031403
// MCP server tools

cmd/odek/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func TestRun_NoAPIKey(t *testing.T) {
196196
}
197197

198198
func TestBuiltinTools(t *testing.T) {
199-
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
199+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
200200
if len(tools) == 0 {
201201
t.Fatal("builtinTools() returned empty slice")
202202
}

cmd/odek/mcp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Flags:
7979
}
8080

8181
// Build tools
82-
toolSet := builtinTools(resolved.Dangerous, sm, nil)
82+
toolSet := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
8383

8484
// MCP server tools — connect and discover before sandbox
8585
var mcpCleanup func()

cmd/odek/repl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func replCmd(args []string) error {
7575
"./.odek/skills",
7676
)
7777
}
78-
tools := builtinTools(resolved.Dangerous, sm, nil)
78+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
7979
var sandboxCleanup func() error
8080

8181
// MCP server tools

cmd/odek/serve.go

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ func serveCmd(args []string) error {
3232
addr := "127.0.0.1:8080"
3333
openBrowser := false
3434

35+
// Sandbox CLI flags (nil pointers = not set)
36+
var sandbox *bool
37+
var sandboxReadonly *bool
38+
var sandboxImage, sandboxNetwork, sandboxMemory, sandboxCPUs, sandboxUser string
39+
3540
for i := 0; i < len(args); i++ {
3641
switch args[i] {
3742
case "--addr":
@@ -42,21 +47,51 @@ func serveCmd(args []string) error {
4247
case "--open":
4348
openBrowser = true
4449
case "--help", "-h":
45-
fmt.Println(`Usage: odek serve [flags]
46-
47-
Start the odek web UI server.
48-
49-
Flags:
50-
--addr 127.0.0.1:8080 Listen address (default 127.0.0.1:8080)
51-
--open Open browser automatically
52-
--help, -h Show this help`)
50+
printServeHelp()
5351
return nil
52+
case "--sandbox":
53+
sandbox = boolPtr(true)
54+
case "--sandbox-image":
55+
i++
56+
if i < len(args) {
57+
sandboxImage = args[i]
58+
}
59+
case "--sandbox-network":
60+
i++
61+
if i < len(args) {
62+
sandboxNetwork = args[i]
63+
}
64+
case "--sandbox-readonly":
65+
sandboxReadonly = boolPtr(true)
66+
case "--sandbox-memory":
67+
i++
68+
if i < len(args) {
69+
sandboxMemory = args[i]
70+
}
71+
case "--sandbox-cpus":
72+
i++
73+
if i < len(args) {
74+
sandboxCPUs = args[i]
75+
}
76+
case "--sandbox-user":
77+
i++
78+
if i < len(args) {
79+
sandboxUser = args[i]
80+
}
5481
default:
5582
return fmt.Errorf("unknown flag %q for serve", args[i])
5683
}
5784
}
5885

59-
resolved := config.LoadConfig(config.CLIFlags{})
86+
resolved := config.LoadConfig(config.CLIFlags{
87+
Sandbox: sandbox,
88+
SandboxImage: sandboxImage,
89+
SandboxNetwork: sandboxNetwork,
90+
SandboxReadonly: sandboxReadonly,
91+
SandboxMemory: sandboxMemory,
92+
SandboxCPUs: sandboxCPUs,
93+
SandboxUser: sandboxUser,
94+
})
6095
systemMessage := resolved.System
6196
if systemMessage == "" {
6297
systemMessage = defaultSystem
@@ -102,6 +137,25 @@ Flags:
102137
return serveOnListener(listener, mux)
103138
}
104139

140+
// printServeHelp prints the serve command help text.
141+
func printServeHelp() {
142+
fmt.Println(`Usage: odek serve [flags]
143+
144+
Start the odek web UI server.
145+
146+
Flags:
147+
--addr 127.0.0.1:8080 Listen address (default 127.0.0.1:8080)
148+
--open Open browser automatically
149+
--sandbox Enable Docker sandbox for agent tool execution
150+
--sandbox-image image Docker image (default: alpine:latest or Dockerfile.odek)
151+
--sandbox-network net Docker network mode (default: bridge)
152+
--sandbox-readonly Mount working directory read-only
153+
--sandbox-memory limit Container memory limit (e.g. 512m, 2g)
154+
--sandbox-cpus limit Container CPU limit (e.g. 0.5, 2, 4)
155+
--sandbox-user user Container user (e.g. 1000:1000)
156+
--help, -h Show this help`)
157+
}
158+
105159
// serveOnListener serves the odek Web UI on a pre-created listener.
106160
// Extracted for testing — allows E2E tests to pass a listener on a known port.
107161
func serveOnListener(listener net.Listener, mux *http.ServeMux) error {
@@ -123,7 +177,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
123177
approver := newWSApprover(sendFn)
124178
resolved.Dangerous.Approver = approver
125179

126-
tools := builtinTools(resolved.Dangerous, sm, approver)
180+
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency)
127181
var sandboxCleanup func() error
128182

129183
// MCP server tools

cmd/odek/serve_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,158 @@ func waitForHTTP(t *testing.T, addr string) {
829829
t.Fatal("server not ready after 5s")
830830
}
831831

832+
// ── Flag Parsing Tests ───────────────────────────────────────────────────
833+
834+
// serveSandboxFlags returns the sandbox CLI flags parsed by serveCmd.
835+
// Used by tests to verify flag parsing without starting the server.
836+
func serveSandboxFlags(args []string) (addr string, open bool, sb *bool, sbr *bool, sbi, sbn, sbm, sbc, sbu string, err error) {
837+
addr = "127.0.0.1:8080"
838+
open = false
839+
840+
for i := 0; i < len(args); i++ {
841+
switch args[i] {
842+
case "--addr":
843+
i++
844+
if i < len(args) {
845+
addr = args[i]
846+
}
847+
case "--open":
848+
open = true
849+
case "--help", "-h":
850+
return
851+
case "--sandbox":
852+
sb = boolPtr(true)
853+
case "--sandbox-image":
854+
i++
855+
if i < len(args) {
856+
sbi = args[i]
857+
}
858+
case "--sandbox-network":
859+
i++
860+
if i < len(args) {
861+
sbn = args[i]
862+
}
863+
case "--sandbox-readonly":
864+
sbr = boolPtr(true)
865+
case "--sandbox-memory":
866+
i++
867+
if i < len(args) {
868+
sbm = args[i]
869+
}
870+
case "--sandbox-cpus":
871+
i++
872+
if i < len(args) {
873+
sbc = args[i]
874+
}
875+
case "--sandbox-user":
876+
i++
877+
if i < len(args) {
878+
sbu = args[i]
879+
}
880+
default:
881+
err = fmt.Errorf("unknown flag %q for serve", args[i])
882+
return
883+
}
884+
}
885+
return
886+
}
887+
888+
func TestServeCmd_Help(t *testing.T) {
889+
err := serveCmd([]string{"--help"})
890+
if err != nil {
891+
t.Fatalf("serveCmd --help: %v", err)
892+
}
893+
}
894+
895+
func TestServeCmd_SandboxFlags(t *testing.T) {
896+
tests := []struct {
897+
name string
898+
args []string
899+
check func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string)
900+
}{
901+
{
902+
name: "sandbox only",
903+
args: []string{"--sandbox"},
904+
check: func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string) {
905+
if sb == nil || !*sb {
906+
t.Error("--sandbox should be true")
907+
}
908+
},
909+
},
910+
{
911+
name: "sandbox-readonly",
912+
args: []string{"--sandbox", "--sandbox-readonly"},
913+
check: func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string) {
914+
if sbr == nil || !*sbr {
915+
t.Error("--sandbox-readonly should be true")
916+
}
917+
},
918+
},
919+
{
920+
name: "sandbox-image",
921+
args: []string{"--sandbox", "--sandbox-image", "ubuntu:22.04"},
922+
check: func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string) {
923+
if sbi != "ubuntu:22.04" {
924+
t.Errorf("sandbox-image = %q, want 'ubuntu:22.04'", sbi)
925+
}
926+
},
927+
},
928+
{
929+
name: "sandbox-network",
930+
args: []string{"--sandbox", "--sandbox-network", "none"},
931+
check: func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string) {
932+
if sbn != "none" {
933+
t.Errorf("sandbox-network = %q, want 'none'", sbn)
934+
}
935+
},
936+
},
937+
{
938+
name: "sandbox-memory",
939+
args: []string{"--sandbox", "--sandbox-memory", "512m"},
940+
check: func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string) {
941+
if sbm != "512m" {
942+
t.Errorf("sandbox-memory = %q, want '512m'", sbm)
943+
}
944+
},
945+
},
946+
{
947+
name: "sandbox-cpus",
948+
args: []string{"--sandbox", "--sandbox-cpus", "2"},
949+
check: func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string) {
950+
if sbc != "2" {
951+
t.Errorf("sandbox-cpus = %q, want '2'", sbc)
952+
}
953+
},
954+
},
955+
{
956+
name: "sandbox-user",
957+
args: []string{"--sandbox", "--sandbox-user", "1000:1000"},
958+
check: func(t *testing.T, addr string, open bool, sb, sbr *bool, sbi, sbn, sbm, sbc, sbu string) {
959+
if sbu != "1000:1000" {
960+
t.Errorf("sandbox-user = %q, want '1000:1000'", sbu)
961+
}
962+
},
963+
},
964+
}
965+
966+
for _, tt := range tests {
967+
t.Run(tt.name, func(t *testing.T) {
968+
addr, open, sb, sbr, sbi, sbn, sbm, sbc, sbu, err := serveSandboxFlags(tt.args)
969+
if err != nil {
970+
t.Fatalf("unexpected error: %v", err)
971+
}
972+
tt.check(t, addr, open, sb, sbr, sbi, sbn, sbm, sbc, sbu)
973+
})
974+
}
975+
}
976+
977+
func TestServeCmd_UnknownFlag(t *testing.T) {
978+
_, _, _, _, _, _, _, _, _, err := serveSandboxFlags([]string{"--bogus"})
979+
if err == nil {
980+
t.Fatal("expected error for unknown flag")
981+
}
982+
}
983+
832984
// TestServe_E2E_MultiToolCall verifies the WebUI event sequence when
833985
// the agent makes multiple tool calls in a single turn. This exercises
834986
// the same code path the WebUI uses for sub-agent delegation rendering.

cmd/odek/subagent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ func subagentCmd(args []string) error {
285285
"./.odek/skills",
286286
)
287287
}
288-
tools := builtinTools(resolved.Dangerous, sm, nil)
288+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency)
289289
var sandboxCleanup func() error
290290

291291
// MCP server tools

cmd/odek/subagent_contract_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ func TestSubagent_ExitCodeThree(t *testing.T) {
314314
// ── 4. delegate_tasks Tool Schema ───────────────────────────────────
315315

316316
func TestDelegateTasksTool_Exists(t *testing.T) {
317-
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
317+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
318318

319319
found := false
320320
for _, tool := range tools {
@@ -329,7 +329,7 @@ func TestDelegateTasksTool_Exists(t *testing.T) {
329329
}
330330

331331
func TestDelegateTasksTool_HasSchema(t *testing.T) {
332-
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
332+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
333333

334334
var tool odek.Tool
335335
for _, t2 := range tools {
@@ -418,7 +418,7 @@ func TestDelegateTasksTool_HasSchema(t *testing.T) {
418418
}
419419

420420
func TestDelegateTasksTool_Description(t *testing.T) {
421-
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
421+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3)
422422

423423
var tool odek.Tool
424424
for _, t2 := range tools {

cmd/odek/subagent_tool.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Example: decomposing "build a REST API" into "create user model", "create auth m
3535
3636
Key rules:
3737
- Each sub-agent has a fresh context (no parent history)
38-
- All sub-agents run in parallel (up to 3 at a time)
38+
- All sub-agents run in parallel (configurable via max_concurrency)
3939
- Each sub-agent has 120s to complete
4040
- Sub-agents can use all tools (shell, read/write files, etc.)
4141
- After all complete, synthesize the results into a cohesive answer

0 commit comments

Comments
 (0)