diff --git a/cmd/jcode/main.go b/cmd/jcode/main.go index 7a60a92a..3eeb69ec 100644 --- a/cmd/jcode/main.go +++ b/cmd/jcode/main.go @@ -22,6 +22,7 @@ func main() { var ( prompt string resumeUUID string + agentName string unsafeMode bool ) @@ -32,12 +33,13 @@ func main() { SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { - return command.RunInteractive(prompt, resumeUUID, unsafeMode) + return command.RunInteractive(prompt, resumeUUID, agentName, unsafeMode) }, } rootCmd.SetVersionTemplate(fmt.Sprintf("JCODE — Coding Assistant\nVersion: %s\nBuild time: %s\nGit commit: %s\n", command.Version, command.BuildTime, command.GitCommit)) rootCmd.Flags().StringVarP(&prompt, "prompt", "p", "", "One-shot prompt (non-interactive)") rootCmd.Flags().StringVar(&resumeUUID, "resume", "", "Resume a previous session by UUID") + rootCmd.Flags().StringVar(&agentName, "agent", "", "Custom agent for the current session") rootCmd.Flags().BoolVar(&unsafeMode, "unsafe", false, "Auto-approve all tool calls (overrides config)") rootCmd.AddCommand( diff --git a/go.mod b/go.mod index 5da8687e..4a874f0f 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( require ( github.com/tyler-smith/go-bip39 v1.1.0 github.com/zalando/go-keyring v0.2.8 + gopkg.in/yaml.v3 v3.0.1 ) require github.com/danieljoos/wincred v1.2.3 // indirect @@ -130,7 +131,6 @@ require ( golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/internal-doc/custom-agents.md b/internal-doc/custom-agents.md index fa6b7bbb..1eb2b59a 100644 --- a/internal-doc/custom-agents.md +++ b/internal-doc/custom-agents.md @@ -1,45 +1,72 @@ -# Custom agents implementation plan +# Custom agents Status: implementation contract -## Product contract +## Definition -JCode custom agents follow Codex's useful separation between a discoverable role and its execution configuration, adapted to JCode's JSON configuration and existing subagent/team tools. +JCode custom agents are Markdown-defined roles used both for top-level sessions +and for delegated subagents, workflows, and team members. -A role has: +JCode loads only files matching: -- a stable lowercase name used as `agent_type`; -- a required description advertised in tool schemas so the parent can choose it; -- a base profile (`explore`, `general`, or `coordinator`) that caps available tools; -- optional instructions appended to the base profile prompt; -- an optional default model reference (`provider/model` or `small`). - -Definitions can be declared in `config.json` under `agents`, or discovered from `~/.jcode/agents/*.json` and `/.jcode/agents/*.json`. Precedence is builtin < user < project < inline config. A malformed role is ignored with a visible diagnostic in `~/.jcode/debug.log`; it never broadens permissions. +- `~/.jcode/agents/*.agent.md` (user scope) +- `/.jcode/agents/*.agent.md` (project scope) Example: -```json -{ - "name": "reviewer", - "description": "Review a patch for correctness, security, and missing tests", - "profile": "explore", - "instructions": "Lead with findings ordered by severity.", - "model": "small" -} +```md +--- +name: bug-fix-teammate +description: Investigate regressions and implement focused fixes +model: anthropic/claude-sonnet-4-5 +--- + +Reproduce the failure before editing. Keep the patch focused and run the +smallest relevant test suite before returning. ``` -## Security rules +`name` and `description` are required frontmatter strings. The Markdown body is +the required, non-empty agent instruction. `model` is optional and accepts a +`provider/model` reference or the `small` alias. + +Unknown frontmatter fields, malformed YAML, empty required values, symlinks, +files larger than 64 KiB, invalid names, and built-in role names are ignored +with a diagnostic in `~/.jcode/debug.log`. + +## Discovery and precedence + +Files are read in lexicographic filename order. Within one scope, the first +valid definition for a given frontmatter `name` wins. A valid project +definition overrides the effective user definition with the same name. The UI +shows only the final effective definition. + +Malformed higher-precedence files do not hide a valid lower-precedence +definition. + +## Runtime semantics + +- The Markdown instruction is appended to JCode's normal system prompt. +- The role inherits the caller's mode, approval policy, sandbox, MCP access, + and tool set. Agent Markdown cannot add tools or bypass approval. +- A role model is applied when the role is selected. With no role model, the + current session model is inherited. For delegated agents, the role model + takes precedence over a per-call model; otherwise the per-call/current model + rules remain unchanged. +- Session metadata and JSONL events retain the effective top-level agent. + Resume restores it; if the definition is no longer valid, JCode falls back + to Default and surfaces the change. -- A custom role cannot define tools directly. Its `profile` selects an existing audited tool set. -- Unknown or malformed profiles are rejected; no fallback to a broader profile. -- Custom role spawns always pass the parent approval boundary; the resolved base profile then caps child tools. Renaming a role therefore cannot bypass delegated-write approval (an `explore` custom role may prompt more conservatively than the builtin). -- Role files are read from fixed user/project directories; symlinks and files larger than 64 KiB are rejected. -- Explicit spawn `model` overrides the role default. A role default never overrides a caller's explicit selection. +## Selection and display -## Implementation and tests +- CLI: `jcode --agent ` selects a custom agent. Omitting the flag or + passing `--agent default` selects Default. +- Web/Desktop: the composer shows the Agent picker only when at least one + custom agent is available. It contains Default plus all effective custom + agents, displays the active name in the toolbar, and records switches as + visible timeline notices. +- ACP intentionally exposes no top-level agent picker. New ACP sessions use + Default. Loading or resuming a session that already selected a custom agent + restores that role automatically. Custom agents remain available to ACP's + delegation tools. -1. Add a deterministic loader/validator in `internal/config/agent_roles.go` with unit tests for precedence, malformed input, symlinks, size limits, and duplicate names. -2. Resolve custom roles in direct subagents, teams, and workflow agents. Tool schemas advertise the available role names and descriptions. -3. Preserve built-in behavior when no custom roles exist. -4. Test tool-set capping, prompt composition, model precedence, unknown-role errors, and each transport's tool construction. -5. Adversarial review permission escalation, path traversal/symlink reads, unbounded prompt size, and config races before delivery. +Inline JSON agent definitions and legacy `*.json` files are unsupported. diff --git a/internal/command/acp.go b/internal/command/acp.go index 02e9a84f..12a28fbb 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -373,7 +373,7 @@ func (a *acpAgent) NewSession(ctx context.Context, params acp.NewSessionRequest) attachTitleRefiner(ctx, rec) sessionID := acp.SessionId(fmt.Sprintf("sess_%s", rec.UUID())) - sess, err := a.buildAgentSession(ctx, cfg, pwd, sessionID, rec, nil) + sess, err := a.buildAgentSession(ctx, cfg, pwd, sessionID, rec, nil, "") if err != nil { return acp.NewSessionResponse{}, err } @@ -408,6 +408,7 @@ func (a *acpAgent) buildAgentSession( sessionID acp.SessionId, rec *session.Recorder, history []*schema.Message, + agentRoleName string, ) (*acpSession, error) { platform := util.GetSystemInfo() envInfo := util.CollectEnvInfo(pwd) @@ -419,6 +420,25 @@ func (a *acpAgent) buildAgentSession( flowLoader.LoadProject(pwd) providerName, modelName := cfg.GetProviderModel() + var selectedRole config.AgentRoleConfig + if agentRoleName != "" { + role, ok := config.LoadAgentRoles(pwd)[agentRoleName] + if !ok { + config.Logger().Printf( + "[acp] custom agent %q is unavailable; resuming with Default", agentRoleName, + ) + agentRoleName = "" + } else { + selectedRole = role + var resolveErr error + providerName, modelName, resolveErr = resolveCustomAgentModel( + role, cfg, providerName, modelName, + ) + if resolveErr != nil { + return nil, fmt.Errorf("custom agent %q: %w", agentRoleName, resolveErr) + } + } + } providers := cfg.GetProviders() providerCfg := providers[providerName] if providerCfg == nil { @@ -474,7 +494,7 @@ func (a *acpAgent) buildAgentSession( // One factory serves subagent + workflow model overrides (incl. the // "small" alias); fallback is this session's current model. factory := internalmodel.NewModelFactory(cfg, chatModel) - agentRoles := config.LoadAgentRoles(env.Pwd(), cfg) + agentRoles := config.LoadAgentRoles(env.Pwd()) allTools := []tool.BaseTool{ // load_skill: ACP puts the skill list in the system prompt (see // skillLoader.Descriptions() below) and the slash-command path literally @@ -549,6 +569,14 @@ func (a *acpAgent) buildAgentSession( normalPrompt := prompts.GetSystemPrompt(platform, pwd, "local", envInfo, skillLoader.Descriptions()) planPrompt := prompts.GetPlanSystemPrompt(platform, pwd, "local", envInfo) + if agentRoleName != "" { + normalPrompt = withCustomAgentPrompt(normalPrompt, agentRoleName, selectedRole) + planPrompt = withCustomAgentPrompt(planPrompt, agentRoleName, selectedRole) + } + if rec != nil { + rec.SetAgent(agentRoleName) + rec.SetModel(modelName) + } startupMode := resolveStartupMode(cfg, false) approvalState := runner.NewApprovalStateWithMode(pwd, startupMode) approvalState.SetComputerPermFunc(func(bundleID, class string) bool { @@ -1094,7 +1122,9 @@ func (a *acpAgent) LoadSession(ctx context.Context, params acp.LoadSessionReques resumeState := session.ReconstructState(entries) history := session.PruneOldToolOutputs(resumeState.History, 2) - sess, err := a.buildAgentSession(ctx, cfg, pwd, params.SessionId, rec, history) + sess, err := a.buildAgentSession( + ctx, cfg, pwd, params.SessionId, rec, history, resumeState.Agent, + ) if err != nil { return acp.LoadSessionResponse{}, err } @@ -1157,11 +1187,13 @@ func (a *acpAgent) ResumeSession(ctx context.Context, params acp.ResumeSessionRe // Load history from disk so the agent has conversation context. var history []*schema.Message var goalSnap *session.GoalSnapshot + var resumedAgent string restoredMode := mode.Approval if entries, err := session.LoadSession(resumeUUID); err == nil { resumeState := session.ReconstructState(entries) history = session.PruneOldToolOutputs(resumeState.History, 2) goalSnap = resumeState.Goal + resumedAgent = resumeState.Agent // Restore the saved mode (Approval/Auto/Full access as-is; Plan normalized to // Approval so the reloaded full-tool agent is not stranded in read-only plan tools). restoredMode = mode.Parse(resumeState.Mode) @@ -1173,7 +1205,9 @@ func (a *acpAgent) ResumeSession(ctx context.Context, params acp.ResumeSessionRe config.Logger().Printf("[acp] ResumeSession: could not load history for %s: %v", params.SessionId, err) } - sess, err := a.buildAgentSession(ctx, cfg, pwd, params.SessionId, rec, history) + sess, err := a.buildAgentSession( + ctx, cfg, pwd, params.SessionId, rec, history, resumedAgent, + ) if err != nil { return acp.ResumeSessionResponse{}, err } diff --git a/internal/command/custom_agents.go b/internal/command/custom_agents.go new file mode 100644 index 00000000..1e584acb --- /dev/null +++ b/internal/command/custom_agents.go @@ -0,0 +1,99 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/cnjack/jcode/internal/config" + internalmodel "github.com/cnjack/jcode/internal/model" +) + +// resolveCustomAgentModel applies a role's optional model over the caller's +// current selection. The "small" alias inherits when it is not configured, +// matching delegated-agent model routing. +func resolveCustomAgentModel( + role config.AgentRoleConfig, + cfg *config.Config, + currentProvider, currentModel string, +) (string, string, error) { + ref := strings.TrimSpace(role.Model) + if ref == "" { + return currentProvider, currentModel, nil + } + if ref == internalmodel.SmallModelAlias { + if cfg == nil || strings.TrimSpace(cfg.SmallModel) == "" { + return currentProvider, currentModel, nil + } + ref = strings.TrimSpace(cfg.SmallModel) + } + provider, modelName, err := internalmodel.ParseProviderModel(ref) + if err != nil { + return "", "", fmt.Errorf("custom agent model: %w", err) + } + return provider, modelName, nil +} + +func loadCustomAgentRole(pwd, roleName string) (config.AgentRoleConfig, error) { + role, ok := config.LoadAgentRoles(pwd)[roleName] + if !ok { + return config.AgentRoleConfig{}, fmt.Errorf("unknown custom agent %q", roleName) + } + return role, nil +} + +func optionalCustomAgentRole(pwd, roleName string) (config.AgentRoleConfig, error) { + if roleName == "" { + return config.AgentRoleConfig{}, nil + } + return loadCustomAgentRole(pwd, roleName) +} + +func withCustomAgentPrompt( + base, roleName string, + role config.AgentRoleConfig, +) string { + if roleName == "" { + return base + } + return base + "\n\n## Custom agent: " + roleName + + "\nDescription: " + role.Description + + "\n\n" + role.Instructions +} + +func withLoadedCustomAgentPrompt(base, pwd, roleName string) string { + role, err := optionalCustomAgentRole(pwd, roleName) + if err != nil { + return base + } + return withCustomAgentPrompt(base, roleName, role) +} + +func resolveWebCustomAgentSelection( + pwd, roleName, currentProvider, currentModel string, +) (config.AgentRoleConfig, string, string, error) { + if roleName == "" { + return config.AgentRoleConfig{}, currentProvider, currentModel, nil + } + role, err := loadCustomAgentRole(pwd, roleName) + if err != nil { + return config.AgentRoleConfig{}, "", "", err + } + if role.Model == "" { + return role, currentProvider, currentModel, nil + } + cfg, err := config.LoadConfig() + if err != nil { + return config.AgentRoleConfig{}, "", "", fmt.Errorf( + "load config for custom agent model: %w", err, + ) + } + provider, modelName, err := resolveCustomAgentModel( + role, cfg, currentProvider, currentModel, + ) + if err != nil { + return config.AgentRoleConfig{}, "", "", fmt.Errorf( + "custom agent %q: %w", roleName, err, + ) + } + return role, provider, modelName, nil +} diff --git a/internal/command/custom_agents_test.go b/internal/command/custom_agents_test.go new file mode 100644 index 00000000..9c0d158d --- /dev/null +++ b/internal/command/custom_agents_test.go @@ -0,0 +1,54 @@ +package command + +import ( + "testing" + + "github.com/cnjack/jcode/internal/config" +) + +func TestResolveCustomAgentModel(t *testing.T) { + tests := []struct { + name string + roleModel string + smallModel string + wantProvider string + wantModel string + wantErr bool + }{ + { + name: "inherits current", + wantProvider: "current", wantModel: "main", + }, + { + name: "role override", roleModel: "other/special", + wantProvider: "other", wantModel: "special", + }, + { + name: "small alias", roleModel: "small", smallModel: "fast/mini", + wantProvider: "fast", wantModel: "mini", + }, + { + name: "unset small inherits", roleModel: "small", + wantProvider: "current", wantModel: "main", + }, + { + name: "invalid reference", roleModel: "missing-provider", wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotProvider, gotModel, err := resolveCustomAgentModel( + config.AgentRoleConfig{Model: tt.roleModel}, + &config.Config{SmallModel: tt.smallModel}, + "current", "main", + ) + if (err != nil) != tt.wantErr { + t.Fatalf("error=%v, wantErr=%v", err, tt.wantErr) + } + if err == nil && (gotProvider != tt.wantProvider || gotModel != tt.wantModel) { + t.Fatalf("got %s/%s, want %s/%s", + gotProvider, gotModel, tt.wantProvider, tt.wantModel) + } + }) + } +} diff --git a/internal/command/interactive.go b/internal/command/interactive.go index 759d284d..a8812f27 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -60,6 +60,8 @@ type interactiveState struct { systemPrompt string toolList []tool.BaseTool agentMode tui.AgentMode + agentRoleName string + agentRole *config.AgentRoleConfig envInfo *util.EnvInfo pwd string platform string @@ -147,7 +149,7 @@ func (s *interactiveState) buildAllTools() []tool.BaseTool { // "small" alias); fallback is the current session model, so it must be // rebuilt here on every agent rebuild (model switches re-enter this func). factory := internalmodel.NewModelFactory(s.cfg, s.chatModel) - agentRoles := config.LoadAgentRoles(s.env.Pwd(), s.cfg) + agentRoles := config.LoadAgentRoles(s.env.Pwd()) all := []tool.BaseTool{ s.env.NewReadTool(), s.env.NewEditTool(), s.env.NewWriteTool(), s.env.NewExecuteTool(s.bgManager), s.env.NewGrepTool(), @@ -212,6 +214,52 @@ func (s *interactiveState) buildPlanTools() []tool.BaseTool { return append(plan, s.env.NewComputerPlanTools()...) } +func (s *interactiveState) setTopLevelAgent(name string) error { + name = strings.TrimSpace(name) + if strings.EqualFold(name, "default") { + name = "" + } + if name == "" { + s.agentRoleName = "" + s.agentRole = nil + return nil + } + role, ok := config.LoadAgentRoles(s.pwd)[name] + if !ok { + return fmt.Errorf("unknown custom agent %q", name) + } + s.agentRoleName = name + s.agentRole = &role + return nil +} + +func (s *interactiveState) withTopLevelAgentPrompt(base string) string { + if s.agentRole == nil { + return base + } + return withCustomAgentPrompt(base, s.agentRoleName, *s.agentRole) +} + +func (s *interactiveState) buildTopLevelTools() []tool.BaseTool { + if s.agentMode == tui.ModePlanning { + return s.buildPlanTools() + } + return s.buildAllTools() +} + +func (s *interactiveState) refreshTopLevelPromptAndTools(envLabel string, envInfo *util.EnvInfo) { + if s.agentMode == tui.ModePlanning { + s.systemPrompt = s.withTopLevelAgentPrompt( + prompts.GetPlanSystemPrompt(s.platform, s.pwd, envLabel, envInfo), + ) + } else { + s.systemPrompt = s.withTopLevelAgentPrompt( + prompts.GetSystemPrompt(s.platform, s.pwd, envLabel, envInfo, s.skillLoader.Descriptions()), + ) + } + s.toolList = s.buildTopLevelTools() +} + func (s *interactiveState) subagentNotifier(name, agentType string, done bool, result string, err error) { if s.p == nil { return @@ -375,8 +423,12 @@ func (s *interactiveState) createAgent() (*adk.ChatModelAgent, error) { if s.agentMode == tui.ModePlanning { toolMode = agent.ToolModePlan } + mcpTools := s.mcpTools + if s.agentMode == tui.ModePlanning { + mcpTools = nil + } toolPlan, err := buildCommandToolPlan( - s.ctx, s.toolList, s.mcpTools, agent.ToolTransportTUI, toolMode, + s.ctx, s.toolList, mcpTools, agent.ToolTransportTUI, toolMode, ) if err != nil { return nil, fmt.Errorf("build TUI tool plan: %w", err) @@ -416,7 +468,7 @@ func (s *interactiveState) reloadMCP() { mcpTools, statuses := tools.LoadMCPTools(s.ctx, latest.MCPServers) s.mcpTools = mcpTools if s.agentMode != tui.ModePlanning { - s.toolList = s.buildAllTools() + s.toolList = s.buildTopLevelTools() if newAg, err := s.createAgent(); err == nil { s.ag = newAg } else { @@ -459,15 +511,8 @@ func (s *interactiveState) applyModeSwitch(newMode tui.AgentMode) { s.rec.RecordModeChange(currentMode.String()) } - if s.agentMode == tui.ModePlanning { - s.systemPrompt = prompts.GetPlanSystemPrompt(s.platform, s.pwd, s.env.Exec.Label(), s.envInfo) - s.toolList = s.buildPlanTools() - config.Logger().Printf("[plan] built plan tools: %d tools", len(s.toolList)) - } else { - s.systemPrompt = prompts.GetSystemPrompt(s.platform, s.pwd, s.env.Exec.Label(), s.envInfo, s.skillLoader.Descriptions()) - s.toolList = s.buildAllTools() - config.Logger().Printf("[plan] built all tools: %d tools", len(s.toolList)) - } + s.refreshTopLevelPromptAndTools(s.env.Exec.Label(), s.envInfo) + config.Logger().Printf("[plan] built tools: %d tools", len(s.toolList)) if newAg, err := s.createAgent(); err == nil { s.ag = newAg config.Logger().Printf("[plan] agent recreated successfully") @@ -792,13 +837,7 @@ func (s *interactiveState) handleConfig(cfgMsg *config.Config) { } // Rebuild system prompt and tools to reflect config changes (e.g., SSH aliases) - if s.agentMode == tui.ModePlanning { - s.systemPrompt = prompts.GetPlanSystemPrompt(s.platform, s.pwd, s.env.Exec.Label(), s.envInfo) - s.toolList = s.buildPlanTools() - } else { - s.systemPrompt = prompts.GetSystemPrompt(s.platform, s.pwd, s.env.Exec.Label(), s.envInfo, s.skillLoader.Descriptions()) - s.toolList = s.buildAllTools() - } + s.refreshTopLevelPromptAndTools(s.env.Exec.Label(), s.envInfo) if newAg, err := s.createAgent(); err == nil { s.ag = newAg @@ -887,11 +926,7 @@ func (s *interactiveState) handleSSH(connMsg interface{}) { HandleSSHListDir(s.ctx, s.env, msg.Path, s.p) case tui.SSHCancelMsg: s.env.ResetToLocal(s.pwd, s.platform) - if s.agentMode == tui.ModePlanning { - s.systemPrompt = prompts.GetPlanSystemPrompt(s.platform, s.pwd, "local", s.envInfo) - } else { - s.systemPrompt = prompts.GetSystemPrompt(s.platform, s.pwd, "local", s.envInfo, s.skillLoader.Descriptions()) - } + s.refreshTopLevelPromptAndTools("local", s.envInfo) if newAg, err := s.createAgent(); err == nil { s.ag = newAg } @@ -1076,9 +1111,10 @@ func (s *interactiveState) handleMCPLogin(name string) { // RunInteractive starts the interactive TUI session. // The unsafe flag enables auto-approve for all tool calls and takes precedence over config. -func RunInteractive(prompt, resumeUUID string, unsafe bool) error { +func RunInteractive(prompt, resumeUUID, agentName string, unsafe bool) error { prompt = strings.TrimSpace(prompt) hasPrompt := prompt != "" + agentFlagSet := strings.TrimSpace(agentName) != "" // Redirect default log output to the app error log so library diagnostics // (e.g. Langfuse upload errors) are visible without corrupting the TUI. @@ -1106,6 +1142,16 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { platform := util.GetSystemInfo() envInfo := util.CollectEnvInfo(pwd) + var resumeEntries []session.Entry + var resumeState *session.SessionState + if resumeUUID != "" { + resumeEntries, err = session.LoadSession(resumeUUID) + if err != nil { + return fmt.Errorf("cannot load session: %w", err) + } + resumeState = session.ReconstructState(resumeEntries) + } + skillLoader := skills.NewLoaderWithDisabled(cfg.DisabledSkills) skillLoader.ScanProjectSkills(pwd) @@ -1122,6 +1168,33 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { systemPrompt := prompts.GetSystemPrompt(platform, pwd, "local", envInfo, skillLoader.Descriptions()) providerName, modelName := cfg.GetProviderModel() + selectedAgentName := strings.TrimSpace(agentName) + if strings.EqualFold(selectedAgentName, "default") { + selectedAgentName = "" + } + if !agentFlagSet && resumeState != nil { + selectedAgentName = strings.TrimSpace(resumeState.Agent) + } + var resumeAgentWarning string + if selectedAgentName != "" { + role, ok := config.LoadAgentRoles(pwd)[selectedAgentName] + if !ok { + if agentFlagSet { + return fmt.Errorf("unknown custom agent %q", selectedAgentName) + } + resumeAgentWarning = fmt.Sprintf( + "Custom agent %q is no longer available; resumed with Default.", selectedAgentName, + ) + selectedAgentName = "" + } else { + providerName, modelName, err = resolveCustomAgentModel( + role, cfg, providerName, modelName, + ) + if err != nil { + return fmt.Errorf("custom agent %q: %w", selectedAgentName, err) + } + } + } providers := cfg.GetProviders() providerCfg := providers[providerName] @@ -1222,6 +1295,14 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { rec: rec, hookDisp: hookDisp, } + if err := st.setTopLevelAgent(selectedAgentName); err != nil { + return err + } + st.sessionResumeWarning = resumeAgentWarning + st.systemPrompt = st.withTopLevelAgentPrompt(systemPrompt) + if rec != nil { + rec.SetAgent(st.agentRoleName) + } // SessionStart hook: fire once for a fresh session; stash any additionalContext // to prepend to the first prompt. A resumed session fires it later — after the @@ -1274,7 +1355,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { // resolution path as subagents/workflows; the fallback (startup model) is // only reached when the "small" alias is unset/invalid. teamModelFactory := internalmodel.NewModelFactory(cfg, chatModel) - teamAgentRoles := config.LoadAgentRoles(pwd, cfg) + teamAgentRoles := config.LoadAgentRoles(pwd) teamManager := team.NewManager(&team.ManagerDeps{ DefaultModel: chatModel, EnvFactory: func(cwd string) any { @@ -1300,7 +1381,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { AgentRoles: teamAgentRoles, }) st.teamManager = teamManager - st.toolList = st.buildAllTools() + st.toolList = st.buildTopLevelTools() // Resolve the startup session mode. CLI --unsafe forces Full access and takes // precedence over config. Otherwise DefaultMode wins, falling back to the @@ -1361,11 +1442,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { browserMgr.SetConfig(browser.FromConfig(cfg.Browser)) // Tool schemas are fixed on an agent instance. Rebuild immediately so // /browser on|off changes the current task's model-visible tools. - if st.agentMode == tui.ModePlanning { - st.toolList = st.buildPlanTools() - } else { - st.toolList = st.buildAllTools() - } + st.toolList = st.buildTopLevelTools() newAg, err := st.createAgent() if err != nil { return fmt.Errorf("saved setting but could not refresh agent tools: %w", err) @@ -1426,11 +1503,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { computerMgr.SetConfig(computer.FromConfig(cfg.Computer)) // Tool schemas are fixed on an agent instance. Rebuild immediately so // /computer on|off takes effect for the current task without restart. - if st.agentMode == tui.ModePlanning { - st.toolList = st.buildPlanTools() - } else { - st.toolList = st.buildAllTools() - } + st.toolList = st.buildTopLevelTools() newAg, err := st.createAgent() if err != nil { return fmt.Errorf("saved setting but could not refresh agent tools: %w", err) @@ -1516,7 +1589,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { // Record the system prompt and environment snapshot for KV-cache-friendly resume. if rec != nil { envSnapshot := prompts.SerializeEnvInfo(platform, pwd, "local", envInfo) - rec.RecordSystemPrompt(systemPrompt, envSnapshot) + rec.RecordSystemPrompt(st.systemPrompt, envSnapshot) } env.OnEnvChange = func(envLabel string, isLocal bool, envErr error) { @@ -1526,11 +1599,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { } if isLocal { approvalState.SetWorkpath(pwd) - if st.agentMode == tui.ModePlanning { - st.systemPrompt = prompts.GetPlanSystemPrompt(platform, pwd, "local", envInfo) - } else { - st.systemPrompt = prompts.GetSystemPrompt(platform, pwd, "local", envInfo, skillLoader.Descriptions()) - } + st.refreshTopLevelPromptAndTools("local", envInfo) if newAg, agErr := st.createAgent(); agErr == nil { st.ag = newAg } @@ -1538,11 +1607,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { return } approvalState.SetWorkpath(env.Pwd()) - if st.agentMode == tui.ModePlanning { - st.systemPrompt = prompts.GetPlanSystemPrompt(platform, pwd, envLabel, nil) - } else { - st.systemPrompt = prompts.GetSystemPrompt(platform, pwd, envLabel, nil, skillLoader.Descriptions()) - } + st.refreshTopLevelPromptAndTools(envLabel, nil) if newAg, agErr := st.createAgent(); agErr == nil { st.ag = newAg } @@ -1554,18 +1619,15 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { var initialResumeUUID string var initialResumeEntries []tui.SessionEntry if resumeUUID != "" { - entries, loadErr := session.LoadSession(resumeUUID) - if loadErr != nil { - return fmt.Errorf("cannot load session: %w", loadErr) - } - resumeState := session.ReconstructState(entries) initialHistory = session.PruneOldToolOutputs(resumeState.History, 2) initialResumeUUID = resumeUUID - initialResumeEntries = tui.ConvertSessionEntries(entries) + initialResumeEntries = tui.ConvertSessionEntries(resumeEntries) hasPrompt = false + restoreStoredPrompt := !agentFlagSet && resumeAgentWarning == "" && selectedAgentName == "" + // Restore stored system prompt for KV-cache-friendly resume. - if resumeState.SystemPrompt != "" { + if restoreStoredPrompt && resumeState.SystemPrompt != "" { systemPrompt = resumeState.SystemPrompt st.systemPrompt = systemPrompt // Inject environment diff as an additional system message. @@ -1577,7 +1639,6 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { }) } } - if resumeState.Plan != nil { switch resumeState.Plan.Status { case "approved": @@ -1603,7 +1664,12 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { env.GoalStore.RestoreFromSnapshot(resumeState.Goal) if targetEnv := resumeState.EnvTarget; targetEnv != "local" { - st.sessionResumeWarning = st.attemptSSHResume(targetEnv) + if envWarning := st.attemptSSHResume(targetEnv); envWarning != "" { + if st.sessionResumeWarning != "" { + st.sessionResumeWarning += "\n" + } + st.sessionResumeWarning += envWarning + } } // Reuse the existing session UUID so new messages are appended to the same file diff --git a/internal/command/web.go b/internal/command/web.go index e29651dd..5275987b 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -564,7 +564,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo }, }, nil }) - agentRoles := config.LoadAgentRoles(taskPwd, agentCfg) + agentRoles := config.LoadAgentRoles(taskPwd) all := []tool.BaseTool{ tenv.NewReadTool(), tenv.NewEditTool(), tenv.NewWriteTool(), tenv.NewExecuteTool(tbg), tenv.NewGrepTool(), @@ -644,7 +644,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo _ = os.MkdirAll(filepath.Dir(transcriptPath), 0o755) _ = os.MkdirAll(reductionRoot, 0o755) - makeAgent := func(cm model.ToolCallingChatModel, ctxLimit int, planMode bool) (*adk.ChatModelAgent, error) { + makeAgent := func(cm model.ToolCallingChatModel, ctxLimit int, planMode bool, roleName string) (*adk.ChatModelAgent, error) { agentCfg, loadErr := config.LoadConfig() if loadErr != nil { return nil, fmt.Errorf("reload agent config: %w", loadErr) @@ -712,10 +712,15 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo systemPrompt, planPrompt := renderPrompts() prompt := systemPrompt toolList := buildAllTools(cm, agentCfg) + selectedRole, roleErr := optionalCustomAgentRole(taskPwd, roleName) + if roleErr != nil { + return nil, fmt.Errorf("custom agent %q is no longer available", roleName) + } if planMode { prompt = planPrompt toolList = buildPlanTools() } + prompt = withCustomAgentPrompt(prompt, roleName, selectedRole) // Snapshot MCP exactly once so the candidate catalog and runtime plan // cannot observe different reload generations while an agent is built. @@ -727,10 +732,11 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo currentMCPTools = dropInteractiveTools(currentMCPTools) } + allowMCP := !planMode if !config.ToolSearchEnabled(agentCfg) { // Preserve the eager/static path. Plan mode has never exposed MCP // tools, while normal mode appends the captured MCP generation. - if !planMode { + if allowMCP { toolList = append(toolList, currentMCPTools...) } return agent.NewAgent(ctx, cm, toolList, prompt, tappr.RequestApproval, middlewares, handlers) @@ -740,6 +746,9 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo if planMode { toolMode = agent.ToolModePlan } + if !allowMCP { + currentMCPTools = nil + } toolPlan, err := buildCommandToolPlan( ctx, toolList, @@ -767,6 +776,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo var currentCM model.ToolCallingChatModel var currentCtxLimit int currentPlanMode := startMode.IsPlan() + currentRole := "" // ToolSearch counts are derived on demand from the latest persisted policy, // MCP catalog and task mode. Candidate agents can be discarded by revision @@ -775,7 +785,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo // never installed. toolSearchStats := func() web.ToolSearchCounts { cmMu.Lock() - cm, planMode := currentCM, currentPlanMode + cm, planMode, roleName := currentCM, currentPlanMode, currentRole cmMu.Unlock() if cm == nil { return web.ToolSearchCounts{} @@ -789,6 +799,8 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo if planMode { toolList = buildPlanTools() toolMode = agent.ToolModePlan + } else if _, ok := config.LoadAgentRoles(taskPwd)[roleName]; roleName != "" && !ok { + return web.ToolSearchCounts{} } var currentMCPTools []tool.BaseTool if mt := mcpToolsPtr.Load(); mt != nil { @@ -797,6 +809,9 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo if excludeInteractive { currentMCPTools = dropInteractiveTools(currentMCPTools) } + if planMode { + currentMCPTools = nil + } plan, planErr := buildCommandToolPlan( ctx, toolList, @@ -816,9 +831,9 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo return nil, err } cmMu.Lock() - plan := currentPlanMode + plan, roleName := currentPlanMode, currentRole cmMu.Unlock() - ag, err := makeAgent(cm, ctxLimit, plan) + ag, err := makeAgent(cm, ctxLimit, plan, roleName) if err != nil { return nil, err // don't poison the cache with a model whose agent failed to build } @@ -833,7 +848,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo cmMu.Lock() previousPlanMode := currentPlanMode currentPlanMode = planMode - cm, ctxLimit := currentCM, currentCtxLimit + cm, ctxLimit, roleName := currentCM, currentCtxLimit, currentRole cmMu.Unlock() if cm == nil { ag, createErr := createAgent(providerName, modelName) @@ -844,7 +859,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo } return ag, createErr } - ag, makeErr := makeAgent(cm, ctxLimit, planMode) + ag, makeErr := makeAgent(cm, ctxLimit, planMode, roleName) if makeErr != nil { cmMu.Lock() currentPlanMode = previousPlanMode @@ -853,11 +868,50 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo return ag, makeErr } + rebuildForRole := func( + roleName, currentProvider, currentModel string, + ) (*web.AgentRoleBuild, error) { + _, targetProvider, targetModel, resolveErr := resolveWebCustomAgentSelection( + taskPwd, roleName, currentProvider, currentModel, + ) + if resolveErr != nil { + return nil, resolveErr + } + cmMu.Lock() + cm, ctxLimit, planMode := currentCM, currentCtxLimit, currentPlanMode + cmMu.Unlock() + if cm == nil || targetProvider != currentProvider || targetModel != currentModel { + var modelErr error + cm, ctxLimit, modelErr = newChatModel(targetProvider, targetModel) + if modelErr != nil { + return nil, modelErr + } + } + ag, makeErr := makeAgent(cm, ctxLimit, planMode, roleName) + if makeErr != nil { + return nil, makeErr + } + cmMu.Lock() + currentRole = roleName + currentCM = cm + currentCtxLimit = ctxLimit + cmMu.Unlock() + return &web.AgentRoleBuild{ + Agent: ag, Provider: targetProvider, Model: targetModel, + }, nil + } + breakdownFn := func() usage.ContextBreakdown { var b usage.ContextBreakdown skillDesc := taskLoader.Descriptions() b.SkillsTokens = usage.Estimate(skillDesc) systemPrompt, _ := renderPrompts() + cmMu.Lock() + roleName := currentRole + cm := currentCM + planMode := currentPlanMode + cmMu.Unlock() + systemPrompt = withLoadedCustomAgentPrompt(systemPrompt, taskPwd, roleName) b.SystemPromptTokens = usage.Estimate(systemPrompt) - b.SkillsTokens if b.SystemPromptTokens < 0 { b.SystemPromptTokens = 0 @@ -867,16 +921,17 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo b.MCPToolsTokens += estimateToolTokens(ctx, t) } } - cmMu.Lock() - cm := currentCM - cmMu.Unlock() if cm != nil { currentCfg, loadErr := config.LoadConfig() if loadErr != nil { return b } total := 0 - for _, at := range buildAllTools(cm, currentCfg) { + toolList := buildAllTools(cm, currentCfg) + if planMode { + toolList = buildPlanTools() + } + for _, at := range toolList { total += estimateToolTokens(ctx, at) } b.SystemToolsTokens = total @@ -918,6 +973,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo ToolSearchStats: toolSearchStats, CreateAgent: createAgent, RebuildForMode: rebuildForMode, + RebuildForRole: rebuildForRole, FlowLoader: taskFlowLoader, // Recorders the engine creates later (lazy create / session switch // in chat.go) get the same title hook as trec above. @@ -960,6 +1016,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo Agent: bootEC.Agent, CreateAgent: bootEC.CreateAgent, RebuildForMode: bootEC.RebuildForMode, + RebuildForRole: bootEC.RebuildForRole, NewEngine: func(taskID, taskPwd, modeStr string) (*web.EngineConfig, error) { return buildWebTask(taskID, taskPwd, modeStr, nil, false) }, diff --git a/internal/config/agent_roles.go b/internal/config/agent_roles.go index 45310c21..6cbd71b5 100644 --- a/internal/config/agent_roles.go +++ b/internal/config/agent_roles.go @@ -1,7 +1,6 @@ package config import ( - "encoding/json" "fmt" "io" "os" @@ -9,6 +8,8 @@ import ( "regexp" "sort" "strings" + + "gopkg.in/yaml.v3" ) const maxAgentRoleFileBytes = 64 << 10 @@ -16,15 +17,13 @@ const maxAgentRoleFileBytes = 64 << 10 var agentRoleNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) var builtinAgentRoleNames = map[string]bool{ - "explore": true, "general": true, "coordinator": true, "coder": true, + "default": true, "explore": true, "general": true, "coordinator": true, "coder": true, } -type agentRoleFile struct { - Name string `json:"name,omitempty"` - Description string `json:"description"` - Profile string `json:"profile,omitempty"` - Instructions string `json:"instructions"` - Model string `json:"model,omitempty"` +type agentRoleFrontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Model string `yaml:"model,omitempty"` } func normalizeAgentRole(name string, role AgentRoleConfig) (AgentRoleConfig, error) { @@ -37,25 +36,58 @@ func normalizeAgentRole(name string, role AgentRoleConfig) (AgentRoleConfig, err } role.Description = strings.TrimSpace(role.Description) role.Instructions = strings.TrimSpace(role.Instructions) - role.Profile = strings.TrimSpace(role.Profile) role.Model = strings.TrimSpace(role.Model) if role.Description == "" || role.Instructions == "" { - return role, fmt.Errorf("agent role %q requires description and instructions", name) + return role, fmt.Errorf("agent role %q requires description and Markdown instructions", name) } if len(role.Description) > 1024 || len(role.Instructions) > 32<<10 || len(role.Model) > 256 { return role, fmt.Errorf("agent role %q exceeds metadata limits", name) } - if role.Profile == "" { - role.Profile = "explore" - } - switch role.Profile { - case "explore", "general", "coordinator": - default: - return role, fmt.Errorf("agent role %q has invalid profile %q", name, role.Profile) + if role.Model != "" && role.Model != "small" { + provider, model, ok := strings.Cut(role.Model, "/") + if !ok || strings.TrimSpace(provider) == "" || strings.TrimSpace(model) == "" { + return role, fmt.Errorf( + "agent role %q model must be \"small\" or use provider/model format", name, + ) + } } return role, nil } +func parseAgentRoleMarkdown(content []byte) (agentRoleFrontmatter, string, error) { + text := strings.TrimPrefix(string(content), "\uFEFF") + text = strings.ReplaceAll(text, "\r\n", "\n") + lines := strings.Split(text, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return agentRoleFrontmatter{}, "", fmt.Errorf("missing YAML frontmatter") + } + end := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + end = i + break + } + } + if end < 0 { + return agentRoleFrontmatter{}, "", fmt.Errorf("unterminated YAML frontmatter") + } + + var meta agentRoleFrontmatter + dec := yaml.NewDecoder(strings.NewReader(strings.Join(lines[1:end], "\n"))) + dec.KnownFields(true) + if err := dec.Decode(&meta); err != nil { + return agentRoleFrontmatter{}, "", fmt.Errorf("invalid YAML frontmatter: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + err = fmt.Errorf("multiple YAML documents") + } + return agentRoleFrontmatter{}, "", fmt.Errorf("invalid YAML frontmatter: %w", err) + } + return meta, strings.TrimSpace(strings.Join(lines[end+1:], "\n")), nil +} + func loadAgentRoleDir(dir string, dst map[string]AgentRoleConfig) { entries, err := os.ReadDir(dir) if err != nil { @@ -67,7 +99,8 @@ func loadAgentRoleDir(dir string, dst map[string]AgentRoleConfig) { sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) seen := make(map[string]bool) for _, entry := range entries { - if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || filepath.Ext(entry.Name()) != ".json" { + if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || + !strings.HasSuffix(entry.Name(), ".agent.md") { continue } path := filepath.Join(dir, entry.Name()) @@ -81,28 +114,29 @@ func loadAgentRoleDir(dir string, dst map[string]AgentRoleConfig) { Logger().Printf("[agents] skip %s: %v", path, err) continue } - dec := json.NewDecoder(io.LimitReader(file, maxAgentRoleFileBytes+1)) - dec.DisallowUnknownFields() - var raw agentRoleFile - err = dec.Decode(&raw) - if err == nil { - var trailing any - if trailingErr := dec.Decode(&trailing); trailingErr != io.EOF { - err = fmt.Errorf("trailing JSON") - } + openedInfo, statErr := file.Stat() + if statErr != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + _ = file.Close() + Logger().Printf("[agents] skip %s: file changed while opening", path) + continue } + content, err := io.ReadAll(io.LimitReader(file, maxAgentRoleFileBytes+1)) _ = file.Close() + if err == nil && len(content) > maxAgentRoleFileBytes { + err = fmt.Errorf("file exceeds 64 KiB") + } if err != nil { - Logger().Printf("[agents] skip malformed %s: %v", path, err) + Logger().Printf("[agents] skip %s: %v", path, err) continue } - name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - if strings.TrimSpace(raw.Name) != "" { - name = strings.TrimSpace(raw.Name) + raw, instructions, err := parseAgentRoleMarkdown(content) + if err != nil { + Logger().Printf("[agents] skip malformed %s: %v", path, err) + continue } + name := strings.TrimSpace(raw.Name) role, err := normalizeAgentRole(name, AgentRoleConfig{ - Description: raw.Description, Profile: raw.Profile, - Instructions: raw.Instructions, Model: raw.Model, + Description: raw.Description, Instructions: instructions, Model: raw.Model, }) if err != nil { Logger().Printf("[agents] skip %s: %v", path, err) @@ -117,33 +151,15 @@ func loadAgentRoleDir(dir string, dst map[string]AgentRoleConfig) { } } -// LoadAgentRoles follows Codex's layered role idea with jcode-native JSON: -// user files < project files < inline config. Malformed files are warning-only -// and cannot override a valid lower layer. -func LoadAgentRoles(pwd string, cfg *Config) map[string]AgentRoleConfig { +// LoadAgentRoles discovers Markdown role definitions with project files +// overriding user files. Malformed files are warning-only and cannot override +// a valid lower layer. +func LoadAgentRoles(pwd string) map[string]AgentRoleConfig { roles := make(map[string]AgentRoleConfig) loadAgentRoleDir(filepath.Join(ConfigDir(), "agents"), roles) if pwd != "" { loadAgentRoleDir(filepath.Join(pwd, ".jcode", "agents"), roles) } - if cfg != nil { - names := make([]string, 0, len(cfg.Agents)) - for name := range cfg.Agents { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - if cfg.Agents[name] == nil { - continue - } - role, err := normalizeAgentRole(name, *cfg.Agents[name]) - if err != nil { - Logger().Printf("[agents] skip inline role: %v", err) - continue - } - roles[name] = role - } - } return roles } diff --git a/internal/config/agent_roles_test.go b/internal/config/agent_roles_test.go index c013c6c5..1ffa1d85 100644 --- a/internal/config/agent_roles_test.go +++ b/internal/config/agent_roles_test.go @@ -17,31 +17,74 @@ func writeRoleFile(t *testing.T, path, body string) { } } +func roleMarkdown(name, description, model, instructions string) string { + modelLine := "" + if model != "" { + modelLine = "model: " + model + "\n" + } + return "---\nname: " + name + "\ndescription: " + description + "\n" + + modelLine + "---\n\n" + instructions + "\n" +} + func TestLoadAgentRolesPrecedenceAndValidation(t *testing.T) { home := t.TempDir() project := t.TempDir() t.Setenv("HOME", home) - userRole := filepath.Join(home, ".jcode", "agents", "reviewer.json") - writeRoleFile(t, userRole, `{"description":"user reviewer","profile":"explore","instructions":"read only"}`) - writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "reviewer.json"), - `{"description":"project reviewer","profile":"general","instructions":"project rules"}`) - writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "broken.json"), `{"profile":"general"}`) - writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "huge.json"), - `{"description":"huge","instructions":"`+strings.Repeat("x", maxAgentRoleFileBytes)+`"}`) - if err := os.Symlink(userRole, filepath.Join(project, ".jcode", "agents", "linked.json")); err != nil { + userRole := filepath.Join(home, ".jcode", "agents", "reviewer.agent.md") + writeRoleFile(t, userRole, roleMarkdown("reviewer", "user reviewer", "", "User rules.")) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "reviewer.agent.md"), + roleMarkdown("reviewer", "project reviewer", "small", "Project rules.")) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "missing-name.agent.md"), `--- +description: missing name +--- + +Instructions.`) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "missing-description.agent.md"), `--- +name: missing-description +--- + +Instructions.`) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "empty-body.agent.md"), `--- +name: empty-body +description: no instructions +--- +`) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "unknown.agent.md"), `--- +name: unknown +description: unknown field +tools: read +--- + +Instructions.`) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "bad-model.agent.md"), `--- +name: bad-model +description: invalid model +model: missing-provider +--- + +Instructions.`) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "huge.agent.md"), + "---\nname: huge\ndescription: huge\n---\n"+strings.Repeat("x", maxAgentRoleFileBytes)) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "general.agent.md"), + roleMarkdown("general", "reserved", "", "Instructions.")) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "legacy.json"), + `{"name":"legacy","description":"legacy","instructions":"ignored"}`) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "plain.md"), + roleMarkdown("plain", "wrong suffix", "", "Ignored.")) + if err := os.Symlink(userRole, filepath.Join(project, ".jcode", "agents", "linked.agent.md")); err != nil { t.Fatal(err) } - cfg := &Config{Agents: map[string]*AgentRoleConfig{ - "reviewer": {Description: "inline reviewer", Profile: "coordinator", Instructions: "inline rules", Model: "small"}, - "general": {Description: "reserved", Instructions: "bad"}, - }} - roles := LoadAgentRoles(project, cfg) + roles := LoadAgentRoles(project) role, ok := roles["reviewer"] - if !ok || role.Description != "inline reviewer" || role.Profile != "coordinator" || role.Model != "small" { + if !ok || role.Description != "project reviewer" || + role.Instructions != "Project rules." || role.Model != "small" { t.Fatalf("reviewer = %+v, ok=%v", role, ok) } - for _, rejected := range []string{"broken", "huge", "linked", "general"} { + for _, rejected := range []string{ + "missing-name", "missing-description", "empty-body", "unknown", "bad-model", + "huge", "linked", "general", "legacy", "plain", + } { if _, ok := roles[rejected]; ok { t.Errorf("unsafe/malformed role %q was loaded", rejected) } @@ -52,12 +95,58 @@ func TestLoadAgentRolesProjectOverridesUser(t *testing.T) { home := t.TempDir() project := t.TempDir() t.Setenv("HOME", home) - writeRoleFile(t, filepath.Join(home, ".jcode", "agents", "audit.json"), - `{"description":"user","instructions":"user"}`) - writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "audit.json"), - `{"description":"project","profile":"general","instructions":"project"}`) - role := LoadAgentRoles(project, &Config{})["audit"] - if role.Description != "project" || role.Profile != "general" { + writeRoleFile(t, filepath.Join(home, ".jcode", "agents", "audit.agent.md"), + roleMarkdown("audit", "user", "", "User instructions.")) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "audit.agent.md"), + roleMarkdown("audit", "project", "", "Project instructions.")) + writeRoleFile(t, filepath.Join(home, ".jcode", "agents", "fallback.agent.md"), + roleMarkdown("fallback", "user fallback", "", "User fallback instructions.")) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "fallback.agent.md"), `--- +name: fallback +description: broken project override +--- +`) + role := LoadAgentRoles(project)["audit"] + if role.Description != "project" || role.Instructions != "Project instructions." { t.Fatalf("project precedence lost: %+v", role) } + fallback := LoadAgentRoles(project)["fallback"] + if fallback.Description != "user fallback" { + t.Fatalf("malformed project role hid valid user role: %+v", fallback) + } +} + +func TestLoadAgentRolesUsesFirstValidDuplicateByFilename(t *testing.T) { + home := t.TempDir() + project := t.TempDir() + t.Setenv("HOME", home) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "00-broken.agent.md"), `--- +name: duplicate +description: broken +--- +`) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "10-first.agent.md"), + roleMarkdown("duplicate", "first", "", "First instructions.")) + writeRoleFile(t, filepath.Join(project, ".jcode", "agents", "20-second.agent.md"), + roleMarkdown("duplicate", "second", "", "Second instructions.")) + + role := LoadAgentRoles(project)["duplicate"] + if role.Description != "first" || role.Instructions != "First instructions." { + t.Fatalf("duplicate resolution = %+v", role) + } +} + +func TestParseAgentRoleMarkdownSupportsYAMLAndCRLF(t *testing.T) { + content := []byte("\ufeff---\r\nname: reviewer\r\ndescription: |\r\n Review correctness\r\n and security\r\nmodel: provider/model\r\n---\r\n\r\nLead with findings.\r\n") + meta, body, err := parseAgentRoleMarkdown(content) + if err != nil { + t.Fatal(err) + } + if meta.Name != "reviewer" || meta.Description != "Review correctness\nand security\n" || + meta.Model != "provider/model" { + t.Fatalf("frontmatter = %+v", meta) + } + if body != "Lead with findings." { + t.Fatalf("body = %q", body) + } } diff --git a/internal/config/config.go b/internal/config/config.go index 76f691c1..5b57d175 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -164,14 +164,12 @@ type SubagentConfig struct { MaxDepth int `json:"max_depth,omitempty"` } -// AgentRoleConfig defines a named custom subagent role. Profile is the -// capability ceiling; Instructions can narrow behavior but can never expand -// tools beyond that profile. +// AgentRoleConfig defines a named custom role for a top-level or delegated +// agent. Roles inherit the caller's mode, approval policy, sandbox, and tools. type AgentRoleConfig struct { - Description string `json:"description"` - Profile string `json:"profile,omitempty"` // explore | general | coordinator - Instructions string `json:"instructions"` - Model string `json:"model,omitempty"` + Description string + Instructions string + Model string } // MemoryConfig controls cross-session learned memory (the file-based store @@ -372,18 +370,17 @@ type Config struct { // Deprecated: use Model field with "provider/model" format instead. Provider string `json:"provider,omitempty"` - MaxIterations int `json:"max_iterations,omitempty"` - SSHAliases []SSHAlias `json:"ssh_aliases,omitempty"` - DockerAliases []DockerAlias `json:"docker_aliases,omitempty"` - MCPServers map[string]*MCPServer `json:"mcp_servers,omitempty"` - Telemetry *TelemetryConfig `json:"telemetry,omitempty"` - Budget *BudgetConfig `json:"budget,omitempty"` - Compaction *CompactionConfig `json:"compaction,omitempty"` - Prompt *PromptConfig `json:"prompt,omitempty"` - Subagent *SubagentConfig `json:"subagent,omitempty"` - Agents map[string]*AgentRoleConfig `json:"agents,omitempty"` - Team *TeamConfig `json:"team,omitempty"` - Memory *MemoryConfig `json:"memory,omitempty"` + MaxIterations int `json:"max_iterations,omitempty"` + SSHAliases []SSHAlias `json:"ssh_aliases,omitempty"` + DockerAliases []DockerAlias `json:"docker_aliases,omitempty"` + MCPServers map[string]*MCPServer `json:"mcp_servers,omitempty"` + Telemetry *TelemetryConfig `json:"telemetry,omitempty"` + Budget *BudgetConfig `json:"budget,omitempty"` + Compaction *CompactionConfig `json:"compaction,omitempty"` + Prompt *PromptConfig `json:"prompt,omitempty"` + Subagent *SubagentConfig `json:"subagent,omitempty"` + Team *TeamConfig `json:"team,omitempty"` + Memory *MemoryConfig `json:"memory,omitempty"` // AutoApprove sets the default approval mode to auto on startup. // diff --git a/internal/session/agent_roundtrip_test.go b/internal/session/agent_roundtrip_test.go new file mode 100644 index 00000000..c698a109 --- /dev/null +++ b/internal/session/agent_roundtrip_test.go @@ -0,0 +1,71 @@ +package session + +import "testing" + +func TestReconstructStateAgentLastChangeWins(t *testing.T) { + st := ReconstructState([]Entry{ + {Type: EntrySessionStart, Agent: "reviewer"}, + {Type: EntryAgentChange, Agent: "builder"}, + {Type: EntryAgentChange, Agent: ""}, + }) + if st.Agent != "" { + t.Fatalf("Agent=%q, want Default", st.Agent) + } +} + +func TestRecorderPersistsAgentInStartAndIndex(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + project := t.TempDir() + rec, err := NewRecorder(project, "provider", "model") + if err != nil { + t.Fatal(err) + } + rec.SetAgent("reviewer") + rec.RecordUser("hello") + id := rec.UUID() + rec.Close() + + entries, err := LoadSession(id) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 || entries[0].Type != EntrySessionStart || entries[0].Agent != "reviewer" { + t.Fatalf("session start=%+v", entries) + } + metas, err := ListSessions(project) + if err != nil { + t.Fatal(err) + } + if len(metas) != 1 || metas[0].Agent != "reviewer" { + t.Fatalf("metas=%+v", metas) + } +} + +func TestRecorderPersistsAgentChange(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + project := t.TempDir() + rec, err := NewRecorder(project, "provider", "model") + if err != nil { + t.Fatal(err) + } + rec.RecordUser("hello") + rec.SetAgent("builder") + id := rec.UUID() + rec.Close() + + entries, err := LoadSession(id) + if err != nil { + t.Fatal(err) + } + st := ReconstructState(entries) + if st.Agent != "builder" { + t.Fatalf("Agent=%q, want builder", st.Agent) + } + metas, err := ListSessions(project) + if err != nil { + t.Fatal(err) + } + if len(metas) != 1 || metas[0].Agent != "builder" { + t.Fatalf("metas=%+v", metas) + } +} diff --git a/internal/session/history.go b/internal/session/history.go index 22c95b39..62cd59ac 100644 --- a/internal/session/history.go +++ b/internal/session/history.go @@ -130,6 +130,7 @@ type SessionState struct { Todos []TodoSnapshotItem // last todo snapshot, nil if none Goal *GoalSnapshot // nil if no goal events or last event cleared it Mode string // last unified session mode (approval/plan/full_access); empty = approval + Agent string // selected top-level custom agent; empty = default EnvTarget string // last environment (local/ssh alias) SystemPrompt string // recorded system prompt for KV-cache-friendly resume EnvInfo string // environment snapshot at recording time @@ -160,6 +161,9 @@ func ReconstructState(entries []Entry) *SessionState { } switch e.Type { + case EntrySessionStart: + state.Agent = e.Agent + case EntryUser: msgs = append(msgs, entryToUserMessage(e)) @@ -264,6 +268,9 @@ func ReconstructState(entries []Entry) *SessionState { case EntryModeChange: state.Mode = e.Mode + case EntryAgentChange: + state.Agent = e.Agent + case EntrySystemPrompt: state.SystemPrompt = e.Content state.EnvInfo = e.EnvInfo diff --git a/internal/session/session.go b/internal/session/session.go index 6d29435c..73686367 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -36,6 +36,7 @@ const ( EntrySubagentResult EntryType = "subagent_result" EntrySubagentAsync EntryType = "subagent_async" EntryModeChange EntryType = "mode_change" + EntryAgentChange EntryType = "agent_change" EntryCompact EntryType = "compact" EntryBudgetWarning EntryType = "budget_warning" EntrySystemPrompt EntryType = "system_prompt" @@ -94,6 +95,7 @@ type Entry struct { Project string `json:"project,omitempty"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` + Agent string `json:"agent,omitempty"` Content string `json:"content,omitempty"` Name string `json:"name,omitempty"` // tool name Args string `json:"args,omitempty"` // tool args JSON @@ -162,6 +164,7 @@ type SessionMeta struct { Project string `json:"project"` Provider string `json:"provider"` Model string `json:"model"` + Agent string `json:"agent,omitempty"` StartTime string `json:"start_time"` // RFC3339 Title string `json:"title,omitempty"` // Task metadata. Additive — legacy index files simply lack these keys, which @@ -332,6 +335,7 @@ type Recorder struct { project string provider string model string + agent string startTime time.Time file *os.File mu sync.Mutex @@ -399,6 +403,29 @@ func (r *Recorder) SetModel(model string) { r.mu.Unlock() } +// SetAgent records the selected top-level custom agent. Empty means the default +// agent. Before the first message it is buffered into session_start; after a +// session exists it also appends an agent_change event for replay. +func (r *Recorder) SetAgent(agent string) { + agent = strings.TrimSpace(agent) + r.mu.Lock() + if r.agent == agent { + r.mu.Unlock() + return + } + r.agent = agent + hasRecording := r.file != nil + id := r.uuid + r.mu.Unlock() + if !hasRecording { + return + } + _, _ = UpdateSessionMeta(id, func(m *SessionMeta) { + m.Agent = agent + }) + _ = r.writeEntry(Entry{Type: EntryAgentChange, Agent: agent}) +} + // ValidateSessionID checks that a session ID is safe for use as a filename. // It rejects empty IDs, path traversal sequences, and path separators. func ValidateSessionID(id string) error { @@ -801,6 +828,7 @@ func (r *Recorder) ensureFile() error { Project: r.project, Provider: r.provider, Model: r.model, + Agent: r.agent, Timestamp: r.startTime.Format(time.RFC3339), } data, err := json.Marshal(startEntry) @@ -818,6 +846,7 @@ func (r *Recorder) ensureFile() error { Project: r.project, Provider: r.provider, Model: r.model, + Agent: r.agent, StartTime: r.startTime.Format(time.RFC3339), }) } diff --git a/internal/team/manager.go b/internal/team/manager.go index eec4cd4c..00df1d0c 100644 --- a/internal/team/manager.go +++ b/internal/team/manager.go @@ -207,12 +207,9 @@ func (m *Manager) SpawnTeammate(ctx context.Context, cfg SpawnConfig) (string, e var agentType string roleInstructions := "" if role, ok := m.deps.AgentRoles[roleName]; ok { - agentType = role.Profile - if agentType == "coordinator" { - agentType = AgentTypeGeneral - } + agentType = AgentTypeGeneral roleInstructions = role.Instructions - if cfg.Model == "" { + if role.Model != "" { cfg.Model = role.Model } } else { diff --git a/internal/tools/flow_spawn.go b/internal/tools/flow_spawn.go index 482bf2fe..dfa5dc19 100644 --- a/internal/tools/flow_spawn.go +++ b/internal/tools/flow_spawn.go @@ -55,9 +55,9 @@ func NewFlowSpawn(deps FlowSpawnDeps) flow.SpawnFunc { instructions := "" modelRef := spec.Model if role, ok := deps.AgentRoles[roleName]; ok { - profile = role.Profile + profile = AgentTypeGeneral instructions = role.Instructions - if modelRef == "" { + if role.Model != "" { modelRef = role.Model } } else if profile != AgentTypeExplore && profile != AgentTypeGeneral && profile != AgentTypeCoordinator { diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index 293d2146..89e35de0 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -82,7 +82,10 @@ func (e *Env) NewSubagentTool(deps *SubagentDeps) tool.InvokableTool { for _, name := range config.AgentRoleNames(deps.AgentRoles) { agentTypes = append(agentTypes, name) role := deps.AgentRoles[name] - roleHelp += fmt.Sprintf(" %s: %s (profile: %s).", name, role.Description, role.Profile) + roleHelp += fmt.Sprintf(" %s: %s.", name, role.Description) + if role.Model != "" { + roleHelp += fmt.Sprintf(" Its model is fixed to %q.", role.Model) + } } info := &schema.ToolInfo{ Name: "subagent", @@ -247,9 +250,9 @@ func (s *subagentTool) InvokableRun(ctx context.Context, argumentsInJSON string, roleInstructions := "" modelRef := input.Model if role, ok := s.deps.AgentRoles[roleName]; ok { - profile = role.Profile + profile = AgentTypeGeneral roleInstructions = role.Instructions - if modelRef == "" { + if role.Model != "" { modelRef = role.Model } } else if profile != AgentTypeExplore && profile != AgentTypeGeneral && profile != AgentTypeCoordinator { diff --git a/internal/tools/subagent_model_routing_test.go b/internal/tools/subagent_model_routing_test.go index 591cc631..0a01c7cf 100644 --- a/internal/tools/subagent_model_routing_test.go +++ b/internal/tools/subagent_model_routing_test.go @@ -187,14 +187,14 @@ func TestSubagentModelRouting_ExplicitRef(t *testing.T) { } } -func TestSubagentCustomRoleModelDefaultAndExplicitOverride(t *testing.T) { +func TestSubagentCustomRoleModelTakesPrecedence(t *testing.T) { t.Setenv("HOME", t.TempDir()) srv, captured := newMockOpenAI(t) defer srv.Close() st := routingFixture(t, srv.URL, "mock/small-model") st.deps.AgentRoles = map[string]config.AgentRoleConfig{ - "reviewer": {Description: "review", Profile: "explore", Instructions: "review carefully", Model: "small"}, + "reviewer": {Description: "review", Instructions: "review carefully", Model: "small"}, } if _, err := st.InvokableRun(context.Background(), `{"name":"role-default","description":"d","prompt":"done","agent_type":"reviewer"}`); err != nil { @@ -205,7 +205,7 @@ func TestSubagentCustomRoleModelDefaultAndExplicitOverride(t *testing.T) { t.Fatal(err) } models := captured() - if len(models) < 2 || models[0] != "small-model" || models[len(models)-1] != "explicit" { + if len(models) < 2 || models[0] != "small-model" || models[len(models)-1] != "small-model" { t.Fatalf("custom role model routing = %v", models) } } diff --git a/internal/tools/subagent_test.go b/internal/tools/subagent_test.go index 9895a93e..b694f6fe 100644 --- a/internal/tools/subagent_test.go +++ b/internal/tools/subagent_test.go @@ -69,7 +69,7 @@ func TestSubagentSchemaExplainsDelegatedWriteGrant(t *testing.T) { func TestSubagentSchemaAdvertisesCustomRoles(t *testing.T) { env := NewEnv(t.TempDir(), "darwin/arm64") info, err := env.NewSubagentTool(&SubagentDeps{AgentRoles: map[string]config.AgentRoleConfig{ - "reviewer": {Description: "Review security boundaries", Profile: "explore", Instructions: "Lead with findings."}, + "reviewer": {Description: "Review security boundaries", Instructions: "Lead with findings."}, }}).Info(context.Background()) if err != nil { t.Fatal(err) diff --git a/internal/tools/team_tools.go b/internal/tools/team_tools.go index 05322f03..9377e4b2 100644 --- a/internal/tools/team_tools.go +++ b/internal/tools/team_tools.go @@ -109,7 +109,10 @@ func teamSpawnParams(manager *team.Manager) *schema.ParamsOneOf { for _, name := range manager.AgentRoleNames() { agentTypes = append(agentTypes, name) role := manager.AgentRole(name) - roleHelp += fmt.Sprintf(" %s: %s (profile: %s).", name, role.Description, role.Profile) + roleHelp += fmt.Sprintf(" %s: %s.", name, role.Description) + if role.Model != "" { + roleHelp += fmt.Sprintf(" Its model is fixed to %q.", role.Model) + } } } properties.Set("agent_type", &jsonschema.Schema{ diff --git a/internal/tools/team_tools_test.go b/internal/tools/team_tools_test.go index c995d91b..ffd28855 100644 --- a/internal/tools/team_tools_test.go +++ b/internal/tools/team_tools_test.go @@ -72,7 +72,7 @@ func TestTeamSpawnSchemaDeclaresProfilesDefaultsAndGrant(t *testing.T) { func TestTeamSpawnSchemaAdvertisesCustomRoles(t *testing.T) { manager := team.NewManager(&team.ManagerDeps{AgentRoles: map[string]config.AgentRoleConfig{ - "reviewer": {Description: "Review a patch", Profile: "explore", Instructions: "read only"}, + "reviewer": {Description: "Review a patch", Instructions: "Review carefully."}, }}) info, err := NewTeamSpawnTool(manager).Info(context.Background()) if err != nil { diff --git a/internal/tui/messages.go b/internal/tui/messages.go index f84b305c..5c0e4d28 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -119,6 +119,8 @@ type SessionEntry struct { // Mode change Mode string + // Custom agent change (empty means Default) + Agent string // Compact fields Summary string diff --git a/internal/tui/session_helper.go b/internal/tui/session_helper.go index 6823d173..41effbe4 100644 --- a/internal/tui/session_helper.go +++ b/internal/tui/session_helper.go @@ -8,6 +8,11 @@ func ConvertSessionEntries(entries []session.Entry) []SessionEntry { result := make([]SessionEntry, 0, len(entries)) for _, e := range entries { if e.Type == session.EntrySessionStart { + if e.Agent != "" { + result = append(result, SessionEntry{ + Type: string(session.EntryAgentChange), Agent: e.Agent, + }) + } continue } @@ -48,6 +53,8 @@ func ConvertSessionEntries(entries []session.Entry) []SessionEntry { Todos: todos, // Mode change Mode: e.Mode, + // Custom agent change + Agent: e.Agent, // Compact fields Summary: e.Summary, CompactedN: e.CompactedN, diff --git a/internal/tui/session_helper_test.go b/internal/tui/session_helper_test.go new file mode 100644 index 00000000..a2e174ae --- /dev/null +++ b/internal/tui/session_helper_test.go @@ -0,0 +1,23 @@ +package tui + +import ( + "testing" + + "github.com/cnjack/jcode/internal/session" +) + +func TestConvertSessionEntriesShowsInitialAndChangedAgent(t *testing.T) { + got := ConvertSessionEntries([]session.Entry{ + {Type: session.EntrySessionStart, Agent: "reviewer"}, + {Type: session.EntryAgentChange, Agent: ""}, + }) + if len(got) != 2 { + t.Fatalf("entries=%+v", got) + } + if got[0].Type != string(session.EntryAgentChange) || got[0].Agent != "reviewer" { + t.Fatalf("initial agent entry=%+v", got[0]) + } + if got[1].Type != string(session.EntryAgentChange) || got[1].Agent != "" { + t.Fatalf("changed agent entry=%+v", got[1]) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index d36dc1e0..fd14c3bb 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1568,6 +1568,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen m.lines = append(m.lines, textLine(fmt.Sprintf(" %s Mode changed to: %s", toolLabelStyle.Render("🔄"), toolNameStyle.Render(e.Mode)))) + case string(session.EntryAgentChange): + agentName := e.Agent + if agentName == "" { + agentName = "Default" + } + m.lines = append(m.lines, textLine(fmt.Sprintf(" %s Agent changed to: %s", + toolLabelStyle.Render("🤖"), + toolNameStyle.Render(agentName)))) case string(session.EntryCompact): m.lines = append(m.lines, textLine(fmt.Sprintf(" %s Context compacted: %d messages summarized", toolSuccessStyle.Render("✓"), diff --git a/internal/web/agents.go b/internal/web/agents.go new file mode 100644 index 00000000..323c6b73 --- /dev/null +++ b/internal/web/agents.go @@ -0,0 +1,102 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "sort" + "strings" + + "github.com/cnjack/jcode/internal/config" +) + +type agentRoleView struct { + Name string `json:"name"` + Description string `json:"description"` + Model string `json:"model,omitempty"` +} + +func (s *Server) handleListAgents(w http.ResponseWriter, _ *http.Request) { + eng := s.activeEngine() + if eng == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + return + } + roles := config.LoadAgentRoles(eng.pwd) + names := make([]string, 0, len(roles)) + for name := range roles { + names = append(names, name) + } + sort.Strings(names) + agents := make([]agentRoleView, 0, len(names)) + for _, name := range names { + role := roles[name] + agents = append(agents, agentRoleView{ + Name: name, + Description: role.Description, + Model: role.Model, + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "agents": agents, + "current": eng.curAgentRole(), + }) +} + +func (s *Server) handleSwitchAgent(w http.ResponseWriter, r *http.Request) { + eng := s.activeEngine() + if eng == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + return + } + var req struct { + Agent string `json:"agent"` + } + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<16)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + req.Agent = strings.TrimSpace(req.Agent) + if req.Agent != "" { + if _, ok := config.LoadAgentRoles(eng.pwd)[req.Agent]; !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown custom agent"}) + return + } + } + if req.Agent == eng.curAgentRole() { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "agent": req.Agent}) + return + } + if eng.rebuildForRole == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "custom agent switching is not supported"}) + return + } + + eng.rebuildMu.Lock() + oldProvider, oldModel, _ := eng.modelSnapshot() + built, err := eng.rebuildForRole(req.Agent, oldProvider, oldModel) + if err != nil { + eng.rebuildMu.Unlock() + config.Logger().Printf("[web] custom agent switch rebuild error: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to switch custom agent"}) + return + } + eng.applyAgentRoleSwitch(req.Agent, built) + eng.rebuildMu.Unlock() + + s.wsBroker.Broadcast(WSEvent{Type: "agent_changed", TaskID: eng.taskID, Data: map[string]string{ + "agent": req.Agent, + }}) + if built.Provider != oldProvider || built.Model != oldModel { + s.wsBroker.Broadcast(WSEvent{Type: "model_changed", TaskID: eng.taskID, Data: map[string]string{ + "provider": built.Provider, + "model": built.Model, + }}) + } + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", "agent": req.Agent, + "provider": built.Provider, "model": built.Model, + }) +} diff --git a/internal/web/agents_test.go b/internal/web/agents_test.go new file mode 100644 index 00000000..07679a3f --- /dev/null +++ b/internal/web/agents_test.go @@ -0,0 +1,106 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cloudwego/eino/adk" +) + +func writeAgentRole(t *testing.T, project, name, description, model string) { + t.Helper() + dir := filepath.Join(project, ".jcode", "agents") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + modelLine := "" + if model != "" { + modelLine = "model: " + model + "\n" + } + body := "---\nname: " + name + "\ndescription: " + description + "\n" + + modelLine + "---\n\nInstructions for " + name + ".\n" + if err := os.WriteFile(filepath.Join(dir, name+".agent.md"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestListAgentsSortedAndReportsCurrent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + project := t.TempDir() + writeAgentRole(t, project, "reviewer", "Review changes", "other/special") + writeAgentRole(t, project, "builder", "Build changes", "") + s := &Server{Engine: &Engine{pwd: project, agentRole: "reviewer"}} + + rec := httptest.NewRecorder() + s.handleListAgents(rec, httptest.NewRequest(http.MethodGet, "/api/agents", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } + var got struct { + Agents []agentRoleView `json:"agents"` + Current string `json:"current"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Current != "reviewer" { + t.Fatalf("current=%q, want reviewer", got.Current) + } + if len(got.Agents) != 2 || got.Agents[0].Name != "builder" || got.Agents[1].Name != "reviewer" { + t.Fatalf("agents=%+v, want builder then reviewer", got.Agents) + } +} + +func TestSwitchAgentAndRestoreDefault(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + project := t.TempDir() + writeAgentRole(t, project, "reviewer", "Review changes", "") + var rebuilt []string + eng := &Engine{ + pwd: project, + providerName: "test", + modelName: "model", + rebuildForRole: func(name, provider, model string) (*AgentRoleBuild, error) { + rebuilt = append(rebuilt, name) + if name == "reviewer" { + provider, model = "other", "special" + } + return &AgentRoleBuild{ + Agent: &adk.ChatModelAgent{}, Provider: provider, Model: model, + }, nil + }, + } + s := &Server{Engine: eng, wsBroker: NewWSBroker()} + + rec := httptest.NewRecorder() + s.handleSwitchAgent(rec, httptest.NewRequest(http.MethodPost, "/api/agent", strings.NewReader(`{"agent":"reviewer"}`))) + if rec.Code != http.StatusOK || eng.curAgentRole() != "reviewer" { + t.Fatalf("select: code=%d role=%q body=%q", rec.Code, eng.curAgentRole(), rec.Body.String()) + } + if provider, model, _ := eng.modelSnapshot(); provider != "other" || model != "special" { + t.Fatalf("selected model=%s/%s, want other/special", provider, model) + } + rec = httptest.NewRecorder() + s.handleSwitchAgent(rec, httptest.NewRequest(http.MethodPost, "/api/agent", strings.NewReader(`{"agent":""}`))) + if rec.Code != http.StatusOK || eng.curAgentRole() != "" { + t.Fatalf("default: code=%d role=%q body=%q", rec.Code, eng.curAgentRole(), rec.Body.String()) + } + if len(rebuilt) != 2 || rebuilt[0] != "reviewer" || rebuilt[1] != "" { + t.Fatalf("rebuilt=%v", rebuilt) + } +} + +func TestSwitchAgentRejectsUnknown(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{Engine: &Engine{pwd: t.TempDir()}, wsBroker: NewWSBroker()} + rec := httptest.NewRecorder() + s.handleSwitchAgent(rec, httptest.NewRequest(http.MethodPost, "/api/agent", strings.NewReader(`{"agent":"missing"}`))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } +} diff --git a/internal/web/chat.go b/internal/web/chat.go index 940499b0..8695eea6 100644 --- a/internal/web/chat.go +++ b/internal/web/chat.go @@ -179,7 +179,10 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str eng.emu.Lock() if eng.recorder == nil { rec, _ := session.NewRecorder(eng.pwd, eng.providerName, eng.modelName) - if sessionID != "" { + if rec != nil { + rec.SetAgent(eng.agentRole) + } + if rec != nil && sessionID != "" { rec.SetUUID(sessionID) } if rec != nil && eng.recorderInit != nil { @@ -193,7 +196,10 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str // Resume the client's session to keep all messages together. eng.recorder.Close() rec, _ := session.NewRecorder(eng.pwd, eng.providerName, eng.modelName) - rec.SetUUID(sessionID) + if rec != nil { + rec.SetAgent(eng.agentRole) + rec.SetUUID(sessionID) + } if rec != nil && eng.recorderInit != nil { eng.recorderInit(rec) } diff --git a/internal/web/engine.go b/internal/web/engine.go index 06d534ec..6787fbdc 100644 --- a/internal/web/engine.go +++ b/internal/web/engine.go @@ -74,6 +74,7 @@ type Engine struct { providerName string modelName string mode string // "build" / "plan" / "full_access" + agentRole string // custom top-level agent name; empty = default // --- per-task execution context --- env *tools.Env // fresh per task; todo/goal/bg hang off it @@ -94,6 +95,7 @@ type Engine struct { // a model or mode switch rebuilds only this task's agent. createAgent func(providerName, modelName string) (*adk.ChatModelAgent, error) rebuildForMode func(planMode bool) (*adk.ChatModelAgent, error) + rebuildForRole func(roleName, providerName, modelName string) (*AgentRoleBuild, error) // pumpCancel stops this engine's event-forwarding goroutine on teardown. pumpCancel context.CancelFunc @@ -115,6 +117,7 @@ type EngineConfig struct { Mode string ProviderName string ModelName string + AgentRole string Agent *adk.ChatModelAgent Env *tools.Env TodoStore *tools.TodoStore @@ -127,6 +130,7 @@ type EngineConfig struct { ToolSearchStats func() ToolSearchCounts CreateAgent func(providerName, modelName string) (*adk.ChatModelAgent, error) RebuildForMode func(planMode bool) (*adk.ChatModelAgent, error) + RebuildForRole func(roleName, providerName, modelName string) (*AgentRoleBuild, error) FlowLoader *flow.Loader // RecorderInit decorates recorders this engine creates AFTER build (lazy // creation / session switch in chat.go) so they get the same hooks (e.g. @@ -134,6 +138,14 @@ type EngineConfig struct { RecorderInit func(*session.Recorder) } +// AgentRoleBuild is an atomic custom-agent rebuild result. Provider/model are +// the actual model selected after applying the role's optional model override. +type AgentRoleBuild struct { + Agent *adk.ChatModelAgent + Provider string + Model string +} + // newEngine assembles an *Engine from the factory-produced config. The engine's // identity (taskID) is its recorder's session UUID unless an explicit resume id // was supplied. @@ -152,6 +164,7 @@ func newEngine(c *EngineConfig) *Engine { mode: c.Mode, providerName: c.ProviderName, modelName: c.ModelName, + agentRole: c.AgentRole, agent: c.Agent, env: c.Env, todoStore: c.TodoStore, @@ -164,6 +177,7 @@ func newEngine(c *EngineConfig) *Engine { toolSearchStats: c.ToolSearchStats, createAgent: c.CreateAgent, rebuildForMode: c.RebuildForMode, + rebuildForRole: c.RebuildForRole, flowLoader: c.FlowLoader, recorderInit: c.RecorderInit, } @@ -259,6 +273,12 @@ func (e *Engine) curMode() string { return e.mode } +func (e *Engine) curAgentRole() string { + e.emu.Lock() + defer e.emu.Unlock() + return e.agentRole +} + // recUUID returns the engine recorder's UUID (or "") under emu. func (e *Engine) recUUID() string { e.emu.Lock() @@ -294,6 +314,25 @@ func (e *Engine) applyModeSwitch(modeStr string, ag *adk.ChatModelAgent) { e.agentRevision++ } +func (e *Engine) applyAgentRoleSwitch(roleName string, built *AgentRoleBuild) { + e.emu.Lock() + e.agentRole = roleName + if built != nil && built.Agent != nil { + e.agent = built.Agent + e.providerName = built.Provider + e.modelName = built.Model + } + e.agentRevision++ + rec := e.recorder + e.emu.Unlock() + if rec != nil { + rec.SetAgent(roleName) + if built != nil && built.Model != "" { + rec.SetModel(built.Model) + } + } +} + // setAgent swaps just the agent under emu (MCP reload, skill toggle, setup). func (e *Engine) setAgent(ag *adk.ChatModelAgent) { e.emu.Lock() @@ -434,6 +473,13 @@ func (s *Server) buildLocalEngineWith(taskID, pwd, modeStr string, factory func( eng.applyModelSwitch(ag, prov, mdl) } } + roleName := cur.curAgentRole() + if roleName != "" && eng.rebuildForRole != nil { + prov, mdl, _ := eng.modelSnapshot() + if built, agErr := eng.rebuildForRole(roleName, prov, mdl); agErr == nil { + eng.applyAgentRoleSwitch(roleName, built) + } + } } if err := s.registerEngine(eng); err != nil { eng.teardown() diff --git a/internal/web/server.go b/internal/web/server.go index 121e8cff..c6ae67bb 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -226,6 +226,7 @@ type ServerConfig struct { Agent *adk.ChatModelAgent CreateAgent func(providerName, modelName string) (*adk.ChatModelAgent, error) RebuildForMode func(planMode bool) (*adk.ChatModelAgent, error) + RebuildForRole func(roleName, providerName, modelName string) (*AgentRoleBuild, error) NewEngine func(taskID, pwd, mode string) (*EngineConfig, error) // factory for new concurrent task engines (local) NewRemoteEngine func(taskID string, executor tools.RemoteExecutor, remotePwd, mode string) (*EngineConfig, error) // remote sibling of NewEngine (SSH or Docker) NewAutomationEngine func(taskID, pwd, mode string) (*EngineConfig, error) // headless sibling of NewEngine for automation runs (drops interactive tools) @@ -288,6 +289,7 @@ func NewServer(cfg *ServerConfig) *Server { toolSearchStats: cfg.ToolSearchStats, createAgent: cfg.CreateAgent, rebuildForMode: cfg.RebuildForMode, + rebuildForRole: cfg.RebuildForRole, } if boot.tokenUsage == nil { boot.tokenUsage = &model.TokenUsage{} @@ -397,6 +399,8 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/tasks/{id}/stats", s.handleTaskStats) mux.HandleFunc("GET /api/models", s.handleListModels) mux.HandleFunc("POST /api/model", s.handleSwitchModel) + mux.HandleFunc("GET /api/agents", s.handleListAgents) + mux.HandleFunc("POST /api/agent", s.handleSwitchAgent) mux.HandleFunc("POST /api/small-model", s.handleSetSmallModel) mux.HandleFunc("POST /api/mode", s.handleSwitchMode) mux.HandleFunc("POST /api/exec", s.handleExec) @@ -588,6 +592,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { "pwd": pwd, "provider": "", "model": "", + "agent": "", "mode": "build", "session_id": "", "running": false, @@ -618,6 +623,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { "pwd": eng.pwd, "provider": provider, "model": mdl, + "agent": eng.curAgentRole(), "mode": modeStr, "session_id": sessionID, "running": eng.running.Load(), @@ -637,6 +643,7 @@ func (s *Server) statusSnapshot(eng *Engine) map[string]any { "pwd": eng.pwd, "provider": provider, "model": mdl, + "agent": eng.curAgentRole(), "mode": modeStr, // Live token snapshot so a client reconnecting between turns can render // the context bar + cache hit rate without waiting for the next diff --git a/internal/web/sessions.go b/internal/web/sessions.go index c831eacd..dbb81aee 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "time" "github.com/cloudwego/eino/schema" "github.com/cnjack/jcode/internal/config" @@ -24,6 +25,7 @@ type taskItem struct { UpdatedAt string `json:"updated_at,omitempty"` Provider string `json:"provider"` Model string `json:"model"` + Agent string `json:"agent,omitempty"` Title string `json:"title,omitempty"` Pinned bool `json:"pinned"` Archived bool `json:"archived"` @@ -40,6 +42,7 @@ func newTaskItem(m *session.SessionMeta, project string, running bool) taskItem UpdatedAt: m.UpdatedAt, Provider: m.Provider, Model: m.Model, + Agent: m.Agent, Title: m.Title, Pinned: m.Pinned, Archived: m.Archived, @@ -179,6 +182,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { CreatedAt string `json:"created_at"` Provider string `json:"provider"` Model string `json:"model"` + Agent string `json:"agent,omitempty"` Title string `json:"title,omitempty"` } @@ -189,6 +193,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { CreatedAt: m.StartTime, Provider: m.Provider, Model: m.Model, + Agent: m.Agent, Title: m.Title, }) } @@ -342,6 +347,13 @@ func (s *Server) writeResumeReply(w http.ResponseWriter, eng *Engine, entries [] resp["status"] = "ok" resp["session_id"] = eng.taskID if entries != nil { + savedAgent := session.ReconstructState(entries).Agent + if currentAgent := eng.curAgentRole(); savedAgent != currentAgent { + entries = append(append([]session.Entry(nil), entries...), session.Entry{ + Type: session.EntryAgentChange, Agent: currentAgent, + Timestamp: time.Now().Format(time.RFC3339), + }) + } resp["entries"] = entries } if eng.env != nil && eng.env.GoalStore != nil { @@ -422,6 +434,20 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { return } st := session.ReconstructState(entries) + if eng.rebuildForRole != nil { + eng.rebuildMu.Lock() + provider, model, _ := eng.modelSnapshot() + built, rebuildErr := eng.rebuildForRole(st.Agent, provider, model) + if rebuildErr != nil { + config.Logger().Printf("[web] resume: custom agent %q unavailable for %s: %v", st.Agent, req.SessionID, rebuildErr) + if fallback, fallbackErr := eng.rebuildForRole("", provider, model); fallbackErr == nil { + eng.applyAgentRoleSwitch("", fallback) + } + } else { + eng.applyAgentRoleSwitch(st.Agent, built) + } + eng.rebuildMu.Unlock() + } eng.emu.Lock() eng.history = st.History eng.emu.Unlock() @@ -448,7 +474,10 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { if req.SessionID == "" { s.wsBroker.Broadcast(WSEvent{TaskID: eng.taskID, Type: "session_reset", Data: map[string]string{}}) s.stampCloudSync(eng.taskID, req.Source, true) - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) + resp := s.statusSnapshot(eng) + resp["status"] = "ok" + resp["session_id"] = eng.taskID + writeJSON(w, http.StatusOK, resp) return } diff --git a/packages/jcode-ui/src/product/ChatInput.test.tsx b/packages/jcode-ui/src/product/ChatInput.test.tsx index 565da69f..4c616ee4 100644 --- a/packages/jcode-ui/src/product/ChatInput.test.tsx +++ b/packages/jcode-ui/src/product/ChatInput.test.tsx @@ -45,6 +45,8 @@ function makeHost(overrides: Partial = {}): ProductComposer recentModels: [], imageSupport: true, effortOverrides: {}, + agents: [], + agentName: '', slashCommands: [ { slash: '/clear', description: 'Clear the conversation', type: 'builtin' }, { slash: '/compact', description: 'Compact context', type: 'builtin' }, @@ -56,6 +58,7 @@ function makeHost(overrides: Partial = {}): ProductComposer tasks: [], selectModel: vi.fn(), selectMode: vi.fn(), + selectAgent: vi.fn(), setEffort: vi.fn(), toggleFavorite: vi.fn(), setModelEnabled: vi.fn(), @@ -166,6 +169,44 @@ describe('goal armed', () => { }) }) +describe('custom agent picker', () => { + it('stays hidden when no custom agents are available', () => { + renderComposer(makeHost()) + expect(screen.queryByLabelText('Agent: Default agent')).toBeNull() + }) + + it('switches between Default and a Markdown-defined custom agent', async () => { + const selectAgent = vi.fn() + const host = makeHost({ + agents: [ + { + name: 'bug-fix-teammate', + description: 'Investigates regressions and implements focused fixes.', + model: 'anthropic/claude-sonnet', + }, + ], + selectAgent, + }) + const view = renderComposer(host) + + fireEvent.click(screen.getByLabelText('Agent: Default agent')) + expect(screen.getByText('bug-fix-teammate')).toBeTruthy() + expect(screen.getByText('Investigates regressions and implements focused fixes.')).toBeTruthy() + expect(screen.getByText('anthropic/claude-sonnet')).toBeTruthy() + fireEvent.click(screen.getByText('bug-fix-teammate')) + await waitFor(() => expect(selectAgent).toHaveBeenCalledWith('bug-fix-teammate')) + + view.rerender( + + + , + ) + fireEvent.click(screen.getByLabelText('Agent: bug-fix-teammate')) + fireEvent.click(screen.getByText('Default agent')) + await waitFor(() => expect(selectAgent).toHaveBeenCalledWith('')) + }) +}) + describe('workspace picker', () => { it('raises the open workspace panel above the composer toolbar', () => { const { container } = renderComposer(makeHost()) @@ -272,15 +313,18 @@ describe('model picker', () => { describe('compact picker anchors', () => { it('keeps mode, model, and effort panels inside their own trigger anchors', async () => { - renderComposer(makeHost({ + const { container } = renderComposer(makeHost({ providerName: 'anthropic', modelName: 'claude-sonnet', })) + expect(container.querySelector('.jcode-product-composer')).toBeTruthy() const modeTrigger = screen.getByRole('button', { name: 'Ask for approval' }) const modelTrigger = screen.getByRole('button', { name: 'Claude Sonnet' }) const effortTrigger = screen.getByRole('button', { name: 'Effort: Default' }) + expect(modeTrigger.title).toBe('Ask for approval') + expect(modelTrigger.title).toBe('Claude Sonnet') expect(modeTrigger.querySelector('.jcode-product-composer-picker-label')?.textContent).toBe('Ask for approval') expect(modelTrigger.querySelector('.jcode-product-composer-picker-label')?.textContent).toBe('Claude Sonnet') expect(effortTrigger.querySelector('.jcode-product-composer-picker-label')?.textContent).toBe('Effort') diff --git a/packages/jcode-ui/src/product/ChatInput.tsx b/packages/jcode-ui/src/product/ChatInput.tsx index c7d9f166..b5872768 100644 --- a/packages/jcode-ui/src/product/ChatInput.tsx +++ b/packages/jcode-ui/src/product/ChatInput.tsx @@ -48,6 +48,7 @@ import { CheckIcon, StarIcon, SparklesIcon, + CpuChipIcon, } from '@heroicons/react/24/outline' import { StarIcon as StarIconSolid, CheckCircleIcon } from '@heroicons/react/24/solid' import { useRuntimeActions, useRuntimeState } from 'jcode-ui-core/runtime' @@ -248,6 +249,8 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa recentModels, imageSupport, effortOverrides, + agents = [], + agentName = '', slashCommands, hasMessages, goalArmed, @@ -265,6 +268,7 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa const [showModelPicker, setShowModelPicker] = useState(false) const [showModePicker, setShowModePicker] = useState(false) const [showEffortPicker, setShowEffortPicker] = useState(false) + const [showAgentPicker, setShowAgentPicker] = useState(false) const [showAddMenu, setShowAddMenu] = useState(false) const [showManageModels, setShowManageModels] = useState(false) const [showContextPopup, setShowContextPopup] = useState(false) @@ -279,6 +283,7 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa const modePopup = useViewportPopup(showModePicker) const modelPopup = useViewportPopup(showModelPicker) const effortPopup = useViewportPopup(showEffortPicker) + const agentPopup = useViewportPopup(showAgentPicker) // ─── Derived model catalog ──────────────────────────────────────────────── @@ -479,6 +484,11 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa await host.selectMode(next) } + async function selectAgent(name: string) { + setShowAgentPicker(false) + await host.selectAgent?.(name) + } + async function pickEffort(effort: string) { setShowEffortPicker(false) await host.setEffort(providerName, modelName, effort) @@ -634,6 +644,11 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa setShowModelPicker(false) return } + if (showAgentPicker) { + e.preventDefault() + setShowAgentPicker(false) + return + } if (showSlashMenu) { e.preventDefault() setShowSlashMenu(false) @@ -683,6 +698,7 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa setShowAddMenu(false) setShowSlashMenu(false) setShowEffortPicker(false) + setShowAgentPicker(false) setShowContextPopup(false) if (showManageModels) { setShowManageModels(false) @@ -703,6 +719,9 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa } else if (showModelPicker) { e.preventDefault() setShowModelPicker(false) + } else if (showAgentPicker) { + e.preventDefault() + setShowAgentPicker(false) } } } @@ -712,7 +731,7 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa document.removeEventListener('click', onClick) document.removeEventListener('keydown', onKey) } - }, [showManageModels, showModelPicker]) + }, [showAgentPicker, showManageModels, showModelPicker]) // ─── Re-focus when a turn ends; drop images when the model can't accept them. @@ -736,23 +755,34 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa setShowAddMenu((v) => !v) setShowModelPicker(false) setShowModePicker(false) + setShowAgentPicker(false) } const openMode = () => { setShowModePicker((v) => !v) setShowModelPicker(false) setShowAddMenu(false) + setShowAgentPicker(false) } const openModel = () => { setShowModelPicker((v) => !v) setShowModePicker(false) setShowAddMenu(false) setShowEffortPicker(false) + setShowAgentPicker(false) } const openEffort = () => { setShowEffortPicker((v) => !v) setShowModelPicker(false) setShowModePicker(false) setShowAddMenu(false) + setShowAgentPicker(false) + } + const openAgent = () => { + setShowAgentPicker((v) => !v) + setShowModelPicker(false) + setShowModePicker(false) + setShowAddMenu(false) + setShowEffortPicker(false) } const canSend = input.trim().length > 0 || pendingImages.length > 0 @@ -770,7 +800,11 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa // Horizontal inset comes from the parent so the composer width matches the // message column exactly. return ( -
+
{/* Type-ahead queue */} {queued.length > 0 && (
@@ -962,10 +996,9 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa type="button" aria-expanded={showModePicker} aria-label={modeLabel(strings, mode)} - title={modeRestricted ? strings.modeCeilingHint : undefined} + title={modeRestricted ? strings.modeCeilingHint : modeLabel(strings, mode)} onClick={(e: ReactMouseEvent) => { e.stopPropagation(); openMode() }} className="jcode-product-composer-picker-trigger inline-flex h-7 items-center gap-[7px] rounded-[var(--radius-lg)] border border-transparent bg-transparent px-2 text-xs font-medium text-[var(--color-foreground)] transition-colors hover:bg-[var(--color-muted)]" - style={{ paddingLeft: 6 }} > @@ -1102,7 +1135,7 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa style={{ transition: 'stroke-dashoffset 0.3s ease' }} /> - {tokenPct}% + {tokenPct}% {showContextPopup && tokenSnapshot && ( { e.stopPropagation(); openModel() }} className="jcode-product-composer-picker-trigger inline-flex h-7 items-center gap-1.5 rounded-[var(--radius-lg)] border border-transparent bg-transparent px-2 text-xs font-medium text-[var(--color-foreground)] transition-colors hover:bg-[var(--color-muted)]" > @@ -1148,9 +1182,7 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa aria-label={`${strings.effort}: ${currentEffort || strings.effortDefault}`} title={strings.effortTitle} onClick={(e: ReactMouseEvent) => { e.stopPropagation(); openEffort() }} - className={`jcode-product-composer-picker-trigger inline-flex h-7 items-center gap-1 rounded-[var(--radius-lg)] border border-transparent bg-transparent px-2 text-xs font-medium transition-colors hover:bg-[var(--color-muted)] ${ - currentEffort ? 'text-[var(--color-primary)]' : 'text-[var(--color-foreground)]' - }`} + className="jcode-product-composer-picker-trigger inline-flex h-7 items-center gap-1 rounded-[var(--radius-lg)] border border-transparent bg-transparent px-2 text-xs font-medium text-[var(--color-foreground)] transition-colors hover:bg-[var(--color-muted)]" > {currentEffort || strings.effort} @@ -1189,6 +1221,85 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa
)} + {/* Custom agent picker — absent when no Markdown agents exist. */} + {agents.length > 0 && ( +
+ + + {showAgentPicker && ( +
+ +
+ {agents.map((customAgent) => { + const active = agentName === customAgent.name + return ( + + ) + })} +
+ )} +
+ )} + {/* Model picker panel */} {showModelPicker && (
@@ -1353,7 +1464,7 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa className="flex shrink-0 items-center gap-[5px] whitespace-nowrap rounded-[var(--radius-lg)] border-none bg-[var(--color-destructive)] px-3 py-[5px] text-xs font-medium text-[var(--color-on-destructive)]" > - {strings.stop} + {strings.stop} )} @@ -1367,7 +1478,9 @@ export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = fa className="flex shrink-0 items-center gap-[5px] whitespace-nowrap rounded-[var(--radius-lg)] border-none bg-[var(--color-primary)] px-3 py-[5px] text-xs font-medium text-[var(--color-on-primary)] transition-[opacity,transform] disabled:cursor-not-allowed disabled:opacity-45 enabled:hover:opacity-90" > - {isRunning ? strings.queue : strings.send} + + {isRunning ? strings.queue : strings.send} + )}
diff --git a/packages/jcode-ui/src/product/host.ts b/packages/jcode-ui/src/product/host.ts index 08ef8d02..fdc9c57e 100644 --- a/packages/jcode-ui/src/product/host.ts +++ b/packages/jcode-ui/src/product/host.ts @@ -22,6 +22,7 @@ import type { Goal } from 'jcode-ui-core' import type { AgentMode, BrowseResult, + CustomAgentInfo, GitBranchesResult, GitCheckoutResult, ModelRef, @@ -53,6 +54,10 @@ export interface ProductComposerHost { imageSupport: boolean /** Per-"provider/model" reasoning-effort overrides. */ effortOverrides: Record + /** Available top-level custom agents. Empty hides the agent picker. */ + agents?: CustomAgentInfo[] + /** Selected custom agent name. Empty means the built-in Default agent. */ + agentName?: string // ── Chat state ──────────────────────────────────────────────────────────── slashCommands: SlashCommandInfo[] @@ -78,6 +83,8 @@ export interface ProductComposerHost { selectModel: (provider: string, model: string) => void | Promise /** Switch the session approval mode. */ selectMode: (mode: AgentMode) => void | Promise + /** Select a top-level custom agent; empty string restores Default. */ + selectAgent?: (name: string) => void | Promise /** Set/clear (empty string) a per-model reasoning-effort override. */ setEffort: (provider: string, model: string, effort: string) => void | Promise /** Toggle a favorite; the host knows the resulting state. */ diff --git a/packages/jcode-ui/src/product/index.ts b/packages/jcode-ui/src/product/index.ts index b1c19841..b20a6d9d 100644 --- a/packages/jcode-ui/src/product/index.ts +++ b/packages/jcode-ui/src/product/index.ts @@ -37,6 +37,7 @@ export type { ModelInfo, ProviderInfo, ModelRef, + CustomAgentInfo, SlashCommandInfo, TaskContextBreakdown, TaskStats, diff --git a/packages/jcode-ui/src/product/strings.ts b/packages/jcode-ui/src/product/strings.ts index b3373fb0..6a5820b0 100644 --- a/packages/jcode-ui/src/product/strings.ts +++ b/packages/jcode-ui/src/product/strings.ts @@ -47,6 +47,11 @@ export interface ProductComposerStrings { /** Shown in the mode picker when the host restricts `allowedModes` (M20). */ modeCeilingHint: string + // ── custom agent picker ── + agentTitle: string + agentDefault: string + agentDefaultSub: string + // ── model picker / manage dialog ── modelFilter: string modelCurrent: string @@ -172,6 +177,10 @@ export const defaultProductComposerStrings: ProductComposerStrings = { modeFullAccessSub: 'Act freely — no approval prompts.', modeCeilingHint: 'This host limits which modes are available', + agentTitle: 'Agent', + agentDefault: 'Default agent', + agentDefaultSub: 'Use JCODE’s standard instructions and tools.', + modelFilter: 'Filter models…', modelCurrent: 'Current', modelFavorites: '★ Favorites', diff --git a/packages/jcode-ui/src/product/types.ts b/packages/jcode-ui/src/product/types.ts index 4553d633..38b93d4f 100644 --- a/packages/jcode-ui/src/product/types.ts +++ b/packages/jcode-ui/src/product/types.ts @@ -53,6 +53,14 @@ export interface ModelRef { model: string } +/** Discoverable top-level agent definition. Empty selection means Default. */ +export interface CustomAgentInfo { + name: string + description: string + /** Optional model applied when this agent is selected. */ + model?: string +} + /** Unified slash command (built-in + skill + flow). */ export interface SlashCommandInfo { slash: string diff --git a/packages/jcode-ui/src/styles/composer2.css b/packages/jcode-ui/src/styles/composer2.css index 8eda3a13..367350d4 100644 --- a/packages/jcode-ui/src/styles/composer2.css +++ b/packages/jcode-ui/src/styles/composer2.css @@ -266,6 +266,57 @@ } } +/* ─── Product composer responsive controls ───────────────────────────────── */ +/* + * Adapt to the space the composer actually owns rather than the viewport. + * This matters in the desktop shell where side panels can narrow the chat + * column without resizing the window. Labels stay on one line at full size, + * then the accessible buttons collapse to icons before they can wrap. + */ +.jcode-product-composer { + container: jcode-product-composer / inline-size; +} + +.jcode-product-composer-picker-trigger .jcode-product-composer-picker-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container jcode-product-composer (max-width: 44rem) { + .jcode-product-composer-picker-trigger { + width: 1.875rem; + min-width: 1.875rem; + justify-content: center; + gap: 0; + padding-inline: 0; + } + + .jcode-product-composer-picker-trigger .jcode-product-composer-picker-label, + .jcode-product-composer-picker-trigger .jcode-product-composer-picker-chevron { + display: none; + } +} + +@container jcode-product-composer (max-width: 28rem) { + .jcode-product-composer-action-label, + .jcode-product-composer-context-label { + display: none; + } + + .jcode-product-composer-toolbar-right { + gap: 0.25rem; + } + + .jcode-product-composer-toolbar-right > button { + width: 1.875rem; + height: 1.875rem; + justify-content: center; + padding: 0; + } +} + /* ─── ModelSelector ───────────────────────────────────────────────────────── */ /* Product composer model catalog: keep the panel within the desktop viewport * and make only the catalog body scroll. The vh declaration is a WebKit-safe diff --git a/site/docs/overview/custom-agents.md b/site/docs/overview/custom-agents.md new file mode 100644 index 00000000..c86cbfe4 --- /dev/null +++ b/site/docs/overview/custom-agents.md @@ -0,0 +1,65 @@ +--- +title: Custom Agents +parent: Overview +nav_order: 20 +--- + +# Custom Agents + +Custom agents give a reusable name and instruction set to a specialized JCode +role. The same definition can be selected for a top-level chat or delegated +through subagent, workflow, and team tools. + +## Define an agent + +Create a file ending in `.agent.md`: + +- User scope: `~/.jcode/agents/.agent.md` +- Project scope: `/.jcode/agents/.agent.md` + +```md +--- +name: bug-fix-teammate +description: Investigate regressions and implement focused fixes +model: anthropic/claude-sonnet-4-5 +--- + +Reproduce the failure before editing. Keep the patch focused and run the +smallest relevant test suite before returning. +``` + +`name`, `description`, and the Markdown instruction body are required. `model` +is optional; it accepts `provider/model` or `small`. The name comes from +frontmatter, not from the filename. + +Only `*.agent.md` files are loaded. Legacy JSON definitions are not supported. + +## Precedence + +Within one scope, files are checked in filename order and the first valid +definition for a name wins. A project definition overrides a user definition +with the same name. The picker shows one effective entry per name. + +## Select an agent + +In Web/Desktop, use the **Agent** picker beside the model controls. The picker +appears only when at least one valid custom agent exists and always includes +**Default**. + +From the terminal: + +```bash +jcode --agent bug-fix-teammate +jcode --agent default +``` + +The selection is saved with the session and restored on resume. ACP does not +offer an agent picker: new ACP sessions use Default, while resumed sessions +continue their saved custom agent automatically. + +## Models, tools, and permissions + +An agent with `model` switches to that model when selected; without it, the +current model is inherited. Custom agents inherit the current mode, approval +policy, sandbox, MCP access, and tool set. Markdown instructions cannot grant +extra tools or bypass approval. diff --git a/web/src/App.tsx b/web/src/App.tsx index 4322ff20..bebe37c5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -77,6 +77,7 @@ export default function App() { if (cancelled) return dispatch(modelActions.setProvider(h.provider)) dispatch(modelActions.setModel(h.model)) + dispatch(modelActions.setAgent(h.agent || '')) dispatch(modelActions.setMode(normalizeMode(h.mode))) dispatch(modelActions.setServerVersion(h.version)) dispatch(modelActions.setImageSupport(!!h.image_support)) diff --git a/web/src/app/composerHost.ts b/web/src/app/composerHost.ts index 398a5093..6e80948d 100644 --- a/web/src/app/composerHost.ts +++ b/web/src/app/composerHost.ts @@ -60,6 +60,10 @@ function buildStrings(t: (key: string, opts?: Record) => string modeFullAccessSub: t('chat.modes.fullAccessSub'), modeCeilingHint: t('chat.modes.ceilingHint'), + agentTitle: t('chat.agent.title'), + agentDefault: t('chat.agent.default'), + agentDefaultSub: t('chat.agent.defaultSub'), + modelFilter: t('chat.model.filter'), modelCurrent: t('chat.model.current'), modelFavorites: t('chat.model.favorites'), @@ -164,6 +168,17 @@ const actions = { store.dispatch(modelActions.setMode(next)) }, + async selectAgent(name: string) { + try { + const result = await api.switchAgent(name) + store.dispatch(modelActions.setAgent(result.agent || '')) + if (result.provider) store.dispatch(modelActions.setProvider(result.provider)) + if (result.model) store.dispatch(modelActions.setModel(result.model)) + } catch { + /* ignore — the next status poll reconciles */ + } + }, + async setEffort(provider: string, model: string, effort: string) { const key = `${provider}/${model}` const prev = (store.getState() as RootState).model.effortOverrides[key] ?? '' @@ -288,6 +303,8 @@ export function useProductComposerHost(): ProductComposerHost { const recentModels = useAppSelector((s) => s.model.recentModels) const imageSupport = useAppSelector((s) => s.model.imageSupport) const effortOverrides = useAppSelector((s) => s.model.effortOverrides) + const agents = useAppSelector((s) => s.model.agents) + const agentName = useAppSelector((s) => s.model.agentName) const slashCommands = useAppSelector((s) => s.chat.slashCommands) const hasMessages = useAppSelector((s) => s.chat.timeline.length > 0) const goalArmed = useAppSelector((s) => s.chat.goalArmed) @@ -307,6 +324,8 @@ export function useProductComposerHost(): ProductComposerHost { recentModels, imageSupport, effortOverrides, + agents, + agentName, slashCommands, hasMessages, goalArmed, @@ -320,7 +339,7 @@ export function useProductComposerHost(): ProductComposerHost { }), [ providerName, modelName, mode, providers, favoriteModels, recentModels, - imageSupport, effortOverrides, slashCommands, hasMessages, goalArmed, + imageSupport, effortOverrides, agents, agentName, slashCommands, hasMessages, goalArmed, sessionId, projectPath, tasks, strings, ], ) diff --git a/web/src/app/store.ts b/web/src/app/store.ts index a5a773d2..29336767 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -16,8 +16,8 @@ import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit' import type { ThreadItem, Message, ToolCall, Approval, TokenSnapshot, Goal, TodoItem, QueuedMessage, AskUserQuestion } from 'jcode-ui-core' import { api } from '../lib/api' import { extractToolDisplayInfo } from '../lib/toolInfo' -import { normalizeMode, type AgentMode, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef } from '../lib/types' -import { setLocale, SUPPORTED_LOCALES } from '../i18n' +import { normalizeMode, type AgentMode, type CustomAgentInfo, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef } from '../lib/types' +import { i18n, setLocale, SUPPORTED_LOCALES } from '../i18n' import { hydrateTheme } from '../lib/useTheme' // ─── seq counter (stable DOM identity across streaming updates) ─── @@ -540,6 +540,8 @@ interface ModelState { favoriteModels: string[] recentModels: ModelRef[] effortOverrides: Record + agents: CustomAgentInfo[] + agentName: string autoApprove: boolean imageSupport: boolean serverVersion: string @@ -555,6 +557,8 @@ const initialModel: ModelState = { favoriteModels: [], recentModels: [], effortOverrides: {}, + agents: [], + agentName: '', autoApprove: false, imageSupport: false, serverVersion: '', @@ -598,6 +602,12 @@ const modelSlice = createSlice({ setMode(s, a: { payload: AgentMode }) { s.mode = a.payload }, + setAgents(s, a: { payload: CustomAgentInfo[] }) { + s.agents = a.payload + }, + setAgent(s, a: { payload: string }) { + s.agentName = a.payload + }, setProviders(s, a: { payload: ProviderInfo[] }) { s.providers = a.payload syncImageSupport(s) @@ -962,6 +972,12 @@ export const loadModels = createAsyncThunk('model/loadModels', async (_, { dispa dispatch(modelActions.setImageSupport(!!model?.image_support)) }) +export const loadAgents = createAsyncThunk('model/loadAgents', async (_, { dispatch }) => { + const data = await api.agents() + dispatch(modelActions.setAgents(data.agents || [])) + dispatch(modelActions.setAgent(data.current || '')) +}) + export const loadModelState = createAsyncThunk('model/loadState', async (_, { dispatch }) => { const data = await api.modelState() dispatch(modelActions.setModelState({ @@ -1008,6 +1024,7 @@ export const loadStatus = createAsyncThunk('app/loadStatus', async (_, { dispatc dispatch(sessionActions.setProjectPath(status.pwd)) dispatch(modelActions.setProvider(status.provider)) dispatch(modelActions.setModel(status.model)) + dispatch(modelActions.setAgent(status.agent || '')) dispatch(modelActions.setMode(normalizeMode(status.mode))) if (status.token) dispatch(chatActions.setTokenSnapshot(status.token)) }) @@ -1065,6 +1082,7 @@ export const loadWorkspaceState = createAsyncThunk('app/loadWorkspaceState', asy dispatch(loadStatus()), dispatch(loadConfig()), dispatch(loadModels()), + dispatch(loadAgents()), dispatch(loadModelState()), dispatch(loadSessions()), dispatch(loadTasks()), @@ -1126,10 +1144,36 @@ export const loadSession = createAsyncThunk( const timeline: ThreadItem[] = [] const pendingToolCalls = new Map() for (const e of entries || []) { - if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) { + if (e.type === 'session_start' && e.agent) { + timeline.push({ + kind: 'message', + seq: nextSeq(), + data: { + id: genId('agent'), + role: 'system', + content: i18n.t('chat.agent.changedTo', { name: e.agent }), + level: 'notice', + timestamp: ts(e.timestamp), + }, + }) + } else if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) { timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('msg'), role: 'user', content: e.content || '', timestamp: ts(e.timestamp), images: e.images } }) } else if (e.type === 'assistant' && e.content) { timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('asst'), role: 'assistant', content: e.content, timestamp: ts(e.timestamp) } }) + } else if (e.type === 'agent_change') { + timeline.push({ + kind: 'message', + seq: nextSeq(), + data: { + id: genId('agent'), + role: 'system', + content: e.agent + ? i18n.t('chat.agent.changedTo', { name: e.agent }) + : i18n.t('chat.agent.changedToDefault'), + level: 'notice', + timestamp: ts(e.timestamp), + }, + }) } else if (e.type === 'tool_call' && e.name) { const tc: ToolCall = { id: genId('tc'), @@ -1194,6 +1238,7 @@ export const loadSession = createAsyncThunk( if (resp.pwd) dispatch(sessionActions.setProjectPath(resp.pwd)) dispatch(modelActions.setProvider(resp.provider || '')) dispatch(modelActions.setModel(resp.model || '')) + dispatch(modelActions.setAgent(resp.agent || '')) dispatch(modelActions.setMode(normalizeMode(resp.mode || ''))) if (resp.token) dispatch(chatActions.setTokenSnapshot(resp.token)) dispatch(chatActions.setGoal(resp.goal ?? null)) @@ -1232,6 +1277,10 @@ export const startNewChat = createAsyncThunk('session/startNew', async (_, { dis try { const resp = await api.newSession() dispatch(sessionActions.setCurrentSession(resp.session_id)) + if (resp.provider !== undefined) dispatch(modelActions.setProvider(resp.provider)) + if (resp.model !== undefined) dispatch(modelActions.setModel(resp.model)) + if (resp.agent !== undefined) dispatch(modelActions.setAgent(resp.agent)) + if (resp.mode !== undefined) dispatch(modelActions.setMode(normalizeMode(resp.mode))) } catch { // surfaced via health/gate } @@ -1257,10 +1306,36 @@ export const replaySession = createAsyncThunk( const timeline: ThreadItem[] = [] const pendingToolCalls = new Map() for (const e of entries) { - if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) { + if (e.type === 'session_start' && e.agent) { + timeline.push({ + kind: 'message', + seq: nextSeq(), + data: { + id: genId('agent'), + role: 'system', + content: i18n.t('chat.agent.changedTo', { name: e.agent }), + level: 'notice', + timestamp: ts(e.timestamp), + }, + }) + } else if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) { timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('msg'), role: 'user', content: e.content || '', timestamp: ts(e.timestamp), images: e.images } }) } else if (e.type === 'assistant' && e.content) { timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('asst'), role: 'assistant', content: e.content, timestamp: ts(e.timestamp) } }) + } else if (e.type === 'agent_change') { + timeline.push({ + kind: 'message', + seq: nextSeq(), + data: { + id: genId('agent'), + role: 'system', + content: e.agent + ? i18n.t('chat.agent.changedTo', { name: e.agent }) + : i18n.t('chat.agent.changedToDefault'), + level: 'notice', + timestamp: ts(e.timestamp), + }, + }) } else if (e.type === 'tool_call' && e.name) { const tc: ToolCall = { id: genId('tc'), diff --git a/web/src/app/wsBridge.ts b/web/src/app/wsBridge.ts index 650b5693..43331609 100644 --- a/web/src/app/wsBridge.ts +++ b/web/src/app/wsBridge.ts @@ -20,6 +20,7 @@ import { import { api } from '../lib/api' import type { Approval, Goal } from 'jcode-ui-core' import { normalizeMode } from '../lib/types' +import { i18n } from '../i18n' /** Create the handler set for a given store getter + dispatch. The handlers read * fresh state (active task id) so they don't capture stale closures. */ @@ -113,6 +114,21 @@ export function createWSHandlers( dispatch(modelActions.setProvider(d.provider)) dispatch(modelActions.setModel(d.model)) }, + onAgentChanged: (d) => { + dispatch(modelActions.setAgent(d.agent || '')) + // Only show the notice when a conversation is active (timeline non-empty). + // On the welcome screen the user hasn't started yet — adding a message + // would replace the welcome hero with a near-empty conversation. + if (getState().chat.timeline.length > 0) { + dispatch(chatActions.addMessage({ + role: 'system', + content: d.agent + ? i18n.t('chat.agent.changedTo', { name: d.agent }) + : i18n.t('chat.agent.changedToDefault'), + level: 'notice', + })) + } + }, onModeChanged: (d) => { const mode = normalizeMode(d.mode) dispatch(modelActions.setMode(mode)) diff --git a/web/src/components/AuthGate.tsx b/web/src/components/AuthGate.tsx index c55bc67c..293df0c5 100644 --- a/web/src/components/AuthGate.tsx +++ b/web/src/components/AuthGate.tsx @@ -26,6 +26,7 @@ export function AuthGate() { const h = await api.health() dispatch(modelActions.setProvider(h.provider)) dispatch(modelActions.setModel(h.model)) + dispatch(modelActions.setAgent(h.agent || '')) dispatch(modelActions.setMode(normalizeMode(h.mode))) dispatch(modelActions.setServerVersion(h.version)) dispatch(modelActions.setImageSupport(!!h.image_support)) diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 71d0d225..c2414363 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -129,7 +129,7 @@ export function ChatView({ readOnly }: ChatViewProps) {
{/* Centered elevated composer. z-[2] keeps its upward-opening menus (model picker, slash palette) above the welcome hero text. */} -
+
{ /* timeline auto-follows */ }} />
{/* Bottom half balances the center */} diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 3143b814..4d959529 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -10,9 +10,17 @@ import { } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useAppDispatch, useAppSelector } from '../app/hooks' -import { chatActions, loadSession, loadTasks, loadWorkspaceState, sessionActions, uiActions } from '../app/store' +import { + chatActions, + loadSession, + loadTasks, + loadWorkspaceState, + modelActions, + sessionActions, + uiActions, +} from '../app/store' import { api } from '../lib/api' -import type { TaskItem } from '../lib/types' +import { normalizeMode, type TaskItem } from '../lib/types' import { isRemotePath, openRemoteConnect, parseRemoteLabel } from '../lib/remote' interface PaletteItem { @@ -46,6 +54,10 @@ export function CommandPalette() { const resp = await api.newSession() // Stay off the sidebar until the first user message (empty UUID rows look broken). dispatch(sessionActions.setCurrentSession(resp.session_id)) + if (resp.provider !== undefined) dispatch(modelActions.setProvider(resp.provider)) + if (resp.model !== undefined) dispatch(modelActions.setModel(resp.model)) + if (resp.agent !== undefined) dispatch(modelActions.setAgent(resp.agent)) + if (resp.mode !== undefined) dispatch(modelActions.setMode(normalizeMode(resp.mode))) } async function openTask(task: TaskItem) { diff --git a/web/src/components/SetupView.tsx b/web/src/components/SetupView.tsx index 460d84a1..2de1a362 100644 --- a/web/src/components/SetupView.tsx +++ b/web/src/components/SetupView.tsx @@ -130,6 +130,7 @@ export function SetupView() { const h = await api.health() dispatch(modelActions.setProvider(h.provider)) dispatch(modelActions.setModel(h.model)) + dispatch(modelActions.setAgent(h.agent || '')) dispatch(modelActions.setMode(normalizeMode(h.mode))) dispatch(modelActions.setServerVersion(h.version)) dispatch(modelActions.setImageSupport(!!h.image_support)) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index ccd82ac2..b566d8a7 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -216,6 +216,13 @@ export default { fullAccessSub: 'Act freely — no approval prompts.', ceilingHint: 'This host limits which modes are available', }, + agent: { + title: 'Agent', + default: 'Default agent', + defaultSub: 'Use JCODE’s standard instructions and tools.', + changedTo: 'Agent changed to {name}.', + changedToDefault: 'Agent changed to Default.', + }, roles: { you: 'You', assistant: 'JCODE', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index d4f2e2d4..b88c951d 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -209,6 +209,13 @@ export default { fullAccessSub: '自由に実行—確認は不要です。', ceilingHint: 'このホストでは利用可能なモードが制限されています', }, + agent: { + title: 'エージェント', + default: 'デフォルトエージェント', + defaultSub: 'JCODE の標準指示とツールを使用します。', + changedTo: 'エージェントを {name} に変更しました。', + changedToDefault: 'エージェントをデフォルトに変更しました。', + }, roles: { you: 'あなた', assistant: 'JCODE', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index f135e84d..7a56e73f 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -209,6 +209,13 @@ export default { fullAccessSub: '자유롭게 실행—승인 프롬프트가 없습니다.', ceilingHint: '이 호스트에서는 사용 가능한 모드가 제한됩니다', }, + agent: { + title: '에이전트', + default: '기본 에이전트', + defaultSub: 'JCODE의 표준 지침과 도구를 사용합니다.', + changedTo: '에이전트가 {name}(으)로 변경되었습니다.', + changedToDefault: '에이전트가 기본값으로 변경되었습니다.', + }, roles: { you: '나', assistant: 'JCODE', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 12e9f3f1..53107837 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -206,6 +206,13 @@ export default { fullAccessSub: '自由操作——不再弹出确认。', ceilingHint: '此宿主限制了可用模式', }, + agent: { + title: 'Agent', + default: '默认 Agent', + defaultSub: '使用 JCODE 的标准指令和工具。', + changedTo: 'Agent 已切换为 {name}。', + changedToDefault: 'Agent 已切换为默认 Agent。', + }, roles: { you: '你', assistant: 'JCODE', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 03fd8468..8f5e541c 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -210,6 +210,13 @@ export default { fullAccessSub: '自由操作——不再彈出確認。', ceilingHint: '此宿主限制了可用模式', }, + agent: { + title: 'Agent', + default: '預設 Agent', + defaultSub: '使用 JCODE 的標準指令和工具。', + changedTo: 'Agent 已切換為 {name}。', + changedToDefault: 'Agent 已切換為預設 Agent。', + }, roles: { you: '你', assistant: 'JCODE', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2eba8a04..0f9c24ed 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,5 @@ // API client for jcode backend — ported from web/src/composables/api.ts. -import type { ModelsResponse, AgentMode, ExecResponse, DiffResponse, WorkspaceInfo, GitBranchesResponse, GitCheckoutResponse, TaskItem, TaskMetaPatch, ProjectInfo, MCPListResponse, MCPServerRequest, MCPLoginStatus, BrowseResponse, SSHListResponse, SkillInfo, SlashCommandInfo, TodoItem, Goal, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ProviderAdvanced, CustomModelDetail, ValidateResult, CatalogModel, ModelStateResponse, ChatImage, AskUserAnswer, AskUserRequestData, ApprovalRequestData, RemoteConnectRequest, RemoteConnectResponse, RemoteListDirResponse, RemoteBindResponse, DockerContainersResponse, UsageStats, TaskStats, TokenUpdateData, ApprovalReviewConfig, ApprovalReviewConfigResponse } from './types' +import type { ModelsResponse, AgentMode, CustomAgentInfo, ExecResponse, DiffResponse, WorkspaceInfo, GitBranchesResponse, GitCheckoutResponse, TaskItem, TaskMetaPatch, ProjectInfo, MCPListResponse, MCPServerRequest, MCPLoginStatus, BrowseResponse, SSHListResponse, SkillInfo, SlashCommandInfo, TodoItem, Goal, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ProviderAdvanced, CustomModelDetail, ValidateResult, CatalogModel, ModelStateResponse, ChatImage, AskUserAnswer, AskUserRequestData, ApprovalRequestData, RemoteConnectRequest, RemoteConnectResponse, RemoteListDirResponse, RemoteBindResponse, DockerContainersResponse, UsageStats, TaskStats, TokenUpdateData, ApprovalReviewConfig, ApprovalReviewConfigResponse } from './types' import type { AutomationItem, AutomationRun, AutomationTemplate, AutomationCreate, Automation } from './automation' import { apiBase } from './apiBase' import { getAuthToken, notifyAuthExpired } from './authToken' @@ -44,7 +44,7 @@ export const api = { body: JSON.stringify({ before_user_message: beforeUserMessage }), }), health: () => - request<{ status: string; version: string; pwd: string; provider: string; model: string; mode: string; session_id: string; running: boolean; image_support?: boolean; needs_setup?: boolean; auth_required?: boolean }>( + request<{ status: string; version: string; pwd: string; provider: string; model: string; agent?: string; mode: string; session_id: string; running: boolean; image_support?: boolean; needs_setup?: boolean; auth_required?: boolean }>( '/api/health', ), // authVerify validates a token typed into the login gate. skipAuth keeps a 401 @@ -62,6 +62,7 @@ export const api = { pwd: string provider: string model: string + agent?: string mode: string token?: TokenUpdateData }>('/api/status'), @@ -101,6 +102,7 @@ export const api = { pwd?: string provider?: string model?: string + agent?: string mode?: string token?: TokenUpdateData }>('/api/sessions', { @@ -136,6 +138,12 @@ export const api = { askPending: () => request('/api/ask/pending'), approvalPending: () => request('/api/approval/pending'), models: () => request('/api/models'), + agents: () => request<{ agents: CustomAgentInfo[]; current: string }>('/api/agents'), + switchAgent: (agent: string) => + request<{ status: string; agent: string; provider?: string; model?: string }>('/api/agent', { + method: 'POST', + body: JSON.stringify({ agent }), + }), switchModel: (provider: string, model: string) => request<{ status: string }>('/api/model', { method: 'POST', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 8caf4143..a070dd7b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -6,6 +6,7 @@ export interface HealthResponse { pwd: string provider: string model: string + agent?: string mode: string } @@ -15,14 +16,22 @@ export interface StatusResponse { pwd: string provider: string model: string + agent?: string mode: string } +export interface CustomAgentInfo { + name: string + description: string + model?: string +} + export interface SessionItem { uuid: string created_at: string provider: string model: string + agent?: string title?: string } @@ -32,6 +41,7 @@ export interface SessionEntry { project?: string provider?: string model?: string + agent?: string content?: string name?: string args?: string @@ -63,7 +73,7 @@ export interface SessionEntry { subagent_name?: string subagent_type?: string - // mode_change field + // mode_change / agent_change fields mode?: string // compact fields @@ -189,6 +199,7 @@ export interface TaskItem { updated_at?: string provider: string model: string + agent?: string title?: string pinned: boolean archived: boolean diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index 652a4b5c..9af56ef7 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -53,6 +53,7 @@ export interface WSHandlers { onAskUserRequest?: (data: import('./types').AskUserRequestData) => void onSessionReset?: (data: { session_id: string }) => void onModelChanged?: (data: { provider: string; model: string }) => void + onAgentChanged?: (data: { agent: string }) => void onModeChanged?: (data: { mode: string }) => void onApprovalModeChanged?: (data: { auto_approve: boolean }) => void onSubagentEvent?: (data: import('./types').SubagentEventData) => void @@ -234,6 +235,7 @@ export class WSClient { ask_user_request: (d) => h.onAskUserRequest?.(d), session_reset: (d) => h.onSessionReset?.(d), model_changed: (d) => h.onModelChanged?.(d), + agent_changed: (d) => h.onAgentChanged?.(d), mode_changed: (d) => h.onModeChanged?.(d), approval_mode_changed: (d) => h.onApprovalModeChanged?.(d), subagent_event: (d) => h.onSubagentEvent?.(d),