fix(agent): preserve swarm state across cwd changes
- commit
- 2830302af84ac11e06b8cd63d56ec22cac46fe11
- author
- brice · · 2026-08-04 04:45 UTC
- parent
- 26cdde5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +179 -56
M packages/agent/cli.go +55 -33
| @@ -1054,17 +1054,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1054 | 1054 | // | |
| 1055 | 1055 | // Steps, in order: | |
| 1056 | 1056 | // 1. resolve + validate the new path (~ expansion, abs/rel) | |
| 1057 | - | // 2. close the current session, flushing pending messages | |
| 1058 | - | // 3. mutate captured args.CWD + r.CWD so future buildAgent | |
| 1059 | - | // calls bind to the new cwd | |
| 1060 | - | // 4. re-root the shared sandbox («·«) so /jail follows the | |
| 1061 | - | // session into the new cwd instead of widening or silently | |
| 1062 | - | // dropping | |
| 1063 | - | // 5. rebuild the agent via buildAgent() so tools, AGENTS.md | |
| 1064 | - | // addendum, system prompt, sessions dir all bind correctly | |
| 1065 | - | // 6. open a fresh session in the new cwd's bucket | |
| 1066 | - | // 7. push the new state into the running Interactive | |
| 1067 | - | // 8. re-scope the swarm dashboard to the freshly-opened session | |
| 1057 | + | // 2. stage the candidate cwd, sandbox, and swarm state | |
| 1058 | + | // 3. rebuild the agent and allocate its new session | |
| 1059 | + | // 4. commit all new state together, closing the old session last | |
| 1060 | + | // 5. push the committed state into the running Interactive | |
| 1068 | 1061 | // | |
| 1069 | 1062 | // The /jail state is preserved verbatim: if the sandbox was locked | |
| 1070 | 1063 | // to the old cwd, it stays locked, just re-pointed at the new one. | |
| @@ -1104,21 +1097,39 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1104 | 1097 | return fmt.Errorf("no agent running; log in first") | |
| 1105 | 1098 | } | |
| 1106 | 1099 | ||
| 1107 | - | // Close the current session before we drop the reference. | |
| 1108 | - | // Per-message persistence keeps it current already; this is | |
| 1109 | - | // a defensive flush + final fsync via Close. | |
| 1110 | - | persistMu.Lock() | |
| 1111 | - | if sess != nil { | |
| 1112 | - | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | |
| 1113 | - | _ = sess.Close() | |
| 1114 | - | sess = nil | |
| 1100 | + | // Stage the candidate state while retaining a complete rollback | |
| 1101 | + | // snapshot. A failed rebuild or session allocation must leave the | |
| 1102 | + | // current agent, session, sandbox, and swarm root usable. | |
| 1103 | + | oldCWD := args.CWD | |
| 1104 | + | oldResolvedCWD := r.CWD | |
| 1105 | + | oldPermissionSet := args.PermissionSet | |
| 1106 | + | oldSwarmRoot := "" | |
| 1107 | + | if swarmMgr != nil { | |
| 1108 | + | oldSwarmRoot = swarmMgr.RepoRoot() | |
| 1109 | + | } | |
| 1110 | + | oldSandboxRoot := "" | |
| 1111 | + | oldSandboxLocked := false | |
| 1112 | + | if sharedSandbox != nil { | |
| 1113 | + | oldSandboxRoot = sharedSandbox.Root | |
| 1114 | + | oldSandboxLocked = sharedSandbox.Locked() | |
| 1115 | + | } | |
| 1116 | + | rollback := func() { | |
| 1117 | + | args.CWD = oldCWD | |
| 1118 | + | r.CWD = oldResolvedCWD | |
| 1119 | + | args.PermissionSet = oldPermissionSet | |
| 1120 | + | if swarmMgr != nil { | |
| 1121 | + | swarmMgr.SetRepoRoot(oldSwarmRoot) | |
| 1122 | + | } | |
| 1123 | + | if sharedSandbox != nil { | |
| 1124 | + | sharedSandbox.Root = oldSandboxRoot | |
| 1125 | + | if oldSandboxLocked { | |
| 1126 | + | sharedSandbox.Lock() | |
| 1127 | + | } else { | |
| 1128 | + | sharedSandbox.Unlock() | |
| 1129 | + | } | |
| 1130 | + | } | |
| 1115 | 1131 | } | |
| 1116 | - | sessBaselineMsgs = 0 | |
| 1117 | - | persistMu.Unlock() | |
| 1118 | 1132 | ||
| 1119 | - | // Mutate captured state so subsequent agent rebuilds and | |
| 1120 | - | // session opens see the new cwd. | |
| 1121 | - | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | |
| 1122 | 1133 | args.CWD = absPath | |
| 1123 | 1134 | r.CWD = absPath | |
| 1124 | 1135 | if swarmMgr != nil { | |
| @@ -1130,7 +1141,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1130 | 1141 | } | |
| 1131 | 1142 | if sharedSandbox != nil { | |
| 1132 | 1143 | sharedSandbox.Root = absPath | |
| 1133 | - | if wasJailed { | |
| 1144 | + | if oldSandboxLocked { | |
| 1134 | 1145 | sharedSandbox.Lock() | |
| 1135 | 1146 | } else { | |
| 1136 | 1147 | sharedSandbox.Unlock() | |
| @@ -1141,27 +1152,38 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1141 | 1152 | // re-bind to the new cwd. buildAgent() reads from args + r. | |
| 1142 | 1153 | newAg, newProvider, newModel, berr := buildAgent() | |
| 1143 | 1154 | if berr != nil { | |
| 1155 | + | rollback() | |
| 1144 | 1156 | return fmt.Errorf("rebuild agent: %v", berr) | |
| 1145 | 1157 | } | |
| 1146 | - | ag = newAg | |
| 1147 | 1158 | ||
| 1148 | 1159 | // Fresh session in the new cwd's bucket. We bypass | |
| 1149 | 1160 | // openOrCreateSession's --continue / --resume branches | |
| 1150 | 1161 | // because /cd's semantics are "start fresh here", matching | |
| 1151 | 1162 | // what relaunching `zot --cwd <path>` would do today. | |
| 1163 | + | var newSess *core.Session | |
| 1152 | 1164 | if !args.NoSess { | |
| 1153 | 1165 | newRoot := agentSessionsRoot(ZotHome(), args) | |
| 1154 | 1166 | core.PruneEmptySessions(newRoot, absPath) | |
| 1155 | - | newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version) | |
| 1167 | + | var serr error | |
| 1168 | + | newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version) | |
| 1156 | 1169 | if serr != nil { | |
| 1170 | + | rollback() | |
| 1157 | 1171 | return fmt.Errorf("open session in %s: %v", absPath, serr) | |
| 1158 | 1172 | } | |
| 1159 | - | persistMu.Lock() | |
| 1160 | - | sess = newSess | |
| 1161 | - | sessBaselineMsgs = 0 | |
| 1162 | - | persistMu.Unlock() | |
| 1163 | 1173 | } | |
| 1164 | 1174 | ||
| 1175 | + | // Commit only after every fallible operation has succeeded. Close | |
| 1176 | + | // the old session last so rollback never has to resurrect it. | |
| 1177 | + | persistMu.Lock() | |
| 1178 | + | if sess != nil { | |
| 1179 | + | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | |
| 1180 | + | _ = sess.Close() | |
| 1181 | + | } | |
| 1182 | + | sess = newSess | |
| 1183 | + | sessBaselineMsgs = 0 | |
| 1184 | + | persistMu.Unlock() | |
| 1185 | + | ag = newAg | |
| 1186 | + | ||
| 1165 | 1187 | // Push the new state into the running Interactive. | |
| 1166 | 1188 | if iv != nil { | |
| 1167 | 1189 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome())) | |
| @@ -1169,8 +1191,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1169 | 1191 | } | |
| 1170 | 1192 | ||
| 1171 | 1193 | // Re-scope the swarm dashboard to the new session. | |
| 1172 | - | if swarmMgr != nil && sess != nil { | |
| 1173 | - | swarmMgr.SetActiveSession(sess.ID) | |
| 1194 | + | if swarmMgr != nil && newSess != nil { | |
| 1195 | + | swarmMgr.SetActiveSession(newSess.ID) | |
| 1174 | 1196 | } | |
| 1175 | 1197 | return nil | |
| 1176 | 1198 | } | |
| @@ -1054,17 +1054,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1054 | // | 1054 | // | ||
| 1055 | // Steps, in order: | 1055 | // Steps, in order: | ||
| 1056 | // 1. resolve + validate the new path (~ expansion, abs/rel) | 1056 | // 1. resolve + validate the new path (~ expansion, abs/rel) | ||
| 1057 | - | // 2. close the current session, flushing pending messages | 1057 | + | // 2. stage the candidate cwd, sandbox, and swarm state |
| 1058 | - | // 3. mutate captured args.CWD + r.CWD so future buildAgent | 1058 | + | // 3. rebuild the agent and allocate its new session |
| 1059 | - | // calls bind to the new cwd | 1059 | + | // 4. commit all new state together, closing the old session last |
| 1060 | - | // 4. re-root the shared sandbox («·«) so /jail follows the | 1060 | + | // 5. push the committed state into the running Interactive |
| 1061 | - | // session into the new cwd instead of widening or silently | |||
| 1062 | - | // dropping | |||
| 1063 | - | // 5. rebuild the agent via buildAgent() so tools, AGENTS.md | |||
| 1064 | - | // addendum, system prompt, sessions dir all bind correctly | |||
| 1065 | - | // 6. open a fresh session in the new cwd's bucket | |||
| 1066 | - | // 7. push the new state into the running Interactive | |||
| 1067 | - | // 8. re-scope the swarm dashboard to the freshly-opened session | |||
| 1068 | // | 1061 | // | ||
| 1069 | // The /jail state is preserved verbatim: if the sandbox was locked | 1062 | // The /jail state is preserved verbatim: if the sandbox was locked | ||
| 1070 | // to the old cwd, it stays locked, just re-pointed at the new one. | 1063 | // to the old cwd, it stays locked, just re-pointed at the new one. | ||
| @@ -1104,21 +1097,39 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1104 | return fmt.Errorf("no agent running; log in first") | 1097 | return fmt.Errorf("no agent running; log in first") | ||
| 1105 | } | 1098 | } | ||
| 1106 | 1099 | ||||
| 1107 | - | // Close the current session before we drop the reference. | 1100 | + | // Stage the candidate state while retaining a complete rollback |
| 1108 | - | // Per-message persistence keeps it current already; this is | 1101 | + | // snapshot. A failed rebuild or session allocation must leave the |
| 1109 | - | // a defensive flush + final fsync via Close. | 1102 | + | // current agent, session, sandbox, and swarm root usable. |
| 1110 | - | persistMu.Lock() | 1103 | + | oldCWD := args.CWD |
| 1111 | - | if sess != nil { | 1104 | + | oldResolvedCWD := r.CWD |
| 1112 | - | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | 1105 | + | oldPermissionSet := args.PermissionSet |
| 1113 | - | _ = sess.Close() | 1106 | + | oldSwarmRoot := "" |
| 1114 | - | sess = nil | 1107 | + | if swarmMgr != nil { |
| 1108 | + | oldSwarmRoot = swarmMgr.RepoRoot() | |||
| 1109 | + | } | |||
| 1110 | + | oldSandboxRoot := "" | |||
| 1111 | + | oldSandboxLocked := false | |||
| 1112 | + | if sharedSandbox != nil { | |||
| 1113 | + | oldSandboxRoot = sharedSandbox.Root | |||
| 1114 | + | oldSandboxLocked = sharedSandbox.Locked() | |||
| 1115 | + | } | |||
| 1116 | + | rollback := func() { | |||
| 1117 | + | args.CWD = oldCWD | |||
| 1118 | + | r.CWD = oldResolvedCWD | |||
| 1119 | + | args.PermissionSet = oldPermissionSet | |||
| 1120 | + | if swarmMgr != nil { | |||
| 1121 | + | swarmMgr.SetRepoRoot(oldSwarmRoot) | |||
| 1122 | + | } | |||
| 1123 | + | if sharedSandbox != nil { | |||
| 1124 | + | sharedSandbox.Root = oldSandboxRoot | |||
| 1125 | + | if oldSandboxLocked { | |||
| 1126 | + | sharedSandbox.Lock() | |||
| 1127 | + | } else { | |||
| 1128 | + | sharedSandbox.Unlock() | |||
| 1129 | + | } | |||
| 1130 | + | } | |||
| 1115 | } | 1131 | } | ||
| 1116 | - | sessBaselineMsgs = 0 | |||
| 1117 | - | persistMu.Unlock() | |||
| 1118 | 1132 | ||||
| 1119 | - | // Mutate captured state so subsequent agent rebuilds and | |||
| 1120 | - | // session opens see the new cwd. | |||
| 1121 | - | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | |||
| 1122 | args.CWD = absPath | 1133 | args.CWD = absPath | ||
| 1123 | r.CWD = absPath | 1134 | r.CWD = absPath | ||
| 1124 | if swarmMgr != nil { | 1135 | if swarmMgr != nil { | ||
| @@ -1130,7 +1141,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1130 | } | 1141 | } | ||
| 1131 | if sharedSandbox != nil { | 1142 | if sharedSandbox != nil { | ||
| 1132 | sharedSandbox.Root = absPath | 1143 | sharedSandbox.Root = absPath | ||
| 1133 | - | if wasJailed { | 1144 | + | if oldSandboxLocked { |
| 1134 | sharedSandbox.Lock() | 1145 | sharedSandbox.Lock() | ||
| 1135 | } else { | 1146 | } else { | ||
| 1136 | sharedSandbox.Unlock() | 1147 | sharedSandbox.Unlock() | ||
| @@ -1141,27 +1152,38 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1141 | // re-bind to the new cwd. buildAgent() reads from args + r. | 1152 | // re-bind to the new cwd. buildAgent() reads from args + r. | ||
| 1142 | newAg, newProvider, newModel, berr := buildAgent() | 1153 | newAg, newProvider, newModel, berr := buildAgent() | ||
| 1143 | if berr != nil { | 1154 | if berr != nil { | ||
| 1155 | + | rollback() | |||
| 1144 | return fmt.Errorf("rebuild agent: %v", berr) | 1156 | return fmt.Errorf("rebuild agent: %v", berr) | ||
| 1145 | } | 1157 | } | ||
| 1146 | - | ag = newAg | |||
| 1147 | 1158 | ||||
| 1148 | // Fresh session in the new cwd's bucket. We bypass | 1159 | // Fresh session in the new cwd's bucket. We bypass | ||
| 1149 | // openOrCreateSession's --continue / --resume branches | 1160 | // openOrCreateSession's --continue / --resume branches | ||
| 1150 | // because /cd's semantics are "start fresh here", matching | 1161 | // because /cd's semantics are "start fresh here", matching | ||
| 1151 | // what relaunching `zot --cwd <path>` would do today. | 1162 | // what relaunching `zot --cwd <path>` would do today. | ||
| 1163 | + | var newSess *core.Session | |||
| 1152 | if !args.NoSess { | 1164 | if !args.NoSess { | ||
| 1153 | newRoot := agentSessionsRoot(ZotHome(), args) | 1165 | newRoot := agentSessionsRoot(ZotHome(), args) | ||
| 1154 | core.PruneEmptySessions(newRoot, absPath) | 1166 | core.PruneEmptySessions(newRoot, absPath) | ||
| 1155 | - | newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version) | 1167 | + | var serr error |
| 1168 | + | newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version) | |||
| 1156 | if serr != nil { | 1169 | if serr != nil { | ||
| 1170 | + | rollback() | |||
| 1157 | return fmt.Errorf("open session in %s: %v", absPath, serr) | 1171 | return fmt.Errorf("open session in %s: %v", absPath, serr) | ||
| 1158 | } | 1172 | } | ||
| 1159 | - | persistMu.Lock() | |||
| 1160 | - | sess = newSess | |||
| 1161 | - | sessBaselineMsgs = 0 | |||
| 1162 | - | persistMu.Unlock() | |||
| 1163 | } | 1173 | } | ||
| 1164 | 1174 | ||||
| 1175 | + | // Commit only after every fallible operation has succeeded. Close | |||
| 1176 | + | // the old session last so rollback never has to resurrect it. | |||
| 1177 | + | persistMu.Lock() | |||
| 1178 | + | if sess != nil { | |||
| 1179 | + | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | |||
| 1180 | + | _ = sess.Close() | |||
| 1181 | + | } | |||
| 1182 | + | sess = newSess | |||
| 1183 | + | sessBaselineMsgs = 0 | |||
| 1184 | + | persistMu.Unlock() | |||
| 1185 | + | ag = newAg | |||
| 1186 | + | ||||
| 1165 | // Push the new state into the running Interactive. | 1187 | // Push the new state into the running Interactive. | ||
| 1166 | if iv != nil { | 1188 | if iv != nil { | ||
| 1167 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome())) | 1189 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome())) | ||
| @@ -1169,8 +1191,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1169 | } | 1191 | } | ||
| 1170 | 1192 | ||||
| 1171 | // Re-scope the swarm dashboard to the new session. | 1193 | // Re-scope the swarm dashboard to the new session. | ||
| 1172 | - | if swarmMgr != nil && sess != nil { | 1194 | + | if swarmMgr != nil && newSess != nil { |
| 1173 | - | swarmMgr.SetActiveSession(sess.ID) | 1195 | + | swarmMgr.SetActiveSession(newSess.ID) |
| 1174 | } | 1196 | } | ||
| 1175 | return nil | 1197 | return nil | ||
| 1176 | } | 1198 | } | ||
M packages/agent/subagents/profiles.go +44 -6
| @@ -204,9 +204,16 @@ func load(path, source string) (*Profile, error) { | |||
| 204 | 204 | if name == "" { | |
| 205 | 205 | name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | |
| 206 | 206 | } | |
| 207 | - | thinking := unquote(values["thinking"]) | |
| 207 | + | thinking, err := parseThinkingValue(values["thinking"], "thinking") | |
| 208 | + | if err != nil { | |
| 209 | + | return nil, err | |
| 210 | + | } | |
| 211 | + | reasoning, err := parseThinkingValue(values["reasoning"], "reasoning") | |
| 212 | + | if err != nil { | |
| 213 | + | return nil, err | |
| 214 | + | } | |
| 208 | 215 | if thinking == "" { | |
| 209 | - | thinking = unquote(values["reasoning"]) | |
| 216 | + | thinking = reasoning | |
| 210 | 217 | } | |
| 211 | 218 | profile := &Profile{ | |
| 212 | 219 | Name: strings.TrimSpace(name), | |
| @@ -220,13 +227,27 @@ func load(path, source string) (*Profile, error) { | |||
| 220 | 227 | Source: source, | |
| 221 | 228 | } | |
| 222 | 229 | profile.SystemPromptMode = "append" | |
| 223 | - | if strings.EqualFold(unquote(values["systempromptmode"]), "replace") { | |
| 224 | - | profile.SystemPromptMode = "replace" | |
| 230 | + | if mode, ok := values["systempromptmode"]; ok { | |
| 231 | + | switch strings.ToLower(strings.TrimSpace(unquote(mode))) { | |
| 232 | + | case "", "append": | |
| 233 | + | case "replace": | |
| 234 | + | profile.SystemPromptMode = "replace" | |
| 235 | + | default: | |
| 236 | + | return nil, fmt.Errorf("systemPromptMode must be append or replace") | |
| 237 | + | } | |
| 225 | 238 | } | |
| 226 | - | if value, ok := parseOptionalBool(values["inheritprojectcontext"]); ok { | |
| 239 | + | if raw, ok := values["inheritprojectcontext"]; ok { | |
| 240 | + | value, parsed := parseOptionalBool(raw) | |
| 241 | + | if !parsed { | |
| 242 | + | return nil, fmt.Errorf("inheritProjectContext must be true or false") | |
| 243 | + | } | |
| 227 | 244 | profile.InheritProjectContext = &value | |
| 228 | 245 | } | |
| 229 | - | if value, ok := parseOptionalBool(values["inheritskills"]); ok { | |
| 246 | + | if raw, ok := values["inheritskills"]; ok { | |
| 247 | + | value, parsed := parseOptionalBool(raw) | |
| 248 | + | if !parsed { | |
| 249 | + | return nil, fmt.Errorf("inheritSkills must be true or false") | |
| 250 | + | } | |
| 230 | 251 | profile.InheritSkills = &value | |
| 231 | 252 | } | |
| 232 | 253 | return profile, nil | |
| @@ -323,6 +344,23 @@ func parseList(value string) []string { | |||
| 323 | 344 | return out | |
| 324 | 345 | } | |
| 325 | 346 | ||
| 347 | + | func parseThinkingValue(value, field string) (string, error) { | |
| 348 | + | switch strings.ToLower(strings.TrimSpace(unquote(value))) { | |
| 349 | + | case "": | |
| 350 | + | return "", nil | |
| 351 | + | case "off": | |
| 352 | + | return "off", nil | |
| 353 | + | case "minimal", "minimum": | |
| 354 | + | return "minimum", nil | |
| 355 | + | case "low", "medium", "high", "xhigh", "max": | |
| 356 | + | return strings.ToLower(strings.TrimSpace(unquote(value))), nil | |
| 357 | + | case "maximum": | |
| 358 | + | return "xhigh", nil | |
| 359 | + | default: | |
| 360 | + | return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field) | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 326 | 364 | func parseOptionalBool(value string) (bool, bool) { | |
| 327 | 365 | parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value))) | |
| 328 | 366 | return parsed, err == nil | |
| @@ -204,9 +204,16 @@ func load(path, source string) (*Profile, error) { | |||||
| 204 | if name == "" { | 204 | if name == "" { | ||
| 205 | name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | 205 | name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | ||
| 206 | } | 206 | } | ||
| 207 | - | thinking := unquote(values["thinking"]) | 207 | + | thinking, err := parseThinkingValue(values["thinking"], "thinking") |
| 208 | + | if err != nil { | |||
| 209 | + | return nil, err | |||
| 210 | + | } | |||
| 211 | + | reasoning, err := parseThinkingValue(values["reasoning"], "reasoning") | |||
| 212 | + | if err != nil { | |||
| 213 | + | return nil, err | |||
| 214 | + | } | |||
| 208 | if thinking == "" { | 215 | if thinking == "" { | ||
| 209 | - | thinking = unquote(values["reasoning"]) | 216 | + | thinking = reasoning |
| 210 | } | 217 | } | ||
| 211 | profile := &Profile{ | 218 | profile := &Profile{ | ||
| 212 | Name: strings.TrimSpace(name), | 219 | Name: strings.TrimSpace(name), | ||
| @@ -220,13 +227,27 @@ func load(path, source string) (*Profile, error) { | |||||
| 220 | Source: source, | 227 | Source: source, | ||
| 221 | } | 228 | } | ||
| 222 | profile.SystemPromptMode = "append" | 229 | profile.SystemPromptMode = "append" | ||
| 223 | - | if strings.EqualFold(unquote(values["systempromptmode"]), "replace") { | 230 | + | if mode, ok := values["systempromptmode"]; ok { |
| 224 | - | profile.SystemPromptMode = "replace" | 231 | + | switch strings.ToLower(strings.TrimSpace(unquote(mode))) { |
| 232 | + | case "", "append": | |||
| 233 | + | case "replace": | |||
| 234 | + | profile.SystemPromptMode = "replace" | |||
| 235 | + | default: | |||
| 236 | + | return nil, fmt.Errorf("systemPromptMode must be append or replace") | |||
| 237 | + | } | |||
| 225 | } | 238 | } | ||
| 226 | - | if value, ok := parseOptionalBool(values["inheritprojectcontext"]); ok { | 239 | + | if raw, ok := values["inheritprojectcontext"]; ok { |
| 240 | + | value, parsed := parseOptionalBool(raw) | |||
| 241 | + | if !parsed { | |||
| 242 | + | return nil, fmt.Errorf("inheritProjectContext must be true or false") | |||
| 243 | + | } | |||
| 227 | profile.InheritProjectContext = &value | 244 | profile.InheritProjectContext = &value | ||
| 228 | } | 245 | } | ||
| 229 | - | if value, ok := parseOptionalBool(values["inheritskills"]); ok { | 246 | + | if raw, ok := values["inheritskills"]; ok { |
| 247 | + | value, parsed := parseOptionalBool(raw) | |||
| 248 | + | if !parsed { | |||
| 249 | + | return nil, fmt.Errorf("inheritSkills must be true or false") | |||
| 250 | + | } | |||
| 230 | profile.InheritSkills = &value | 251 | profile.InheritSkills = &value | ||
| 231 | } | 252 | } | ||
| 232 | return profile, nil | 253 | return profile, nil | ||
| @@ -323,6 +344,23 @@ func parseList(value string) []string { | |||||
| 323 | return out | 344 | return out | ||
| 324 | } | 345 | } | ||
| 325 | 346 | ||||
| 347 | + | func parseThinkingValue(value, field string) (string, error) { | |||
| 348 | + | switch strings.ToLower(strings.TrimSpace(unquote(value))) { | |||
| 349 | + | case "": | |||
| 350 | + | return "", nil | |||
| 351 | + | case "off": | |||
| 352 | + | return "off", nil | |||
| 353 | + | case "minimal", "minimum": | |||
| 354 | + | return "minimum", nil | |||
| 355 | + | case "low", "medium", "high", "xhigh", "max": | |||
| 356 | + | return strings.ToLower(strings.TrimSpace(unquote(value))), nil | |||
| 357 | + | case "maximum": | |||
| 358 | + | return "xhigh", nil | |||
| 359 | + | default: | |||
| 360 | + | return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field) | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | ||||
| 326 | func parseOptionalBool(value string) (bool, bool) { | 364 | func parseOptionalBool(value string) (bool, bool) { | ||
| 327 | parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value))) | 365 | parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value))) | ||
| 328 | return parsed, err == nil | 366 | return parsed, err == nil | ||
M packages/agent/subagents/profiles_test.go +23 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "strings" | |
| @@ -99,6 +100,28 @@ No. | |||
| 99 | 100 | } | |
| 100 | 101 | } | |
| 101 | 102 | ||
| 103 | + | func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |
| 104 | + | cases := []struct { | |
| 105 | + | name string | |
| 106 | + | field string | |
| 107 | + | }{ | |
| 108 | + | {name: "invalid-thinking", field: "thinking: extreme"}, | |
| 109 | + | {name: "invalid-reasoning", field: "reasoning: extreme"}, | |
| 110 | + | {name: "invalid-prompt-mode", field: "systemPromptMode: merge"}, | |
| 111 | + | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | |
| 112 | + | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | |
| 113 | + | } | |
| 114 | + | for _, tc := range cases { | |
| 115 | + | t.Run(tc.name, func(t *testing.T) { | |
| 116 | + | path := filepath.Join(t.TempDir(), "profile.md") | |
| 117 | + | writeProfile(t, path, fmt.Sprintf("---\nname: test\n%s\n---\nInstructions.\n", tc.field)) | |
| 118 | + | if _, err := load(path, "test"); err == nil { | |
| 119 | + | t.Fatalf("load succeeded for invalid metadata %q", tc.field) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 102 | 125 | func TestProfileModelSelection(t *testing.T) { | |
| 103 | 126 | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | |
| 104 | 127 | provider, model := profile.ModelSelection() | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package subagents | 1 | package subagents | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "strings" | 7 | "strings" | ||
| @@ -99,6 +100,28 @@ No. | |||||
| 99 | } | 100 | } | ||
| 100 | } | 101 | } | ||
| 101 | 102 | ||||
| 103 | + | func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||
| 104 | + | cases := []struct { | |||
| 105 | + | name string | |||
| 106 | + | field string | |||
| 107 | + | }{ | |||
| 108 | + | {name: "invalid-thinking", field: "thinking: extreme"}, | |||
| 109 | + | {name: "invalid-reasoning", field: "reasoning: extreme"}, | |||
| 110 | + | {name: "invalid-prompt-mode", field: "systemPromptMode: merge"}, | |||
| 111 | + | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | |||
| 112 | + | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | |||
| 113 | + | } | |||
| 114 | + | for _, tc := range cases { | |||
| 115 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 116 | + | path := filepath.Join(t.TempDir(), "profile.md") | |||
| 117 | + | writeProfile(t, path, fmt.Sprintf("---\nname: test\n%s\n---\nInstructions.\n", tc.field)) | |||
| 118 | + | if _, err := load(path, "test"); err == nil { | |||
| 119 | + | t.Fatalf("load succeeded for invalid metadata %q", tc.field) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 102 | func TestProfileModelSelection(t *testing.T) { | 125 | func TestProfileModelSelection(t *testing.T) { | ||
| 103 | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | 126 | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | ||
| 104 | provider, model := profile.ModelSelection() | 127 | provider, model := profile.ModelSelection() | ||
M packages/agent/swarm/persist.go +2 -0
| @@ -353,6 +353,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 353 | 353 | Dir: existing.Dir, Started: existing.Started, | |
| 354 | 354 | Model: existing.Model, Provider: existing.Provider, | |
| 355 | 355 | Reasoning: existing.Reasoning, Subagent: existing.Subagent, | |
| 356 | + | SessionID: existing.SessionID, | |
| 356 | 357 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |
| 357 | 358 | SessionPath: existing.SessionPath, | |
| 358 | 359 | } | |
| @@ -366,6 +367,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 366 | 367 | Provider: m.Provider, | |
| 367 | 368 | Reasoning: m.Reasoning, | |
| 368 | 369 | Subagent: m.Subagent, | |
| 370 | + | SessionID: m.SessionID, | |
| 369 | 371 | InboxPath: m.InboxPath, | |
| 370 | 372 | EventLogPath: m.EventLogPath, | |
| 371 | 373 | SessionPath: m.SessionPath, | |
| @@ -353,6 +353,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 353 | Dir: existing.Dir, Started: existing.Started, | 353 | Dir: existing.Dir, Started: existing.Started, | ||
| 354 | Model: existing.Model, Provider: existing.Provider, | 354 | Model: existing.Model, Provider: existing.Provider, | ||
| 355 | Reasoning: existing.Reasoning, Subagent: existing.Subagent, | 355 | Reasoning: existing.Reasoning, Subagent: existing.Subagent, | ||
| 356 | + | SessionID: existing.SessionID, | |||
| 356 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | 357 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | ||
| 357 | SessionPath: existing.SessionPath, | 358 | SessionPath: existing.SessionPath, | ||
| 358 | } | 359 | } | ||
| @@ -366,6 +367,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 366 | Provider: m.Provider, | 367 | Provider: m.Provider, | ||
| 367 | Reasoning: m.Reasoning, | 368 | Reasoning: m.Reasoning, | ||
| 368 | Subagent: m.Subagent, | 369 | Subagent: m.Subagent, | ||
| 370 | + | SessionID: m.SessionID, | |||
| 369 | InboxPath: m.InboxPath, | 371 | InboxPath: m.InboxPath, | ||
| 370 | EventLogPath: m.EventLogPath, | 372 | EventLogPath: m.EventLogPath, | ||
| 371 | SessionPath: m.SessionPath, | 373 | SessionPath: m.SessionPath, | ||
M packages/agent/swarm/persist_test.go +25 -7
| @@ -536,6 +536,7 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 536 | 536 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 537 | 537 | }, | |
| 538 | 538 | }) | |
| 539 | + | f.SetActiveSession("host-session") | |
| 539 | 540 | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | |
| 540 | 541 | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | |
| 541 | 542 | Reasoning: "high", Subagent: "reviewer", | |
| @@ -543,8 +544,8 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 543 | 544 | if err != nil { | |
| 544 | 545 | t.Fatalf("spawn: %v", err) | |
| 545 | 546 | } | |
| 546 | - | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" { | |
| 547 | - | t.Fatalf("agent fields = (%q,%q,%q,%q); want model/provider/reasoning/profile", a.Model, a.Provider, a.Reasoning, a.Subagent) | |
| 547 | + | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" || a.SessionID != "host-session" { | |
| 548 | + | t.Fatalf("agent fields = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session", a.Model, a.Provider, a.Reasoning, a.Subagent, a.SessionID) | |
| 548 | 549 | } | |
| 549 | 550 | snap := a.Snapshot() | |
| 550 | 551 | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" { | |
| @@ -565,14 +566,19 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 565 | 566 | if err := json.Unmarshal(metaBytes, &got); err != nil { | |
| 566 | 567 | t.Fatal(err) | |
| 567 | 568 | } | |
| 568 | - | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" { | |
| 569 | - | t.Errorf("meta = (%q,%q,%q,%q); want model/provider/reasoning/profile persisted", got.Model, got.Provider, got.Reasoning, got.Subagent) | |
| 569 | + | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" || got.SessionID != "host-session" { | |
| 570 | + | t.Errorf("meta = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session persisted", got.Model, got.Provider, got.Reasoning, got.Subagent, got.SessionID) | |
| 570 | 571 | } | |
| 571 | 572 | ||
| 572 | 573 | // Reload in a fresh Swarm and confirm the detached agent still | |
| 573 | 574 | // carries the model/provider so Resume can route the child | |
| 574 | 575 | // subprocess back to the same model. | |
| 575 | - | g := New(Config{Root: root, RepoRoot: root}) | |
| 576 | + | g := New(Config{ | |
| 577 | + | Root: root, RepoRoot: root, | |
| 578 | + | NewRunner: func(a *Agent) Runner { | |
| 579 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |
| 580 | + | }, | |
| 581 | + | }) | |
| 576 | 582 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | |
| 577 | 583 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 578 | 584 | } | |
| @@ -580,9 +586,21 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 580 | 586 | if re == nil { | |
| 581 | 587 | t.Fatal("reloaded agent missing") | |
| 582 | 588 | } | |
| 583 | - | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" { | |
| 584 | - | t.Errorf("reloaded fields = (%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent) | |
| 589 | + | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" || re.SessionID != "host-session" { | |
| 590 | + | t.Errorf("reloaded fields = (%q,%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent, re.SessionID) | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | resumed, err := g.Resume(context.Background(), re.ID) | |
| 594 | + | if err != nil { | |
| 595 | + | t.Fatalf("resume: %v", err) | |
| 596 | + | } | |
| 597 | + | if resumed.Reasoning != "high" || resumed.Subagent != "reviewer" || resumed.SessionID != "host-session" { | |
| 598 | + | t.Errorf("resumed fields = (%q,%q,%q); want reasoning/profile/session preserved", resumed.Reasoning, resumed.Subagent, resumed.SessionID) | |
| 599 | + | } | |
| 600 | + | if err := g.Stop(resumed.ID); err != nil { | |
| 601 | + | t.Fatalf("stop resumed agent: %v", err) | |
| 585 | 602 | } | |
| 603 | + | resumed.Wait() | |
| 586 | 604 | } | |
| 587 | 605 | ||
| 588 | 606 | // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that | |
| @@ -536,6 +536,7 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 536 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | 536 | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | ||
| 537 | }, | 537 | }, | ||
| 538 | }) | 538 | }) | ||
| 539 | + | f.SetActiveSession("host-session") | |||
| 539 | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | 540 | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | ||
| 540 | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | 541 | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | ||
| 541 | Reasoning: "high", Subagent: "reviewer", | 542 | Reasoning: "high", Subagent: "reviewer", | ||
| @@ -543,8 +544,8 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 543 | if err != nil { | 544 | if err != nil { | ||
| 544 | t.Fatalf("spawn: %v", err) | 545 | t.Fatalf("spawn: %v", err) | ||
| 545 | } | 546 | } | ||
| 546 | - | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" { | 547 | + | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" || a.Reasoning != "high" || a.Subagent != "reviewer" || a.SessionID != "host-session" { |
| 547 | - | t.Fatalf("agent fields = (%q,%q,%q,%q); want model/provider/reasoning/profile", a.Model, a.Provider, a.Reasoning, a.Subagent) | 548 | + | t.Fatalf("agent fields = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session", a.Model, a.Provider, a.Reasoning, a.Subagent, a.SessionID) |
| 548 | } | 549 | } | ||
| 549 | snap := a.Snapshot() | 550 | snap := a.Snapshot() | ||
| 550 | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" { | 551 | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" || snap.Reasoning != "high" || snap.Subagent != "reviewer" { | ||
| @@ -565,14 +566,19 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 565 | if err := json.Unmarshal(metaBytes, &got); err != nil { | 566 | if err := json.Unmarshal(metaBytes, &got); err != nil { | ||
| 566 | t.Fatal(err) | 567 | t.Fatal(err) | ||
| 567 | } | 568 | } | ||
| 568 | - | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" { | 569 | + | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" || got.Reasoning != "high" || got.Subagent != "reviewer" || got.SessionID != "host-session" { |
| 569 | - | t.Errorf("meta = (%q,%q,%q,%q); want model/provider/reasoning/profile persisted", got.Model, got.Provider, got.Reasoning, got.Subagent) | 570 | + | t.Errorf("meta = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session persisted", got.Model, got.Provider, got.Reasoning, got.Subagent, got.SessionID) |
| 570 | } | 571 | } | ||
| 571 | 572 | ||||
| 572 | // Reload in a fresh Swarm and confirm the detached agent still | 573 | // Reload in a fresh Swarm and confirm the detached agent still | ||
| 573 | // carries the model/provider so Resume can route the child | 574 | // carries the model/provider so Resume can route the child | ||
| 574 | // subprocess back to the same model. | 575 | // subprocess back to the same model. | ||
| 575 | - | g := New(Config{Root: root, RepoRoot: root}) | 576 | + | g := New(Config{ |
| 577 | + | Root: root, RepoRoot: root, | |||
| 578 | + | NewRunner: func(a *Agent) Runner { | |||
| 579 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 580 | + | }, | |||
| 581 | + | }) | |||
| 576 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | 582 | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | ||
| 577 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | 583 | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | ||
| 578 | } | 584 | } | ||
| @@ -580,9 +586,21 @@ func TestSpawnReqPersistsModel(t *testing.T) { | |||||
| 580 | if re == nil { | 586 | if re == nil { | ||
| 581 | t.Fatal("reloaded agent missing") | 587 | t.Fatal("reloaded agent missing") | ||
| 582 | } | 588 | } | ||
| 583 | - | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" { | 589 | + | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" || re.Reasoning != "high" || re.Subagent != "reviewer" || re.SessionID != "host-session" { |
| 584 | - | t.Errorf("reloaded fields = (%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent) | 590 | + | t.Errorf("reloaded fields = (%q,%q,%q,%q,%q); want preserved", re.Model, re.Provider, re.Reasoning, re.Subagent, re.SessionID) |
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | resumed, err := g.Resume(context.Background(), re.ID) | |||
| 594 | + | if err != nil { | |||
| 595 | + | t.Fatalf("resume: %v", err) | |||
| 596 | + | } | |||
| 597 | + | if resumed.Reasoning != "high" || resumed.Subagent != "reviewer" || resumed.SessionID != "host-session" { | |||
| 598 | + | t.Errorf("resumed fields = (%q,%q,%q); want reasoning/profile/session preserved", resumed.Reasoning, resumed.Subagent, resumed.SessionID) | |||
| 599 | + | } | |||
| 600 | + | if err := g.Stop(resumed.ID); err != nil { | |||
| 601 | + | t.Fatalf("stop resumed agent: %v", err) | |||
| 585 | } | 602 | } | ||
| 603 | + | resumed.Wait() | |||
| 586 | } | 604 | } | ||
| 587 | 605 | ||||
| 588 | // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that | 606 | // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that | ||
M packages/agent/swarm/runner_test.go +5 -0
| @@ -171,6 +171,11 @@ func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) { | |||
| 171 | 171 | if a := args[len(args)-1]; strings.HasPrefix(a, "--") { | |
| 172 | 172 | t.Fatalf("argv ends on a flag with no value: %v", args) | |
| 173 | 173 | } | |
| 174 | + | for _, flag := range []string{"--reasoning", "--subagent"} { | |
| 175 | + | if indexOf(args, flag) >= 0 { | |
| 176 | + | t.Fatalf("empty child options should omit %s: %v", flag, args) | |
| 177 | + | } | |
| 178 | + | } | |
| 174 | 179 | } | |
| 175 | 180 | ||
| 176 | 181 | // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a | |
| @@ -171,6 +171,11 @@ func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) { | |||||
| 171 | if a := args[len(args)-1]; strings.HasPrefix(a, "--") { | 171 | if a := args[len(args)-1]; strings.HasPrefix(a, "--") { | ||
| 172 | t.Fatalf("argv ends on a flag with no value: %v", args) | 172 | t.Fatalf("argv ends on a flag with no value: %v", args) | ||
| 173 | } | 173 | } | ||
| 174 | + | for _, flag := range []string{"--reasoning", "--subagent"} { | |||
| 175 | + | if indexOf(args, flag) >= 0 { | |||
| 176 | + | t.Fatalf("empty child options should omit %s: %v", flag, args) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 174 | } | 179 | } | ||
| 175 | 180 | ||||
| 176 | // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a | 181 | // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a | ||
M packages/agent/swarm/swarm.go +8 -0
| @@ -148,6 +148,14 @@ func (f *Swarm) SetRepoRoot(root string) { | |||
| 148 | 148 | f.cfg.RepoRoot = root | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | + | // RepoRoot returns the working directory used by subsequently spawned | |
| 152 | + | // agents. | |
| 153 | + | func (f *Swarm) RepoRoot() string { | |
| 154 | + | f.mu.Lock() | |
| 155 | + | defer f.mu.Unlock() | |
| 156 | + | return f.cfg.RepoRoot | |
| 157 | + | } | |
| 158 | + | ||
| 151 | 159 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |
| 152 | 160 | // to a particular host zot session id. Pass empty to clear the | |
| 153 | 161 | // scope and revert to "show every agent" (the original behaviour). | |
| @@ -148,6 +148,14 @@ func (f *Swarm) SetRepoRoot(root string) { | |||||
| 148 | f.cfg.RepoRoot = root | 148 | f.cfg.RepoRoot = root | ||
| 149 | } | 149 | } | ||
| 150 | 150 | ||||
| 151 | + | // RepoRoot returns the working directory used by subsequently spawned | |||
| 152 | + | // agents. | |||
| 153 | + | func (f *Swarm) RepoRoot() string { | |||
| 154 | + | f.mu.Lock() | |||
| 155 | + | defer f.mu.Unlock() | |||
| 156 | + | return f.cfg.RepoRoot | |||
| 157 | + | } | |||
| 158 | + | ||||
| 151 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | 159 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | ||
| 152 | // to a particular host zot session id. Pass empty to clear the | 160 | // to a particular host zot session id. Pass empty to clear the | ||
| 153 | // scope and revert to "show every agent" (the original behaviour). | 161 | // scope and revert to "show every agent" (the original behaviour). | ||
M packages/agent/tools/swarm_spawn.go +17 -10
| @@ -101,10 +101,10 @@ func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarm | |||
| 101 | 101 | ||
| 102 | 102 | func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 103 | 103 | if t.Swarm == nil { | |
| 104 | - | return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil | |
| 104 | + | return protocolToolError("swarm_spawn: swarm supervisor not available in this mode") | |
| 105 | 105 | } | |
| 106 | 106 | if t.Enabled == nil || !t.Enabled() { | |
| 107 | - | return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil | |
| 107 | + | return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.") | |
| 108 | 108 | } | |
| 109 | 109 | var a swarmSpawnArgs | |
| 110 | 110 | if err := json.Unmarshal(raw, &a); err != nil { | |
| @@ -112,22 +112,22 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 112 | 112 | } | |
| 113 | 113 | task := strings.TrimSpace(a.Task) | |
| 114 | 114 | if task == "" { | |
| 115 | - | return toolErr("swarm_spawn: task is required"), nil | |
| 115 | + | return protocolToolError("swarm_spawn: task is required") | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | 118 | agentName := strings.TrimSpace(a.Agent) | |
| 119 | 119 | var profile *subagents.Profile | |
| 120 | 120 | if agentName != "" { | |
| 121 | 121 | if t.ResolveSubagent == nil { | |
| 122 | - | return toolErr("swarm_spawn: named subagent profiles are unavailable"), nil | |
| 122 | + | return protocolToolError("swarm_spawn: named subagent profiles are unavailable") | |
| 123 | 123 | } | |
| 124 | 124 | var err error | |
| 125 | 125 | profile, err = t.ResolveSubagent(agentName) | |
| 126 | 126 | if err != nil { | |
| 127 | - | return toolErr("swarm_spawn: " + err.Error()), nil | |
| 127 | + | return protocolToolError("swarm_spawn: " + err.Error()) | |
| 128 | 128 | } | |
| 129 | 129 | if profile == nil { | |
| 130 | - | return toolErr("swarm_spawn: unknown subagent profile " + agentName), nil | |
| 130 | + | return protocolToolError("swarm_spawn: unknown subagent profile " + agentName) | |
| 131 | 131 | } | |
| 132 | 132 | agentName = profile.Name | |
| 133 | 133 | } | |
| @@ -135,7 +135,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 135 | 135 | model := strings.TrimSpace(a.Model) | |
| 136 | 136 | providerID := strings.TrimSpace(a.Provider) | |
| 137 | 137 | if (model == "") != (providerID == "") { | |
| 138 | - | return toolErr("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly"), nil | |
| 138 | + | return protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly") | |
| 139 | 139 | } | |
| 140 | 140 | if model == "" && providerID == "" && profile != nil { | |
| 141 | 141 | // A fully-qualified profile model is useful metadata for the | |
| @@ -158,18 +158,18 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 158 | 158 | ||
| 159 | 159 | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | |
| 160 | 160 | if err != nil { | |
| 161 | - | return toolErr("swarm_spawn: " + err.Error()), nil | |
| 161 | + | return protocolToolError("swarm_spawn: " + err.Error()) | |
| 162 | 162 | } | |
| 163 | 163 | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | |
| 164 | 164 | reasoning, err = reasoningOverride(profile.Thinking, "") | |
| 165 | 165 | if err != nil { | |
| 166 | - | return toolErr("swarm_spawn: profile " + profile.Name + ": " + err.Error()), nil | |
| 166 | + | return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error()) | |
| 167 | 167 | } | |
| 168 | 168 | } | |
| 169 | 169 | if reasoning == "" && t.DefaultReasoning != nil { | |
| 170 | 170 | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | |
| 171 | 171 | if err != nil { | |
| 172 | - | return toolErr("swarm_spawn: host " + err.Error()), nil | |
| 172 | + | return protocolToolError("swarm_spawn: host " + err.Error()) | |
| 173 | 173 | } | |
| 174 | 174 | } | |
| 175 | 175 | ||
| @@ -241,6 +241,13 @@ func reasoningOverride(reasoning, thinking string) (string, error) { | |||
| 241 | 241 | } | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | + | // protocolToolError keeps model-visible validation failures in the | |
| 245 | + | // ToolResult channel rather than treating them as host execution errors. | |
| 246 | + | func protocolToolError(msg string) (core.ToolResult, error) { | |
| 247 | + | //nolint:nilerr // ToolResult.IsError is the established tool protocol. | |
| 248 | + | return toolErr(msg), nil | |
| 249 | + | } | |
| 250 | + | ||
| 244 | 251 | func toolErr(msg string) core.ToolResult { | |
| 245 | 252 | return core.ToolResult{ | |
| 246 | 253 | Content: []provider.Content{provider.TextBlock{Text: msg}}, | |
| @@ -101,10 +101,10 @@ func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarm | |||||
| 101 | 101 | ||||
| 102 | func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | 102 | func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | ||
| 103 | if t.Swarm == nil { | 103 | if t.Swarm == nil { | ||
| 104 | - | return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil | 104 | + | return protocolToolError("swarm_spawn: swarm supervisor not available in this mode") |
| 105 | } | 105 | } | ||
| 106 | if t.Enabled == nil || !t.Enabled() { | 106 | if t.Enabled == nil || !t.Enabled() { | ||
| 107 | - | return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil | 107 | + | return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.") |
| 108 | } | 108 | } | ||
| 109 | var a swarmSpawnArgs | 109 | var a swarmSpawnArgs | ||
| 110 | if err := json.Unmarshal(raw, &a); err != nil { | 110 | if err := json.Unmarshal(raw, &a); err != nil { | ||
| @@ -112,22 +112,22 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 112 | } | 112 | } | ||
| 113 | task := strings.TrimSpace(a.Task) | 113 | task := strings.TrimSpace(a.Task) | ||
| 114 | if task == "" { | 114 | if task == "" { | ||
| 115 | - | return toolErr("swarm_spawn: task is required"), nil | 115 | + | return protocolToolError("swarm_spawn: task is required") |
| 116 | } | 116 | } | ||
| 117 | 117 | ||||
| 118 | agentName := strings.TrimSpace(a.Agent) | 118 | agentName := strings.TrimSpace(a.Agent) | ||
| 119 | var profile *subagents.Profile | 119 | var profile *subagents.Profile | ||
| 120 | if agentName != "" { | 120 | if agentName != "" { | ||
| 121 | if t.ResolveSubagent == nil { | 121 | if t.ResolveSubagent == nil { | ||
| 122 | - | return toolErr("swarm_spawn: named subagent profiles are unavailable"), nil | 122 | + | return protocolToolError("swarm_spawn: named subagent profiles are unavailable") |
| 123 | } | 123 | } | ||
| 124 | var err error | 124 | var err error | ||
| 125 | profile, err = t.ResolveSubagent(agentName) | 125 | profile, err = t.ResolveSubagent(agentName) | ||
| 126 | if err != nil { | 126 | if err != nil { | ||
| 127 | - | return toolErr("swarm_spawn: " + err.Error()), nil | 127 | + | return protocolToolError("swarm_spawn: " + err.Error()) |
| 128 | } | 128 | } | ||
| 129 | if profile == nil { | 129 | if profile == nil { | ||
| 130 | - | return toolErr("swarm_spawn: unknown subagent profile " + agentName), nil | 130 | + | return protocolToolError("swarm_spawn: unknown subagent profile " + agentName) |
| 131 | } | 131 | } | ||
| 132 | agentName = profile.Name | 132 | agentName = profile.Name | ||
| 133 | } | 133 | } | ||
| @@ -135,7 +135,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 135 | model := strings.TrimSpace(a.Model) | 135 | model := strings.TrimSpace(a.Model) | ||
| 136 | providerID := strings.TrimSpace(a.Provider) | 136 | providerID := strings.TrimSpace(a.Provider) | ||
| 137 | if (model == "") != (providerID == "") { | 137 | if (model == "") != (providerID == "") { | ||
| 138 | - | return toolErr("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly"), nil | 138 | + | return protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly") |
| 139 | } | 139 | } | ||
| 140 | if model == "" && providerID == "" && profile != nil { | 140 | if model == "" && providerID == "" && profile != nil { | ||
| 141 | // A fully-qualified profile model is useful metadata for the | 141 | // A fully-qualified profile model is useful metadata for the | ||
| @@ -158,18 +158,18 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 158 | 158 | ||||
| 159 | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | 159 | reasoning, err := reasoningOverride(a.Reasoning, a.Thinking) | ||
| 160 | if err != nil { | 160 | if err != nil { | ||
| 161 | - | return toolErr("swarm_spawn: " + err.Error()), nil | 161 | + | return protocolToolError("swarm_spawn: " + err.Error()) |
| 162 | } | 162 | } | ||
| 163 | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | 163 | if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { | ||
| 164 | reasoning, err = reasoningOverride(profile.Thinking, "") | 164 | reasoning, err = reasoningOverride(profile.Thinking, "") | ||
| 165 | if err != nil { | 165 | if err != nil { | ||
| 166 | - | return toolErr("swarm_spawn: profile " + profile.Name + ": " + err.Error()), nil | 166 | + | return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error()) |
| 167 | } | 167 | } | ||
| 168 | } | 168 | } | ||
| 169 | if reasoning == "" && t.DefaultReasoning != nil { | 169 | if reasoning == "" && t.DefaultReasoning != nil { | ||
| 170 | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | 170 | reasoning, err = reasoningOverride(t.DefaultReasoning(), "") | ||
| 171 | if err != nil { | 171 | if err != nil { | ||
| 172 | - | return toolErr("swarm_spawn: host " + err.Error()), nil | 172 | + | return protocolToolError("swarm_spawn: host " + err.Error()) |
| 173 | } | 173 | } | ||
| 174 | } | 174 | } | ||
| 175 | 175 | ||||
| @@ -241,6 +241,13 @@ func reasoningOverride(reasoning, thinking string) (string, error) { | |||||
| 241 | } | 241 | } | ||
| 242 | } | 242 | } | ||
| 243 | 243 | ||||
| 244 | + | // protocolToolError keeps model-visible validation failures in the | |||
| 245 | + | // ToolResult channel rather than treating them as host execution errors. | |||
| 246 | + | func protocolToolError(msg string) (core.ToolResult, error) { | |||
| 247 | + | //nolint:nilerr // ToolResult.IsError is the established tool protocol. | |||
| 248 | + | return toolErr(msg), nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 244 | func toolErr(msg string) core.ToolResult { | 251 | func toolErr(msg string) core.ToolResult { | ||
| 245 | return core.ToolResult{ | 252 | return core.ToolResult{ | ||
| 246 | Content: []provider.Content{provider.TextBlock{Text: msg}}, | 253 | Content: []provider.Content{provider.TextBlock{Text: msg}}, | ||