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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/jcode/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func main() {
var (
prompt string
resumeUUID string
agentName string
unsafeMode bool
)

Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
89 changes: 58 additions & 31 deletions internal-doc/custom-agents.md
Original file line number Diff line number Diff line change
@@ -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 `<project>/.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)
- `<project>/.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 <name>` 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.
42 changes: 38 additions & 4 deletions internal/command/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
99 changes: 99 additions & 0 deletions internal/command/custom_agents.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading