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
2 changes: 2 additions & 0 deletions src/crates/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
duration_ms: outcome.duration_ms,
subagent_parent_info: None,
partial_recovery_reason: None,
success: Some(true),
finish_reason: Some("complete".to_string()),
})
.await;

Expand Down
24 changes: 13 additions & 11 deletions src/crates/core/src/agentic/execution/execution_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1747,6 +1747,17 @@ impl ExecutionEngine {
context.dialog_turn_id, completed_rounds, total_tools
);

// Determine finish reason
let finish_reason = if loop_detected {
FinishReason::LoopDetected
} else if completed_rounds >= self.config.max_rounds {
FinishReason::MaxRounds
} else {
FinishReason::Complete
};

let success = !loop_detected && completed_rounds < self.config.max_rounds;

// Emit dialog turn completed event
debug!("Preparing to send DialogTurnCompleted event");

Expand All @@ -1761,6 +1772,8 @@ impl ExecutionEngine {
duration_ms,
subagent_parent_info: event_subagent_parent_info,
partial_recovery_reason: last_partial_recovery_reason,
success: Some(success),
finish_reason: Some(finish_reason.to_string()),
},
None,
)
Expand Down Expand Up @@ -1797,17 +1810,6 @@ impl ExecutionEngine {
);
}

// Determine finish reason
let finish_reason = if loop_detected {
FinishReason::LoopDetected
} else if completed_rounds >= self.config.max_rounds {
FinishReason::MaxRounds
} else {
FinishReason::Complete
};

let success = !loop_detected && completed_rounds < self.config.max_rounds;

if loop_detected {
warn!(
"Dialog turn stopped due to loop detection: turn={}, rounds={}",
Expand Down
13 changes: 13 additions & 0 deletions src/crates/core/src/agentic/execution/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ pub enum FinishReason {
LoopDetected,
}

impl std::fmt::Display for FinishReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FinishReason::Complete => write!(f, "complete"),
FinishReason::ToolCalls => write!(f, "tool_calls"),
FinishReason::MaxRounds => write!(f, "max_rounds"),
FinishReason::Cancelled => write!(f, "cancelled"),
FinishReason::Error => write!(f, "error"),
FinishReason::LoopDetected => write!(f, "loop_detected"),
}
}
}

