Skip to content

Commit 00c89af

Browse files
authored
Merge pull request #9769 from gitbutlerapp/butbot-agent-planning
butbot Agent - planning and routing
2 parents 8c92102 + 7b7585e commit 00c89af

File tree

13 files changed

+1291
-227
lines changed

13 files changed

+1291
-227
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/components/FeedItem.svelte

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,13 @@
208208
</Tooltip>
209209
</div>
210210
</div>
211-
{#each action.toolCalls as toolCall}
212-
<FeedItemKind type="tool-call" {projectId} {toolCall} />
213-
{/each}
211+
{#if action.toolCalls.length > 0}
212+
<div class="action-item__content-tool-calls">
213+
{#each action.toolCalls as toolCall}
214+
<FeedItemKind type="tool-call" {projectId} {toolCall} />
215+
{/each}
216+
</div>
217+
{/if}
214218
{/if}
215219
<span class="text-14">
216220
<Markdown content={action.content} />
@@ -232,9 +236,7 @@
232236
</Tooltip>
233237
</div>
234238
</div>
235-
<span class="text-14">
236-
<FeedStreamMessage {projectId} message={action} />
237-
</span>
239+
<FeedStreamMessage {projectId} message={action} />
238240
</div>
239241
{/if}
240242
</div>
@@ -302,6 +304,16 @@
302304
gap: 8px;
303305
}
304306
307+
.action-item__content-tool-calls {
308+
display: flex;
309+
flex-direction: column;
310+
padding: 10px;
311+
gap: 4px;
312+
border: 1px solid var(--clr-border-2);
313+
314+
border-radius: var(--radius-ml);
315+
}
316+
305317
.action-item__editor-logo {
306318
position: relative;
307319
height: fit-content;

apps/desktop/src/components/FeedStreamMessage.svelte

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script lang="ts">
22
import FeedItemKind from '$components/FeedItemKind.svelte';
3-
import { FEED_FACTORY, type InProgressAssistantMessage } from '$lib/feed/feed';
3+
import { FEED_FACTORY, type InProgressAssistantMessage, type TodoState } from '$lib/feed/feed';
44
import { inject } from '@gitbutler/shared/context';
5-
import { Markdown } from '@gitbutler/ui';
5+
import { Icon, Markdown } from '@gitbutler/ui';
66
77
import type { ToolCall } from '$lib/ai/tool';
88
@@ -15,8 +15,10 @@
1515
1616
const feedFactory = inject(FEED_FACTORY);
1717
const feed = $derived(feedFactory.getFeed(projectId));
18+
1819
let toolCalls = $state<ToolCall[]>(message.toolCalls);
1920
let messageContent = $state(message.content);
21+
let todos = $state<TodoState[]>(message.todos);
2022
const paragraphs = $derived(messageContent.split('\n\n'));
2123
2224
let bottom = $state<HTMLDivElement>();
@@ -35,13 +37,19 @@
3537
}
3638
}
3739
40+
function handleTodoUpdate(list: TodoState[]) {
41+
todos = list;
42+
}
43+
3844
$effect(() => {
3945
const unsubscribe = feed.subscribeToMessage(message.id, (updatedMessage) => {
4046
switch (updatedMessage.type) {
4147
case 'token':
4248
return handleToken(updatedMessage.token);
4349
case 'tool-call':
4450
return handleToolCall(updatedMessage.toolCall);
51+
case 'todo-update':
52+
return handleTodoUpdate(updatedMessage.list);
4553
}
4654
});
4755
@@ -51,16 +59,46 @@
5159
});
5260
</script>
5361

54-
<div>
62+
{#snippet todoItem(todo: TodoState)}
63+
<div class="stream-message__todo-item">
64+
{#if todo.status === 'in-progress'}
65+
<Icon name="spinner" opacity={0.6} />
66+
{:else if todo.status === 'success'}
67+
<Icon name="success" color="success" opacity={0.6} />
68+
{:else if todo.status === 'failed'}
69+
<Icon name="error" color="error" opacity={0.6} />
70+
{:else if todo.status === 'waiting'}
71+
<Icon name="info" opacity={0.6} />
72+
{/if}
73+
<span class="text-12" class:suceeded={todo.status === 'success'}>{todo.title}</span>
74+
</div>
75+
{/snippet}
76+
77+
{#snippet todoList()}
78+
<div class="stream-message__todo-list">
79+
{#each todos as todo (todo.id)}
80+
{@render todoItem(todo)}
81+
{/each}
82+
</div>
83+
{/snippet}
84+
85+
<div class="stream-message text-14">
86+
{#if todos.length > 0}
87+
{@render todoList()}
88+
{/if}
89+
5590
{#if messageContent === '' && toolCalls.length === 0}
5691
<p class="thinking">Thinking...</p>
5792
{:else}
5893
{#if toolCalls.length > 0}
5994
<p class="vibing">Vibing</p>
60-
{#each toolCalls as toolCall, index (index)}
61-
<FeedItemKind type="tool-call" {projectId} {toolCall} />
62-
{/each}
95+
<div class="stream-message__tool-calls">
96+
{#each toolCalls as toolCall, index (index)}
97+
<FeedItemKind type="tool-call" {projectId} {toolCall} />
98+
{/each}
99+
</div>
63100
{/if}
101+
64102
<div class="text-content">
65103
{#each paragraphs as paragraph, index (index)}
66104
<Markdown content={paragraph} />
@@ -70,7 +108,39 @@
70108
<div bind:this={bottom} style="margin-top: 8px; height: 1px; width: 100%;"></div>
71109
</div>
72110

73-
<style>
111+
<style lang="postcss">
112+
.stream-message {
113+
display: flex;
114+
flex-direction: column;
115+
gap: 8px;
116+
}
117+
118+
.stream-message__todo-list {
119+
display: flex;
120+
flex-direction: column;
121+
gap: 8px;
122+
}
123+
124+
.stream-message__todo-item {
125+
display: flex;
126+
gap: 9px;
127+
128+
& > span.suceeded {
129+
color: var(--clr-text-2);
130+
text-decoration: line-through;
131+
}
132+
}
133+
134+
.stream-message__tool-calls {
135+
display: flex;
136+
flex-direction: column;
137+
padding: 10px;
138+
gap: 8px;
139+
border: 1px solid var(--clr-border-2);
140+
141+
border-radius: var(--radius-ml);
142+
}
143+
74144
.thinking,
75145
.vibing {
76146
margin: 4px 0;

apps/desktop/src/lib/feed/feed.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ type ToolCallEvent = ToolCall & {
5656
messageId: string;
5757
};
5858

59+
export type TodoState = {
60+
id: string;
61+
title: string;
62+
status: 'waiting' | 'in-progress' | 'success' | 'failed';
63+
};
64+
65+
type TodoUpdateEvent = {
66+
messageId: string;
67+
list: TodoState[];
68+
};
69+
5970
type UserMessageId = `user-${string}`;
6071

6172
export type UserMessage = {
@@ -80,6 +91,7 @@ export type InProgressAssistantMessage = {
8091
type: 'assistant-in-progress';
8192
content: string;
8293
toolCalls: ToolCall[];
94+
todos: TodoState[];
8395
};
8496

8597
export type FeedMessage = UserMessage | AssistantMessage;
@@ -102,7 +114,7 @@ export function isInProgressAssistantMessage(
102114
}
103115

104116
interface BaseInProgressUpdate {
105-
type: 'token' | 'tool-call';
117+
type: 'token' | 'tool-call' | 'todo-update';
106118
}
107119

108120
export interface TokenUpdate extends BaseInProgressUpdate {
@@ -115,7 +127,12 @@ export interface ToolCallUpdate extends BaseInProgressUpdate {
115127
toolCall: ToolCall;
116128
}
117129

118-
export type InProgressUpdate = TokenUpdate | ToolCallUpdate;
130+
export interface TodoUpdate extends BaseInProgressUpdate {
131+
type: 'todo-update';
132+
list: TodoState[];
133+
}
134+
135+
export type InProgressUpdate = TokenUpdate | ToolCallUpdate | TodoUpdate;
119136

120137
type InProgressSubscribeCallback = (update: InProgressUpdate) => void;
121138

@@ -125,6 +142,7 @@ class Feed {
125142
private unlistenDB: () => void;
126143
private unlistenTokens: () => void;
127144
private unlistenToolCalls: () => void;
145+
private unlistenTodoUpdates: () => void;
128146
private initialized;
129147
private mutex = new Mutex();
130148
private updateTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -163,6 +181,13 @@ class Feed {
163181
this.handleToolCallEvent(event.payload);
164182
}
165183
);
184+
185+
this.unlistenTodoUpdates = this.tauri.listen<TodoUpdateEvent>(
186+
`project://${projectId}/todo-updates`,
187+
(event) => {
188+
this.handleTodoUpdate(event.payload);
189+
}
190+
);
166191
}
167192

168193
isProjectFeed(projectId: string): boolean {
@@ -201,6 +226,11 @@ class Feed {
201226
subscribers.forEach((callback) => callback({ type: 'tool-call', toolCall }));
202227
}
203228

229+
private notifySubscribersTodoUpdate(messageId: InProgressAssistantMessageId, list: TodoState[]) {
230+
const subscribers = this.messageSubscribers.get(messageId) ?? [];
231+
subscribers.forEach((callback) => callback({ type: 'todo-update', list }));
232+
}
233+
204234
private handleDBEvent(event: DBEvent) {
205235
switch (event.kind) {
206236
case 'actions':
@@ -238,6 +268,22 @@ class Feed {
238268
});
239269
}
240270

271+
private async handleTodoUpdate(event: TodoUpdateEvent) {
272+
const inProgressId: InProgressAssistantMessageId = `assistant-in-progress-${event.messageId}`;
273+
this.notifySubscribersTodoUpdate(inProgressId, event.list);
274+
275+
await this.mutex.lock(async () => {
276+
this.combined.update((entries) => {
277+
const existing = entries.find((entry) => entry.id === inProgressId);
278+
if (existing && isInProgressAssistantMessage(existing)) {
279+
existing.todos = event.list;
280+
return deduplicateBy([...entries], 'id');
281+
}
282+
return entries; // If not found, do nothing.
283+
});
284+
});
285+
}
286+
241287
private async handleLastAdded(entry: FeedEntry) {
242288
this.lastAddedId.set(entry.id);
243289
}
@@ -266,7 +312,8 @@ class Feed {
266312
id: `assistant-in-progress-${uuid}`,
267313
type: 'assistant-in-progress',
268314
content: '',
269-
toolCalls: []
315+
toolCalls: [],
316+
todos: []
270317
};
271318

272319
let added = false;
@@ -496,5 +543,6 @@ class Feed {
496543
this.unlistenDB();
497544
this.unlistenTokens();
498545
this.unlistenToolCalls();
546+
this.unlistenTodoUpdates();
499547
}
500548
}

crates/but-action/src/grouping.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
use std::{fmt::Debug, str};
22

3-
use async_openai::types::{ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage};
43
use but_tools::workspace::ProjectStatus;
54
use schemars::JsonSchema;
65
use serde::{Deserialize, Serialize};
76

8-
use crate::{OpenAiProvider, openai};
7+
use crate::{ChatMessage, OpenAiProvider, openai};
98

109
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1110
pub enum BranchSuggestion {
@@ -97,13 +96,11 @@ pub fn group(openai: &OpenAiProvider, project_status: &ProjectStatus) -> anyhow:
9796
serialized_project_status
9897
);
9998

100-
let messages = vec![
101-
ChatCompletionRequestSystemMessage::from(system_message).into(),
102-
ChatCompletionRequestUserMessage::from(user_message).into(),
103-
];
99+
let messages = vec![ChatMessage::User(user_message)];
104100

105-
let grouping = openai::structured_output_blocking::<Grouping>(openai, messages)?
106-
.ok_or_else(|| anyhow::anyhow!("Failed to get grouping from OpenAI"))?;
101+
let grouping =
102+
openai::structured_output_blocking::<Grouping>(openai, system_message, messages)?
103+
.ok_or_else(|| anyhow::anyhow!("Failed to get grouping from OpenAI"))?;
107104

108105
Ok(grouping)
109106
}

crates/but-action/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ pub use action::ActionListing;
3333
pub use action::Source;
3434
pub use action::list_actions;
3535
use but_graph::VirtualBranchesTomlMetadata;
36-
pub use openai::{ChatMessage, ToolCallContent, ToolResponseContent, tool_calling_loop_stream};
36+
pub use openai::{
37+
ChatMessage, ToolCallContent, ToolResponseContent, structured_output_blocking,
38+
tool_calling_loop, tool_calling_loop_stream,
39+
};
3740
use strum::EnumString;
3841
use uuid::Uuid;
3942
pub use workflow::WorkflowList;
@@ -128,7 +131,7 @@ pub fn freestyle(
128131
(emitter)(&name, payload);
129132
}
130133
});
131-
let response = crate::openai::tool_calling_loop_stream(
134+
let (response, _) = crate::openai::tool_calling_loop_stream(
132135
openai,
133136
system_message,
134137
internal_chat_messages,
@@ -137,7 +140,7 @@ pub fn freestyle(
137140
on_token_cb,
138141
)?;
139142

140-
Ok(response.unwrap_or_default())
143+
Ok(response)
141144
}
142145

143146
pub fn absorb(

0 commit comments

Comments
 (0)