diff --git a/internal/agent/middleware.go b/internal/agent/middleware.go index 40b0fe4..414014a 100644 --- a/internal/agent/middleware.go +++ b/internal/agent/middleware.go @@ -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 @@ -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 diff --git a/internal/command/acp.go b/internal/command/acp.go index 38ec642..efc08b4 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -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 { diff --git a/internal/command/interactive.go b/internal/command/interactive.go index 14adb77..877d5a7 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -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": @@ -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}) @@ -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": diff --git a/internal/handler/notifying.go b/internal/handler/notifying.go index a292340..f8e910a 100644 --- a/internal/handler/notifying.go +++ b/internal/handler/notifying.go @@ -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 @@ -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 diff --git a/internal/handler/tui.go b/internal/handler/tui.go index 135b692..b319108 100644 --- a/internal/handler/tui.go +++ b/internal/handler/tui.go @@ -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: diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index 526d2c6..da8a3e2 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -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") +} diff --git a/internal/runner/approval.go b/internal/runner/approval.go index a03769e..35b2c0d 100644 --- a/internal/runner/approval.go +++ b/internal/runner/approval.go @@ -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 @@ -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 { @@ -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 } @@ -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 @@ -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 } @@ -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 } @@ -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 diff --git a/internal/session/history.go b/internal/session/history.go index 58b378a..6405de8 100644 --- a/internal/session/history.go +++ b/internal/session/history.go @@ -43,14 +43,47 @@ func entryToUserMessage(e Entry) *schema.Message { // LLM history messages suitable for resuming a conversation. // It reconstructs tool call and tool result messages so that resumed sessions // retain full context. +// +// Subagent-internal entries (tool_call / tool_result / assistant recorded +// between subagent_start and subagent_result) are skipped — only the main +// agent's own messages are included. +// +// Because the runner records assistant text AFTER tool calls in the JSONL, +// an EntryAssistant that follows tool-call entries is merged back into the +// preceding assistant message as its Content field. func ReconstructHistory(entries []Entry) []adk.Message { var msgs []adk.Message + var subagentDepth int for _, e := range entries { + switch e.Type { + case EntrySubagentStart: + subagentDepth++ + continue + case EntrySubagentResult: + if subagentDepth > 0 { + subagentDepth-- + } + continue + } + // Skip entries that belong to a running subagent. + if subagentDepth > 0 { + continue + } + switch e.Type { case EntryUser: msgs = append(msgs, entryToUserMessage(e)) case EntryAssistant: if e.Content != "" { + // The runner records assistant text after tool calls, so the + // preceding message may already be an assistant with ToolCalls + // but empty Content. Merge into it when possible. + if n := len(msgs); n > 0 { + if last := msgs[n-1]; last.Role == schema.Assistant && last.Content == "" && len(last.ToolCalls) > 0 { + last.Content = e.Content + continue + } + } msgs = append(msgs, &schema.Message{Role: schema.Assistant, Content: e.Content}) } case EntryToolCall: @@ -73,6 +106,66 @@ func ReconstructHistory(entries []Entry) []adk.Message { return msgs } +// toolPlaceholders maps tool names to actionable placeholder messages. +// These tell the model what happened and how to recover the data. +var toolPlaceholders = map[string]string{ + "read": "[File was read previously. Use the read tool again if needed.]", + "grep": "[Search was performed. Run grep again for current results.]", + "execute": "[Command was executed. Run it again if you need fresh output.]", +} + +// defaultPlaceholder is used for tools not in the map above. +const defaultPlaceholder = "[Old tool output cleared. Re-run the tool if needed.]" + +// PruneOldToolOutputs replaces old tool result outputs with actionable +// placeholders, protecting the most recent turns from pruning. +// This implements the Tier 1.5 "placeholder compression" strategy: +// recent tool outputs are preserved verbatim; older ones are replaced +// with hints telling the model how to recover the data. +// +// protectTurns is the number of recent user turns to protect (default 2). +// Returns the pruned messages slice (same backing array, modified in place). +func PruneOldToolOutputs(msgs []adk.Message, protectTurns int) []adk.Message { + if protectTurns <= 0 { + protectTurns = 2 + } + + // Find the protection boundary by counting user messages backwards. + userCount := 0 + protectFrom := len(msgs) // index from which messages are protected + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == schema.User { + userCount++ + if userCount >= protectTurns { + protectFrom = i + break + } + } + } + + // Replace old tool outputs with placeholders. + for i := 0; i < protectFrom; i++ { + msg := msgs[i] + if msg.Role != schema.Tool { + continue + } + toolName := msg.ToolName + if toolName == "" { + // Extract from MultiContent if available. + for _, tc := range msg.ToolCalls { + toolName = tc.Function.Name + } + } + placeholder, ok := toolPlaceholders[toolName] + if !ok { + placeholder = defaultPlaceholder + } + msg.Content = placeholder + } + + return msgs +} + // PlanSnapshot holds the last known plan state from a session. type PlanSnapshot struct { Status string @@ -84,16 +177,20 @@ type PlanSnapshot struct { // SessionState is the full recoverable state from a session file, including // conversation history, plan, todos, mode, and environment. type SessionState struct { - History []adk.Message - Plan *PlanSnapshot // nil if no plan events found - Todos []TodoSnapshotItem // last todo snapshot, nil if none - Mode string // last mode (normal/planning/executing), empty = normal - EnvTarget string // last environment (local/ssh alias) + History []adk.Message + Plan *PlanSnapshot // nil if no plan events found + Todos []TodoSnapshotItem // last todo snapshot, nil if none + Mode string // last mode (normal/planning/executing), empty = normal + EnvTarget string // last environment (local/ssh alias) + SystemPrompt string // recorded system prompt for KV-cache-friendly resume + EnvInfo string // environment snapshot at recording time } // ReconstructState rebuilds the full session state from recorded entries. // It is compact-aware: if a compact entry is found, messages before it are // replaced with the compact summary. +// +// Subagent-internal entries are skipped (same logic as ReconstructHistory). func ReconstructState(entries []Entry) *SessionState { state := &SessionState{ EnvTarget: "local", @@ -101,14 +198,42 @@ func ReconstructState(entries []Entry) *SessionState { var msgs []adk.Message var lastTarget string + var subagentDepth int for _, e := range entries { + // Track subagent boundaries first. + switch e.Type { + case EntrySubagentStart: + subagentDepth++ + continue + case EntrySubagentResult: + if subagentDepth > 0 { + subagentDepth-- + } + continue + case EntrySubagentAsync: + continue + } + + // Skip entries that belong to a running subagent. + if subagentDepth > 0 { + continue + } + switch e.Type { case EntryUser: msgs = append(msgs, entryToUserMessage(e)) case EntryAssistant: if e.Content != "" { + // Merge into preceding assistant message that has tool calls + // but empty content (runner records text after tool calls). + if n := len(msgs); n > 0 { + if last := msgs[n-1]; last.Role == schema.Assistant && last.Content == "" && len(last.ToolCalls) > 0 { + last.Content = e.Content + continue + } + } msgs = append(msgs, &schema.Message{Role: schema.Assistant, Content: e.Content}) } @@ -169,6 +294,10 @@ func ReconstructState(entries []Entry) *SessionState { case EntryModeChange: state.Mode = e.Mode + + case EntrySystemPrompt: + state.SystemPrompt = e.Content + state.EnvInfo = e.EnvInfo } } diff --git a/internal/session/session.go b/internal/session/session.go index 5090060..59aa004 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -33,6 +33,7 @@ const ( EntryModeChange EntryType = "mode_change" EntryCompact EntryType = "compact" EntryBudgetWarning EntryType = "budget_warning" + EntrySystemPrompt EntryType = "system_prompt" ) // TodoSnapshotItem is a single todo entry stored in a todo_snapshot event. @@ -85,6 +86,9 @@ type Entry struct { // compact fields Summary string `json:"summary,omitempty"` CompactedN int `json:"compacted_n,omitempty"` + + // system_prompt fields + EnvInfo string `json:"env_info,omitempty"` // serialized environment snapshot } // SessionMeta is stored in the index for fast listing. @@ -213,11 +217,14 @@ func (r *Recorder) RecordToolCall(name, args, toolCallID string) { } // RecordToolResult appends a tool-result entry. +// Large outputs are automatically truncated (head+tail preserved) and the +// full content is saved to an overflow file on disk. func (r *Recorder) RecordToolResult(name, output, toolCallID string, err error) { errStr := "" if err != nil { errStr = err.Error() } + output = TruncateToolOutput(output, r.uuid, toolCallID) _ = r.writeEntry(Entry{Type: EntryToolResult, Name: name, Output: output, ToolCallID: toolCallID, Error: errStr}) } @@ -266,6 +273,14 @@ func (r *Recorder) RecordCompact(summary string, compactedN int) { _ = r.writeEntry(Entry{Type: EntryCompact, Summary: summary, CompactedN: compactedN}) } +// RecordSystemPrompt records the system prompt and a snapshot of the +// environment information used to generate it. This allows resume to reuse +// the exact same system prompt (maximising KV-cache hit) and inject only +// an environment diff as an appended system message. +func (r *Recorder) RecordSystemPrompt(prompt, envInfo string) { + _ = r.writeEntry(Entry{Type: EntrySystemPrompt, Content: prompt, EnvInfo: envInfo}) +} + // TruncateAtUserMessage rewrites the session file keeping only entries that // appear before the (beforeCount)th user message (0-indexed). // If beforeCount == 0, the file is truncated to the session_start header only. diff --git a/internal/session/truncate.go b/internal/session/truncate.go new file mode 100644 index 0000000..005e73d --- /dev/null +++ b/internal/session/truncate.go @@ -0,0 +1,121 @@ +package session + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/cnjack/jcode/internal/config" +) + +const ( + // MaxOutputBytes is the maximum byte size of a tool output before truncation. + MaxOutputBytes = 50 * 1024 // 50 KB + // MaxOutputLines is the maximum line count of a tool output before truncation. + MaxOutputLines = 2000 + // HeadLines is the number of lines to preserve from the beginning. + HeadLines = 200 + // TailLines is the number of lines to preserve from the end. + TailLines = 500 +) + +// TruncateToolOutput truncates a tool output string if it exceeds size limits. +// It preserves head + tail lines and writes the full content to disk. +// Returns the (possibly truncated) output. If no truncation is needed, the +// original string is returned unchanged. +// +// sessionUUID is used to namespace the overflow files. toolCallID identifies +// the specific tool call. +func TruncateToolOutput(output, sessionUUID, toolCallID string) string { + if len(output) <= MaxOutputBytes && countLines(output) <= MaxOutputLines { + return output + } + + lines := strings.Split(output, "\n") + totalLines := len(lines) + totalBytes := len(output) + + // Save full output to disk. + overflowPath := saveOverflow(output, sessionUUID, toolCallID) + + // Build truncated version: head + notice + tail. + headEnd := HeadLines + if headEnd > totalLines { + headEnd = totalLines + } + tailStart := totalLines - TailLines + if tailStart < headEnd { + tailStart = headEnd + } + + var sb strings.Builder + // Head section. + for i := 0; i < headEnd; i++ { + sb.WriteString(lines[i]) + sb.WriteByte('\n') + } + + // Truncation notice. + omitted := tailStart - headEnd + if omitted > 0 { + notice := fmt.Sprintf("\n... [%d lines / %d bytes truncated] ...\n", omitted, totalBytes) + if overflowPath != "" { + notice += fmt.Sprintf("Full output saved to: %s\nUse the read tool to view the complete output.\n", overflowPath) + } + sb.WriteString(notice) + } + + // Tail section. + for i := tailStart; i < totalLines; i++ { + sb.WriteString(lines[i]) + if i < totalLines-1 { + sb.WriteByte('\n') + } + } + + return sb.String() +} + +// saveOverflow writes the full tool output to a file under the session's +// overflow directory. Returns the file path, or "" on error. +func saveOverflow(content, sessionUUID, toolCallID string) string { + dir, err := config.SessionsDir() + if err != nil { + return "" + } + + overflowDir := filepath.Join(dir, "overflow", sessionUUID) + if err := os.MkdirAll(overflowDir, 0o700); err != nil { + config.Logger().Printf("[session] overflow mkdir error: %v", err) + return "" + } + + // Sanitize toolCallID for filename safety. + safeID := toolCallID + if safeID == "" { + safeID = "unknown" + } + safeID = strings.ReplaceAll(safeID, "/", "_") + safeID = strings.ReplaceAll(safeID, "..", "_") + + fp := filepath.Join(overflowDir, safeID+".txt") + if err := os.WriteFile(fp, []byte(content), 0o600); err != nil { + config.Logger().Printf("[session] overflow write error: %v", err) + return "" + } + return fp +} + +func countLines(s string) int { + if s == "" { + return 0 + } + n := 1 + for i := range s { + if s[i] == '\n' { + n++ + } + } + return n +} diff --git a/internal/telemetry/langfuse.go b/internal/telemetry/langfuse.go index dc44656..578a845 100644 --- a/internal/telemetry/langfuse.go +++ b/internal/telemetry/langfuse.go @@ -20,6 +20,19 @@ type contextKey string const traceIDKey contextKey = "langfuse_trace_id" const parentSpanIDKey contextKey = "langfuse_parent_span_id" +const toolSpanIDKey contextKey = "langfuse_tool_span_id" +const toolSpanTracerKey contextKey = "langfuse_tool_span_tracer" + +// SubSpanFunc creates a child span under the current tool span. +// It returns a finish function that must be called with the output string. +type SubSpanFunc func(name string) (finish func(output string)) + +// SubSpanFromContext retrieves the sub-span creator stored by the langfuse +// WrapToolCall middleware. Returns nil if tracing is not active. +func SubSpanFromContext(ctx context.Context) SubSpanFunc { + fn, _ := ctx.Value(toolSpanTracerKey).(SubSpanFunc) + return fn +} // LangfuseTracer wraps the Langfuse client and provides eino integration helpers. type LangfuseTracer struct { @@ -210,6 +223,35 @@ func (t *LangfuseTracer) buildMiddleware(useParentSpan bool) adk.AgentMiddleware }, }) } + + // Store a sub-span creator in context so downstream middleware + // (e.g. approval) can create child spans under this tool span. + if spanID != "" { + subSpanFunc := SubSpanFunc(func(name string) func(output string) { + childStart := time.Now() + childID, _ := t.client.CreateSpan(&langfuseacl.SpanEventBody{ + BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ + BaseEventBody: langfuseacl.BaseEventBody{Name: name}, + TraceID: traceID, + ParentObservationID: spanID, + StartTime: childStart, + }, + }) + return func(output string) { + if childID != "" { + _ = t.client.EndSpan(&langfuseacl.SpanEventBody{ + BaseObservationEventBody: langfuseacl.BaseObservationEventBody{ + BaseEventBody: langfuseacl.BaseEventBody{ID: childID}, + Output: output, + }, + EndTime: time.Now(), + }) + } + } + }) + ctx = context.WithValue(ctx, toolSpanTracerKey, subSpanFunc) + } + out, err := next(ctx, input) if spanID != "" { output := "" diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index 85c8751..285a721 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -266,9 +266,6 @@ func (s *subagentTool) runSubagent(ctx context.Context, ag *adk.ChatModelAgent, toolName := mo.ToolName if !mo.IsStreaming && mo.Message != nil { s.notifyProgress(input.Name, "tool_result", toolName, mo.Message.Content) - if s.deps.Recorder != nil { - s.deps.Recorder.RecordToolResult(toolName, mo.Message.Content, mo.Message.ToolCallID, nil) - } } else if mo.IsStreaming { var sb strings.Builder var toolCallID string @@ -288,9 +285,6 @@ func (s *subagentTool) runSubagent(ctx context.Context, ag *adk.ChatModelAgent, } } s.notifyProgress(input.Name, "tool_result", toolName, sb.String()) - if s.deps.Recorder != nil { - s.deps.Recorder.RecordToolResult(toolName, sb.String(), toolCallID, nil) - } } continue } @@ -331,22 +325,16 @@ func (s *subagentTool) runSubagent(ctx context.Context, ag *adk.ChatModelAgent, assistantText.WriteString(chunk.Content) } } - // Notify and record accumulated tool calls. + // Notify accumulated tool calls (progress only, not recorded to session). for _, p := range pending { s.notifyProgress(input.Name, "tool_call", p.name, p.args.String()) - if s.deps.Recorder != nil { - s.deps.Recorder.RecordToolCall(p.name, p.args.String(), "") - } } reportTokens() } else if mo.Message != nil { - // Forward tool call events. + // Forward tool call events (progress only, not recorded to session). for _, tc := range mo.Message.ToolCalls { if tc.Function.Name != "" { s.notifyProgress(input.Name, "tool_call", tc.Function.Name, tc.Function.Arguments) - if s.deps.Recorder != nil { - s.deps.Recorder.RecordToolCall(tc.Function.Name, tc.Function.Arguments, tc.ID) - } } } if mo.Message.Content != "" { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 20256d2..8887757 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -14,8 +14,8 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/glamour/v2" "charm.land/lipgloss/v2" - "github.com/rivo/uniseg" "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/session" @@ -118,7 +118,8 @@ type Model struct { approvalWorkerName string // Non-empty for teammate approval approvalWorkerColor string // Teammate color approvalMode ApprovalMode - approvalSelected int // 0=Approve, 1=ApproveAll, 2=Reject + approvalSelected int // 0=Approve, 1=ApproveAll, 2=Reject + approvalQueue []ToolApprovalRequestMsg // queued requests when dialog is already active envLabel string agentMode AgentMode @@ -594,64 +595,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen case "enter", " ": switch m.approvalSelected { case 0: // Approve once - m.approvalPending = false - if m.approvalRespChan != nil { - m.approvalRespChan <- ToolApprovalResponse{Approved: true, Mode: ModeManual} - } + m.resolveApproval(ToolApprovalResponse{Approved: true, Mode: ModeManual}) case 1: // Approve all - m.approvalPending = false m.approvalMode = ModeAuto - if m.approvalRespChan != nil { - m.approvalRespChan <- ToolApprovalResponse{Approved: true, Mode: ModeAuto} - } + m.resolveApproval(ToolApprovalResponse{Approved: true, Mode: ModeAuto}) if m.OnApprovalModeChange != nil { m.OnApprovalModeChange(true) } case 2: // Reject - m.approvalPending = false - if m.approvalRespChan != nil { - m.approvalRespChan <- ToolApprovalResponse{Approved: false, Mode: m.approvalMode} - } m.lines = append(m.lines, textLine(fmt.Sprintf(" %s %s — user denied this operation", toolErrorStyle.Render("⚠ Rejected:"), toolNameStyle.Render(m.approvalToolName)))) + m.resolveApproval(ToolApprovalResponse{Approved: false, Mode: m.approvalMode}) } - m.textarea.Focus() - m.refreshViewport() return m, tea.Batch(cmds...) case "y", "Y": // Event: ApproveOnce - approve current only, stay in MANUAL mode - m.approvalPending = false - if m.approvalRespChan != nil { - m.approvalRespChan <- ToolApprovalResponse{Approved: true, Mode: ModeManual} - } - m.textarea.Focus() - m.refreshViewport() + m.resolveApproval(ToolApprovalResponse{Approved: true, Mode: ModeManual}) return m, tea.Batch(cmds...) case "a", "A": - m.approvalPending = false m.approvalMode = ModeAuto - if m.approvalRespChan != nil { - m.approvalRespChan <- ToolApprovalResponse{Approved: true, Mode: ModeAuto} - } + m.resolveApproval(ToolApprovalResponse{Approved: true, Mode: ModeAuto}) if m.OnApprovalModeChange != nil { m.OnApprovalModeChange(true) } - m.textarea.Focus() - m.refreshViewport() return m, tea.Batch(cmds...) case "n", "N", "esc": // Event: Reject - deny the operation - m.approvalPending = false - if m.approvalRespChan != nil { - m.approvalRespChan <- ToolApprovalResponse{Approved: false, Mode: m.approvalMode} - } - // Show rejection notice in chat view m.lines = append(m.lines, textLine(fmt.Sprintf(" %s %s — user denied this operation", toolErrorStyle.Render("⚠ Rejected:"), toolNameStyle.Render(m.approvalToolName)))) - m.textarea.Focus() - m.refreshViewport() + m.resolveApproval(ToolApprovalResponse{Approved: false, Mode: m.approvalMode}) return m, tea.Batch(cmds...) } return m, tea.Batch(cmds...) @@ -2117,16 +2091,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen } case ToolApprovalRequestMsg: - m.approvalPending = true - m.approvalToolName = msg.Name - m.approvalToolArgs = msg.Args - m.approvalRespChan = msg.Resp - m.approvalIsExternal = msg.IsExternal - m.approvalWorkerName = msg.WorkerName - m.approvalWorkerColor = msg.WorkerColor - m.approvalSelected = 0 // Default to "Approve" - m.textarea.Blur() - m.refreshViewport() + if m.approvalPending { + // Already showing a dialog — queue this request instead of overwriting. + m.approvalQueue = append(m.approvalQueue, msg) + } else { + m.showApproval(msg) + } case SubagentStartMsg: m.thinking = true @@ -2724,6 +2694,53 @@ func (m *Model) refreshViewport() { } } +// showApproval activates the approval dialog for a single request. +func (m *Model) showApproval(msg ToolApprovalRequestMsg) { + m.approvalPending = true + m.approvalToolName = msg.Name + m.approvalToolArgs = msg.Args + m.approvalRespChan = msg.Resp + m.approvalIsExternal = msg.IsExternal + m.approvalWorkerName = msg.WorkerName + m.approvalWorkerColor = msg.WorkerColor + m.approvalSelected = 0 // Default to "Approve" + m.textarea.Blur() + m.refreshViewport() +} + +// resolveApproval responds to the current approval dialog and, if there are +// queued requests, immediately shows the next one. When the user selects +// "Approve All" (ModeAuto), all queued requests are auto-approved at once. +func (m *Model) resolveApproval(resp ToolApprovalResponse) { + m.approvalPending = false + if m.approvalRespChan != nil { + m.approvalRespChan <- resp + } + + // If user chose "Approve All", auto-approve everything in the queue. + if resp.Mode == ModeAuto { + for _, queued := range m.approvalQueue { + if queued.Resp != nil { + queued.Resp <- ToolApprovalResponse{Approved: true, Mode: ModeAuto} + } + } + m.approvalQueue = nil + m.textarea.Focus() + m.refreshViewport() + return + } + + // Show next queued approval, or restore input focus. + if len(m.approvalQueue) > 0 { + next := m.approvalQueue[0] + m.approvalQueue = m.approvalQueue[1:] + m.showApproval(next) + } else { + m.textarea.Focus() + m.refreshViewport() + } +} + // --- Helpers --- // maxRenderedLines estimates the number of rendered lines for a tool result.