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
50 changes: 41 additions & 9 deletions internal/agent/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (

"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"

"github.com/cnjack/jcode/internal/telemetry"
)

// approvalMiddleware implements adk.ChatModelAgentMiddleware with both
Expand Down Expand Up @@ -40,29 +42,59 @@ func (m *approvalMiddleware) WrapInvokableToolCall(
}
}()

// Approval gate
subSpan := telemetry.SubSpanFromContext(ctx)

// Approval gate — traced as a separate "approval" span.
if m.approvalFunc != nil {
var finishApproval func(string)
if subSpan != nil {
finishApproval = subSpan("approval")
}

approved, err := m.approvalFunc(ctx, tCtx.Name, argumentsInJSON)

if err != nil {
return fmt.Sprintf("Tool approval error: %v", err), nil
msg := fmt.Sprintf("Tool approval error: %v", err)
if finishApproval != nil {
finishApproval(msg)
}
return msg, nil
}
if !approved {
return "Tool execution was rejected by user. " +
msg := "Tool execution was rejected by user. " +
"IMPORTANT: The user has explicitly denied this operation. " +
"Do NOT attempt to perform the same action using alternative tools, different commands, or workarounds. " +
"Respect the user's decision and either ask the user how they would like to proceed or move on to a different task.", nil
"Respect the user's decision and either ask the user how they would like to proceed or move on to a different task."
if finishApproval != nil {
finishApproval("rejected")
}
return msg, nil
}
if finishApproval != nil {
finishApproval("approved")
}
}

// Execution — traced as a separate "execution" span.
var finishExec func(string)
if subSpan != nil {
finishExec = subSpan("execution")
}

// Safe execution: convert errors to agent-visible strings.
// Preserve any partial output (e.g. stdout/stderr from a command that
// exited with a non-zero status) so the agent can see the details.
result, err := endpoint(ctx, argumentsInJSON, opts...)
if err != nil {
if result != "" {
return fmt.Sprintf("%s\n\nTool execution failed: %v", result, err), nil
result = fmt.Sprintf("%s\n\nTool execution failed: %v", result, err)
} else {
result = fmt.Sprintf("Tool execution failed: %v", err)
}
return fmt.Sprintf("Tool execution failed: %v", err), nil
if finishExec != nil {
finishExec(result)
}
return result, nil
}
if finishExec != nil {
finishExec(result)
}
return result, nil
}, nil
Expand Down
3 changes: 2 additions & 1 deletion internal/command/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,8 @@ func (a *acpAgent) LoadSession(ctx context.Context, params acp.LoadSessionReques
}

// Reconstruct full message history (including tool calls/results).
history := session.ReconstructHistory(entries)
resumeState := session.ReconstructState(entries)
history := session.PruneOldToolOutputs(resumeState.History, 2)