/// Execution result
#[derive(Debug, Clone)]
pub struct ExecutionResult {
Expand Down
31 changes: 30 additions & 1 deletion src/crates/core/src/agentic/tools/implementations/git_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,19 @@ impl GitTool {
.await
.map_err(|e| BitFunError::tool(format!("Git diff failed: {}", e)))?;

// When there are no differences, git diff returns exit code 0 with an
// empty stdout. Return a friendly message so the model (and user) see
// a clear "no changes" indication instead of a bare empty string.
let stdout = if diff_output.trim().is_empty() {
"No differences found.".to_string()
} else {
diff_output
};

Ok(json!({
"success": true,
"exit_code": 0,
"stdout": diff_output,
"stdout": stdout,
"stderr": ""
}))
}
Expand Down Expand Up @@ -777,6 +786,12 @@ This tool provides a safe and convenient way to execute Git commands. It support
{"operation": "switch", "args": "main"}
```

## Important: `args` Field Rules

- The `operation` field already specifies the Git subcommand (e.g. `diff`, `log`, `add`).
- The `args` field must contain **only additional arguments** for that subcommand.
- **Do NOT include the subcommand name itself in `args`.** For example, use `{"operation": "diff", "args": "HEAD~2..HEAD --stat"}` — NOT `{"operation": "diff", "args": "diff HEAD~2..HEAD --stat"}`.

## Safety Notes

- This tool validates operations to ensure only allowed Git commands are executed
Expand Down Expand Up @@ -1022,6 +1037,20 @@ When creating commits, use this format for the commit message:

let args = input.get("args").and_then(|v| v.as_str());

// Tolerance: strip a leading operation name from args if the model
// mistakenly includes it (e.g. "diff HEAD~2..HEAD --stat" when
// operation is already "diff"). This prevents commands like
// "git diff diff HEAD~2..HEAD --stat".
let args = args.map(|a| {
let trimmed = a.trim();
let prefix = format!("{} ", operation);
if trimmed.starts_with(&prefix) {
&trimmed[prefix.len()..]
} else {
trimmed
}
});

let working_directory = input.get("working_directory").and_then(|v| v.as_str());

// Get repository path
Expand Down
7 changes: 7 additions & 0 deletions src/crates/events/src/agentic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ pub enum AgenticEvent {
/// recovery (stream aborted mid-way). Contains a human-readable reason.
#[serde(skip_serializing_if = "Option::is_none")]
partial_recovery_reason: Option<String>,
/// Whether the turn completed successfully (false for loop_detected or
/// max_rounds).
#[serde(skip_serializing_if = "Option::is_none")]
success: Option<bool>,
/// Why the turn finished: "complete", "loop_detected", or "max_rounds".
#[serde(skip_serializing_if = "Option::is_none")]
finish_reason: Option<String>,
},

DialogTurnCancelled {
Expand Down
6 changes: 6 additions & 0 deletions src/crates/transport/src/adapters/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub enum CliEvent {
DialogTurnCompleted {
session_id: String,
turn_id: String,
success: Option<bool>,
finish_reason: Option<String>,
},
/// Generic event (for LSP, file watch, etc.)
Generic {
Expand Down Expand Up @@ -93,10 +95,14 @@ impl TransportAdapter for CliTransportAdapter {
AgenticEvent::DialogTurnCompleted {
session_id,
turn_id,
success,
finish_reason,
..
} => CliEvent::DialogTurnCompleted {
session_id,
turn_id,
success,
finish_reason,
},
_ => return Ok(()),
};
Expand Down
4 changes: 4 additions & 0 deletions src/crates/transport/src/adapters/tauri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ impl TransportAdapter for TauriTransportAdapter {
turn_id,
subagent_parent_info,
partial_recovery_reason,
success,
finish_reason,
..
} => {
self.app_handle.emit(
Expand All @@ -204,6 +206,8 @@ impl TransportAdapter for TauriTransportAdapter {
"turnId": turn_id,
"subagentParentInfo": subagent_parent_info,
"partialRecoveryReason": partial_recovery_reason,
"success": success,
"finishReason": finish_reason,
}),
)?;
}
Expand Down
4 changes: 4 additions & 0 deletions src/crates/transport/src/adapters/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ impl TransportAdapter for WebSocketTransportAdapter {
turn_id,
subagent_parent_info,
partial_recovery_reason,
success,
finish_reason,
..
} => {
json!({
Expand All @@ -173,6 +175,8 @@ impl TransportAdapter for WebSocketTransportAdapter {
"turnId": turn_id,
"subagentParentInfo": subagent_parent_info,
"partialRecoveryReason": partial_recovery_reason,
"success": success,
"finishReason": finish_reason,
})
}
_ => return Ok(()),
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/flow_chat/components/FlowTextBlock.scss
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@
gap: 0.5rem;
min-height: 1.5rem;
color: var(--color-text-muted);
font-size: var(--flowchat-font-size-sm);
font-size: var(--flowchat-font-size-base);
line-height: 1.4;
opacity: 0.86;
}
Expand Down
17 changes: 10 additions & 7 deletions src/web-ui/src/flow_chat/components/FlowTextBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownRenderer } from '@/component-library';
import { TaskRunningIndicator } from '@/component-library';
import { DotMatrixLoader } from '@/component-library';
import type { FlowTextItem } from '../types/flow-chat';
import { useFlowChatContext } from './modern/FlowChatContext';
import { useTypewriter } from '../hooks/useTypewriter';
import { DEFAULT_MODEL_RESPONSE_STATUS_MESSAGE_KEY } from '../services/flow-chat-manager/RuntimeStatusModule';
import { processingHintsZh, processingHintsEn } from '../constants/processingHints';
import './FlowTextBlock.scss';

// Idle timeout (ms) after content stops growing.
Expand All @@ -34,7 +34,7 @@ export const FlowTextBlock = React.memo<FlowTextBlockProps>(({
replayStreamingOnMount = true
}) => {
const { onFileViewRequest, onTabOpen, onOpenVisualization } = useFlowChatContext();
const { t } = useTranslation('flow-chat');
const { i18n } = useTranslation();

// Normalize content to a string.
const content = typeof textItem.content === 'string'
Expand Down Expand Up @@ -79,17 +79,20 @@ export const FlowTextBlock = React.memo<FlowTextBlockProps>(({
}
}, [textItem.status, textItem.isStreaming]);

const isActivelyStreaming = textItem.isStreaming &&
const isActivelyStreaming = textItem.isStreaming &&
(textItem.status === 'streaming' || textItem.status === 'running') &&
isContentGrowing;
const hasContent = content.length > 0;

if (textItem.runtimeStatus) {
const messageKey = textItem.runtimeStatus.messageKey || DEFAULT_MODEL_RESPONSE_STATUS_MESSAGE_KEY;
const hints = i18n.language.startsWith('zh') ? processingHintsZh : processingHintsEn;
const hintIndex = Math.abs(textItem.id.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0)) % hints.length;
const hint = hints[hintIndex];

return (
<div className={`flow-text-block flow-text-block--runtime-status ${className}`}>
<TaskRunningIndicator size="sm" className="flow-text-block__runtime-status-icon" />
<span className="flow-text-block__runtime-status-text">{t(messageKey)}</span>
<DotMatrixLoader size="medium" className="flow-text-block__runtime-status-icon" />
<span className="flow-text-block__runtime-status-text">{hint}</span>
</div>
);
}
Expand Down
11 changes: 9 additions & 2 deletions src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
// should not push the first summary line downward when the round is
// re-wrapped from `model-round` into `explore-group`.
padding: 0;
// Hide scrollbar track by default; only show when content actually overflows.
overflow: hidden;

// In the plain model-round layout, the first/last flow item margins can
// collapse with the parent. Once wrapped by explore-group, that collapse no
Expand Down Expand Up @@ -113,6 +115,8 @@
// Align nested tool cards with the summary text, not the group chevron.
padding: 0 0 0 20px;
box-sizing: border-box;
// Hide scrollbar track by default; only show when content actually overflows.
overflow: hidden;

&::-webkit-scrollbar {
width: 4px;
Expand Down Expand Up @@ -201,11 +205,14 @@
}
}

// Limit height and enable scroll during streaming.
// Limit height and enable scroll only when content actually overflows.
.explore-region--has-scroll .explore-region__content {
overflow-y: auto;
}

.explore-region--expanded.explore-region--streaming {
.explore-region__content {
max-height: 400px;
overflow-y: auto;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,36 @@
}
}

.turn-stopped-banner {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 8px 0;
padding: 10px 14px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 25%, transparent);

&__icon {
flex-shrink: 0;
color: var(--color-warning, #f59e0b);
margin-top: 1px;
}

&__content {
min-width: 0;
}

&__title {
font-weight: 600;
font-size: 13px;
color: var(--color-text-primary, #e8e8e8);
margin-bottom: 2px;
}

&__suggestion {
font-size: 12px;
color: var(--color-text-muted, #858585);
line-height: 1.5;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*/

import React from 'react';
import { Loader2 } from 'lucide-react';
import { Loader2, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { VirtualItem } from '../../store/modernFlowChatStore';
import { UserMessageItem } from './UserMessageItem';
import { ModelRoundItem } from './ModelRoundItem';
Expand All @@ -21,6 +22,7 @@ interface VirtualItemRendererProps {
export const VirtualItemRenderer = React.memo<VirtualItemRendererProps>(
({ item, index }) => {
const { searchMatchIndices, searchCurrentMatchVirtualIndex } = useFlowChatContext();
const { t } = useTranslation('errors');
const isSearchMatch = searchMatchIndices != null && searchMatchIndices.size > 0
? searchMatchIndices.has(index)
: false;
Expand Down Expand Up @@ -63,6 +65,30 @@ export const VirtualItemRenderer = React.memo<VirtualItemRendererProps>(
/>
</div>
);

case 'turn-stopped': {
const titleKey = item.finishReason === 'loop_detected'
? 'ai.loopDetected'
: item.finishReason === 'max_rounds'
? 'ai.maxRounds'
: 'ai.loopDetected';
const suggestionKey = item.finishReason === 'loop_detected'
? 'ai.loopDetectedSuggestion'
: item.finishReason === 'max_rounds'
? 'ai.maxRoundsSuggestion'
: 'ai.loopDetectedSuggestion';
return (
<div className="turn-stopped-banner">
<div className="turn-stopped-banner__icon">
<AlertTriangle size={16} />
</div>
<div className="turn-stopped-banner__content">
<div className="turn-stopped-banner__title">{t(titleKey)}</div>
<div className="turn-stopped-banner__suggestion">{t(suggestionKey)}</div>
</div>
</div>
);
}

default:
return <div style={{ minHeight: '1px' }} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,8 @@ function handleDialogTurnComplete(
const subagentParentInfo = normalizeSubagentParentInfo(event);
// Partial recovery reason from backend (stream was interrupted mid-way)
const partialRecoveryReason = event?.partialRecoveryReason ?? event?.partial_recovery_reason;
const success = event?.success;
const finishReason = event?.finishReason ?? event?.finish_reason;

if (subagentParentInfo) {
if (sessionId) {
Expand Down Expand Up @@ -1561,6 +1563,8 @@ function handleDialogTurnComplete(
return {
...turn,
status: 'finishing' as const,
success: success ?? undefined,
finishReason: finishReason ?? undefined,
};
});

Expand Down
Loading
Loading