sess, err := a.buildAgentSession(ctx, cfg, pwd, params.SessionId, rec, history)
if err != nil {
Expand Down
36 changes: 34 additions & 2 deletions internal/command/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,11 +440,23 @@ func (s *interactiveState) handleResume(uuid string) {
return
}
st := session.ReconstructState(entries)
s.history = st.History
s.history = session.PruneOldToolOutputs(st.History, 2)
s.approvalState.SetSessionApproval(false)
s.rec.SetUUID(uuid)
s.p.Send(tui.SessionResumedMsg{UUID: uuid, Entries: tui.ConvertSessionEntries(entries)})

// Restore stored system prompt for KV-cache-friendly resume.
if st.SystemPrompt != "" {
s.systemPrompt = st.SystemPrompt
envDiff := prompts.BuildEnvDiff(st.EnvInfo, s.platform, s.pwd, s.env.Exec.Label(), s.envInfo)
if envDiff != "" {
s.history = append(s.history, &schema.Message{
Role: schema.System,
Content: envDiff,
})
}
}

if st.Plan != nil {
switch st.Plan.Status {
case "approved":
Expand Down Expand Up @@ -991,6 +1003,12 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error {
}
st.ag = ag

// 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)
}

env.OnEnvChange = func(envLabel string, isLocal bool, envErr error) {
if envErr != nil {
p.Send(tui.SSHStatusMsg{Success: false, Err: envErr})
Expand Down Expand Up @@ -1031,11 +1049,25 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error {
return fmt.Errorf("cannot load session: %w", loadErr)
}
resumeState := session.ReconstructState(entries)
initialHistory = resumeState.History
initialHistory = session.PruneOldToolOutputs(resumeState.History, 2)
initialResumeUUID = resumeUUID
initialResumeEntries = tui.ConvertSessionEntries(entries)
hasPrompt = false

// Restore stored system prompt for KV-cache-friendly resume.
if resumeState.SystemPrompt != "" {
systemPrompt = resumeState.SystemPrompt
st.systemPrompt = systemPrompt
// Inject environment diff as an additional system message.
envDiff := prompts.BuildEnvDiff(resumeState.EnvInfo, platform, pwd, "local", envInfo)
if envDiff != "" {
initialHistory = append(initialHistory, &schema.Message{
Role: schema.System,
Content: envDiff,
})
}
}

if resumeState.Plan != nil {
switch resumeState.Plan.Status {
case "approved":
Expand Down
9 changes: 5 additions & 4 deletions internal/handler/notifying.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ func (h *NotifyingHandler) OnTokenUpdate(info TokenUsage) {
}

func (h *NotifyingHandler) RequestApproval(ctx context.Context, req ApprovalRequest) (ApprovalResponse, error) {
// Push attention status to notifiers immediately.
h.notifyAll(channel.NotifyEvent{Type: channel.EventApproval, Tool: req.ToolName})
// Push attention status to notifiers asynchronously to avoid blocking
// on slow channels (e.g. WeChat HTTP calls).
go h.notifyAll(channel.NotifyEvent{Type: channel.EventApproval, Tool: req.ToolName})

// Use toolCallID if available, otherwise fall back to tool name + args
approvalID := req.ToolCallID
Expand Down Expand Up @@ -176,9 +177,9 @@ func (h *NotifyingHandler) RequestApproval(ctx context.Context, req ApprovalRequ
// Delegate to inner handler (blocks until user responds)
resp, err := h.inner.RequestApproval(ctx, req)

// Resume working status after approval resolved.
// Resume working status after approval resolved (async to avoid blocking).
if resp.Approved {
h.notifyAll(channel.NotifyEvent{Type: channel.EventWorking})
go h.notifyAll(channel.NotifyEvent{Type: channel.EventWorking})
}

// Mark as resolved and cancel the timer if it hasn't fired yet
Expand Down
11 changes: 9 additions & 2 deletions internal/handler/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,21 @@ func (h *TUIHandler) OnTokenUpdate(info TokenUsage) {

func (h *TUIHandler) RequestApproval(ctx context.Context, req ApprovalRequest) (ApprovalResponse, error) {
respCh := make(chan tui.ToolApprovalResponse, 1)
h.p.Send(tui.ToolApprovalRequestMsg{
msg := tui.ToolApprovalRequestMsg{
Name: req.ToolName,
Args: req.ToolArgs,
Resp: respCh,
IsExternal: req.IsExternal,
WorkerName: req.WorkerName,
WorkerColor: req.WorkerColor,
})
}

// p.Send() blocks on BubbleTea's unbuffered message channel.
// When multiple tool calls need approval concurrently, the second
// Send would block until the first approval dialog is fully processed,
// causing unnecessary serialization. Send in a goroutine so that
// this goroutine can immediately proceed to wait on respCh.
go h.p.Send(msg)

select {
case resp := <-respCh:
Expand Down
64 changes: 64 additions & 0 deletions internal/prompts/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,67 @@ func loadAgentsMd(pwd string) string {
}
return content
}

// SerializeEnvInfo produces a stable string representation of environment info
// for storage in session entries. The format is simple key=value lines.
func SerializeEnvInfo(platform, pwd, envLabel string, envInfo *utils.EnvInfo) string {
var sb strings.Builder
sb.WriteString("platform=" + platform + "\n")
sb.WriteString("pwd=" + pwd + "\n")
sb.WriteString("date=" + time.Now().Format("2006-01-02") + "\n")
sb.WriteString("env_label=" + envLabel + "\n")
if envInfo != nil {
sb.WriteString("git_branch=" + envInfo.GitBranch + "\n")
if envInfo.GitDirty {
sb.WriteString("git_dirty=true\n")
} else {
sb.WriteString("git_dirty=false\n")
}
sb.WriteString("last_commit=" + envInfo.LastCommit + "\n")
sb.WriteString("project_type=" + envInfo.ProjectType + "\n")
// DirTree omitted from diff — too noisy and changes often.
}
return sb.String()
}

// parseEnvKV parses a key=value env info string into a map.
func parseEnvKV(s string) map[string]string {
m := make(map[string]string)
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if idx := strings.IndexByte(line, '='); idx > 0 {
m[line[:idx]] = line[idx+1:]
}
}
return m
}

// BuildEnvDiff compares a stored environment snapshot (from session) with
// the current environment and returns a human-readable diff string.
// Returns "" if nothing changed.
func BuildEnvDiff(storedEnvInfo string, platform, pwd, envLabel string, envInfo *utils.EnvInfo) string {
currentEnvInfo := SerializeEnvInfo(platform, pwd, envLabel, envInfo)
if storedEnvInfo == currentEnvInfo {
return ""
}

stored := parseEnvKV(storedEnvInfo)
current := parseEnvKV(currentEnvInfo)

var diffs []string
keys := []string{"date", "git_branch", "git_dirty", "last_commit", "project_type", "pwd", "env_label"}
for _, k := range keys {
sv, cv := stored[k], current[k]
if sv != cv {
diffs = append(diffs, k+": "+sv+" → "+cv)
}
}

if len(diffs) == 0 {
return ""
}
return "Environment changes since session was last active:\n" + strings.Join(diffs, "\n")
}
26 changes: 22 additions & 4 deletions internal/runner/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"fmt"
"path/filepath"
"strings"
"sync"

"github.com/cnjack/jcode/internal/handler"
)

// ApprovalState manages whether tool calls require interactive user approval.
type ApprovalState struct {
mu sync.Mutex
h handler.AgentEventHandler
mode handler.ApprovalMode // Current approval mode
workpath string // Current working directory for path detection
Expand All @@ -36,22 +38,30 @@ func (s *ApprovalState) SetHandler(h handler.AgentEventHandler) {

// SetMode sets the approval mode (used for external mode changes).
func (s *ApprovalState) SetMode(mode handler.ApprovalMode) {
s.mu.Lock()
s.mode = mode
s.mu.Unlock()
}

// SetWorkpath sets the current working directory (called on environment switch).
func (s *ApprovalState) SetWorkpath(path string) {
s.mu.Lock()
s.workpath = path
s.mu.Unlock()
}

// GetMode returns the current approval mode.
func (s *ApprovalState) GetMode() handler.ApprovalMode {
s.mu.Lock()
defer s.mu.Unlock()
return s.mode
}

// SetSessionApproval sets the approval mode based on the boolean value.
// This is kept for backward compatibility with the channel-based mode sync.
func (s *ApprovalState) SetSessionApproval(enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
if enabled {
s.mode = handler.ModeAuto
} else {
Expand All @@ -64,7 +74,10 @@ func (s *ApprovalState) SetSessionApproval(enabled bool) {
// For everything else it sends a TUI prompt and waits for the user's answer.
func (s *ApprovalState) RequestApproval(ctx context.Context, toolName, toolArgs string) (bool, error) {
// State machine: AUTO mode passes all operations directly
if s.mode == handler.ModeAuto {
s.mu.Lock()
currentMode := s.mode
s.mu.Unlock()
if currentMode == handler.ModeAuto {
return true, nil
}

Expand Down Expand Up @@ -116,7 +129,7 @@ func (s *ApprovalState) RequestApproval(ctx context.Context, toolName, toolArgs
return true, nil
}
cmd := strings.TrimSpace(input.Command)
safePrefix := []string{"ls", "pwd", "env", "ls ", "cat ", "pwd ", "echo ", "which ", "git status", "git log"}
safePrefix := []string{"ls", "pwd", "env", "ls ", "cat ", "pwd ", "echo ", "which ", "git status", "git log", "git diff", "git show"}
for _, p := range safePrefix {
if cmd == p || strings.HasPrefix(cmd, p) {
return true, nil
Expand Down Expand Up @@ -153,7 +166,9 @@ func (s *ApprovalState) requestUserApprovalWithWorker(ctx context.Context, toolN

// State transition: update mode based on user choice
if resp.Approved {
s.mu.Lock()
s.mode = resp.Mode
s.mu.Unlock()
}
return resp.Approved, nil
}
Expand All @@ -163,7 +178,10 @@ func (s *ApprovalState) requestUserApprovalWithWorker(ctx context.Context, toolN
func (s *ApprovalState) NewTeammateApprovalFunc(workerName, workerColor string) func(ctx context.Context, toolName, toolArgs string) (bool, error) {
return func(ctx context.Context, toolName, toolArgs string) (bool, error) {
// Same logic as RequestApproval, but with worker badge.
if s.mode == handler.ModeAuto {
s.mu.Lock()
currentMode := s.mode
s.mu.Unlock()
if currentMode == handler.ModeAuto {
return true, nil
}

Expand Down Expand Up @@ -208,7 +226,7 @@ func (s *ApprovalState) NewTeammateApprovalFunc(workerName, workerColor string)
return true, nil
}
cmd := strings.TrimSpace(input.Command)
safePrefix := []string{"ls", "pwd", "env", "ls ", "cat ", "pwd ", "echo ", "which ", "git status", "git log"}
safePrefix := []string{"ls", "pwd", "env", "ls ", "cat ", "pwd ", "echo ", "which ", "git status", "git log", "git diff", "git show"}
for _, p := range safePrefix {
if cmd == p || strings.HasPrefix(cmd, p) {
return true, nil
Expand Down
Loading