mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 04:00:10 +00:00
mom: add working indicator and improve stop command
Working Indicator:
- Add '...' to channel messages while mom is processing
- Automatically removed when work completes or stops
- Applies to working message, not status messages
Improved Stop Command:
- Posts separate 'Stopping...' message that updates to 'Stopped'
- Original working message continues updating with tool results
- Clean separation between status and work output
- Properly handles abort during multi-step operations
Clean Stop Reason Handling:
- Agent run() now returns { stopReason } instead of throwing
- Handle 'aborted', 'error', 'stop', 'length', 'toolUse' cases cleanly
- No more exception-based control flow
- Track stopReason from assistant message.stopReason field
New SlackContext Methods:
- replaceMessage() - replace message text instead of appending
- setWorking() - add/remove working indicator
- Improved context tracking for stop command updates
This commit is contained in:
parent
bfe7df6a49
commit
0c6c0f34dd
4 changed files with 104 additions and 23 deletions
|
|
@ -20,6 +20,13 @@
|
||||||
- Tracks tokens (input, output, cache read, cache write) and costs per run
|
- Tracks tokens (input, output, cache read, cache write) and costs per run
|
||||||
- Displays summary at end of each agent run in console and Slack thread
|
- Displays summary at end of each agent run in console and Slack thread
|
||||||
- Example: `💰 Usage: 12,543 in + 847 out (5,234 cache read, 127 cache write) = $0.0234`
|
- Example: `💰 Usage: 12,543 in + 847 out (5,234 cache read, 127 cache write) = $0.0234`
|
||||||
|
- Working indicator in Slack messages
|
||||||
|
- Channel messages show "..." while mom is processing
|
||||||
|
- Automatically removed when work completes
|
||||||
|
- Improved stop command behavior
|
||||||
|
- Separate "Stopping..." message that updates to "Stopped" when abort completes
|
||||||
|
- Original working message continues to show tool results (including abort errors)
|
||||||
|
- Clean separation between status and results
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
@ -42,6 +49,9 @@
|
||||||
- Tool result display now extracts actual text instead of showing JSON wrapper
|
- Tool result display now extracts actual text instead of showing JSON wrapper
|
||||||
- Slack thread messages now show cleaner tool call formatting with duration and label
|
- Slack thread messages now show cleaner tool call formatting with duration and label
|
||||||
- All console logging centralized and removed from scattered locations
|
- All console logging centralized and removed from scattered locations
|
||||||
|
- Agent run now returns `{ stopReason }` instead of throwing exceptions
|
||||||
|
- Clean handling of "aborted", "error", "stop", "length", "toolUse" cases
|
||||||
|
- No more error-based control flow
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { createMomTools, setUploadFunction } from "./tools/index.js";
|
||||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||||
|
|
||||||
export interface AgentRunner {
|
export interface AgentRunner {
|
||||||
run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void>;
|
run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<{ stopReason: string }>;
|
||||||
abort(): void;
|
abort(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +316,7 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
const executor = createExecutor(sandboxConfig);
|
const executor = createExecutor(sandboxConfig);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
|
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<{ stopReason: string }> {
|
||||||
// Ensure channel directory exists
|
// Ensure channel directory exists
|
||||||
await mkdir(channelDir, { recursive: true });
|
await mkdir(channelDir, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -374,6 +374,9 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Track stop reason
|
||||||
|
let stopReason = "stop";
|
||||||
|
|
||||||
// Subscribe to events
|
// Subscribe to events
|
||||||
agent.subscribe(async (event: AgentEvent) => {
|
agent.subscribe(async (event: AgentEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|
@ -474,6 +477,11 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
if (event.message.role === "assistant") {
|
if (event.message.role === "assistant") {
|
||||||
const assistantMsg = event.message as any; // AssistantMessage type
|
const assistantMsg = event.message as any; // AssistantMessage type
|
||||||
|
|
||||||
|
// Track stop reason
|
||||||
|
if (assistantMsg.stopReason) {
|
||||||
|
stopReason = assistantMsg.stopReason;
|
||||||
|
}
|
||||||
|
|
||||||
// Accumulate usage
|
// Accumulate usage
|
||||||
if (assistantMsg.usage) {
|
if (assistantMsg.usage) {
|
||||||
totalUsage.input += assistantMsg.usage.input;
|
totalUsage.input += assistantMsg.usage.input;
|
||||||
|
|
@ -520,6 +528,8 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
const summary = log.logUsageSummary(logCtx, totalUsage);
|
const summary = log.logUsageSummary(logCtx, totalUsage);
|
||||||
await ctx.respondInThread(summary);
|
await ctx.respondInThread(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { stopReason };
|
||||||
},
|
},
|
||||||
|
|
||||||
abort(): void {
|
abort(): void {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTH
|
||||||
await validateSandbox(sandbox);
|
await validateSandbox(sandbox);
|
||||||
|
|
||||||
// Track active agent runs per channel
|
// Track active agent runs per channel
|
||||||
const activeRuns = new Map<string, AgentRunner>();
|
const activeRuns = new Map<string, { runner: AgentRunner; context: SlackContext; stopContext?: SlackContext }>();
|
||||||
|
|
||||||
async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Promise<void> {
|
async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Promise<void> {
|
||||||
const channelId = ctx.message.channel;
|
const channelId = ctx.message.channel;
|
||||||
|
|
@ -82,11 +82,15 @@ async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Prom
|
||||||
|
|
||||||
// Check for stop command
|
// Check for stop command
|
||||||
if (messageText === "stop") {
|
if (messageText === "stop") {
|
||||||
const runner = activeRuns.get(channelId);
|
const active = activeRuns.get(channelId);
|
||||||
if (runner) {
|
if (active) {
|
||||||
log.logStopRequest(logCtx);
|
log.logStopRequest(logCtx);
|
||||||
runner.abort();
|
// Post a NEW message saying "Stopping..."
|
||||||
await ctx.respond("_Stopping..._");
|
await ctx.respond("_Stopping..._");
|
||||||
|
// Store this context to update it to "Stopped" later
|
||||||
|
active.stopContext = ctx;
|
||||||
|
// Abort the runner
|
||||||
|
active.runner.abort();
|
||||||
} else {
|
} else {
|
||||||
await ctx.respond("_Nothing running._");
|
await ctx.respond("_Nothing running._");
|
||||||
}
|
}
|
||||||
|
|
@ -103,23 +107,31 @@ async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Prom
|
||||||
const channelDir = join(workingDir, channelId);
|
const channelDir = join(workingDir, channelId);
|
||||||
|
|
||||||
const runner = createAgentRunner(sandbox);
|
const runner = createAgentRunner(sandbox);
|
||||||
activeRuns.set(channelId, runner);
|
activeRuns.set(channelId, { runner, context: ctx });
|
||||||
|
|
||||||
await ctx.setTyping(true);
|
await ctx.setTyping(true);
|
||||||
try {
|
await ctx.setWorking(true);
|
||||||
await runner.run(ctx, channelDir, ctx.store);
|
|
||||||
} catch (error) {
|
const result = await runner.run(ctx, channelDir, ctx.store);
|
||||||
// Don't report abort errors
|
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
// Remove working indicator
|
||||||
if (msg.includes("aborted") || msg.includes("Aborted")) {
|
await ctx.setWorking(false);
|
||||||
// Already said "Stopping..." - nothing more to say
|
|
||||||
} else {
|
// Handle different stop reasons
|
||||||
log.logAgentError(logCtx, msg);
|
const active = activeRuns.get(channelId);
|
||||||
await ctx.respond(`❌ Error: ${msg}`);
|
if (result.stopReason === "aborted") {
|
||||||
|
// Replace the STOP message with "Stopped"
|
||||||
|
if (active?.stopContext) {
|
||||||
|
await active.stopContext.setWorking(false);
|
||||||
|
await active.stopContext.replaceMessage("_Stopped_");
|
||||||
}
|
}
|
||||||
} finally {
|
} else if (result.stopReason === "error") {
|
||||||
|
// Agent encountered an error
|
||||||
|
log.logAgentError(logCtx, "Agent stopped with error");
|
||||||
|
}
|
||||||
|
// "stop", "length", "toolUse" are normal completions - nothing extra to do
|
||||||
|
|
||||||
activeRuns.delete(channelId);
|
activeRuns.delete(channelId);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bot = new MomBot(
|
const bot = new MomBot(
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,16 @@ export interface SlackContext {
|
||||||
store: ChannelStore;
|
store: ChannelStore;
|
||||||
/** Send/update the main message (accumulates text) */
|
/** Send/update the main message (accumulates text) */
|
||||||
respond(text: string): Promise<void>;
|
respond(text: string): Promise<void>;
|
||||||
|
/** Replace the entire message text (not append) */
|
||||||
|
replaceMessage(text: string): Promise<void>;
|
||||||
/** Post a message in the thread under the main message (for verbose details) */
|
/** Post a message in the thread under the main message (for verbose details) */
|
||||||
respondInThread(text: string): Promise<void>;
|
respondInThread(text: string): Promise<void>;
|
||||||
/** Show/hide typing indicator */
|
/** Show/hide typing indicator */
|
||||||
setTyping(isTyping: boolean): Promise<void>;
|
setTyping(isTyping: boolean): Promise<void>;
|
||||||
/** Upload a file to the channel */
|
/** Upload a file to the channel */
|
||||||
uploadFile(filePath: string, title?: string): Promise<void>;
|
uploadFile(filePath: string, title?: string): Promise<void>;
|
||||||
|
/** Set working state (adds/removes working indicator emoji) */
|
||||||
|
setWorking(working: boolean): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MomHandler {
|
export interface MomHandler {
|
||||||
|
|
@ -201,6 +205,8 @@ export class MomBot {
|
||||||
let messageTs: string | null = null;
|
let messageTs: string | null = null;
|
||||||
let accumulatedText = "";
|
let accumulatedText = "";
|
||||||
let isThinking = true; // Track if we're still in "thinking" state
|
let isThinking = true; // Track if we're still in "thinking" state
|
||||||
|
let isWorking = true; // Track if still processing
|
||||||
|
const workingIndicator = " ...";
|
||||||
let updatePromise: Promise<void> = Promise.resolve();
|
let updatePromise: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -227,18 +233,21 @@ export class MomBot {
|
||||||
accumulatedText += "\n" + responseText;
|
accumulatedText += "\n" + responseText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add working indicator if still working
|
||||||
|
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||||
|
|
||||||
if (messageTs) {
|
if (messageTs) {
|
||||||
// Update existing message
|
// Update existing message
|
||||||
await this.webClient.chat.update({
|
await this.webClient.chat.update({
|
||||||
channel: event.channel,
|
channel: event.channel,
|
||||||
ts: messageTs,
|
ts: messageTs,
|
||||||
text: accumulatedText,
|
text: displayText,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Post initial message
|
// Post initial message
|
||||||
const result = await this.webClient.chat.postMessage({
|
const result = await this.webClient.chat.postMessage({
|
||||||
channel: event.channel,
|
channel: event.channel,
|
||||||
text: accumulatedText,
|
text: displayText,
|
||||||
});
|
});
|
||||||
messageTs = result.ts as string;
|
messageTs = result.ts as string;
|
||||||
}
|
}
|
||||||
|
|
@ -267,8 +276,8 @@ export class MomBot {
|
||||||
},
|
},
|
||||||
setTyping: async (isTyping: boolean) => {
|
setTyping: async (isTyping: boolean) => {
|
||||||
if (isTyping && !messageTs) {
|
if (isTyping && !messageTs) {
|
||||||
// Post initial "thinking" message
|
// Post initial "thinking" message (... auto-appended by working indicator)
|
||||||
accumulatedText = "_Thinking..._";
|
accumulatedText = "_Thinking_";
|
||||||
const result = await this.webClient.chat.postMessage({
|
const result = await this.webClient.chat.postMessage({
|
||||||
channel: event.channel,
|
channel: event.channel,
|
||||||
text: accumulatedText,
|
text: accumulatedText,
|
||||||
|
|
@ -288,6 +297,46 @@ export class MomBot {
|
||||||
title: fileName,
|
title: fileName,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
replaceMessage: async (text: string) => {
|
||||||
|
updatePromise = updatePromise.then(async () => {
|
||||||
|
// Replace the accumulated text entirely
|
||||||
|
accumulatedText = text;
|
||||||
|
|
||||||
|
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||||
|
|
||||||
|
if (messageTs) {
|
||||||
|
await this.webClient.chat.update({
|
||||||
|
channel: event.channel,
|
||||||
|
ts: messageTs,
|
||||||
|
text: displayText,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Post initial message
|
||||||
|
const result = await this.webClient.chat.postMessage({
|
||||||
|
channel: event.channel,
|
||||||
|
text: displayText,
|
||||||
|
});
|
||||||
|
messageTs = result.ts as string;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await updatePromise;
|
||||||
|
},
|
||||||
|
setWorking: async (working: boolean) => {
|
||||||
|
updatePromise = updatePromise.then(async () => {
|
||||||
|
isWorking = working;
|
||||||
|
|
||||||
|
// If we have a message, update it to add/remove indicator
|
||||||
|
if (messageTs) {
|
||||||
|
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||||
|
await this.webClient.chat.update({
|
||||||
|
channel: event.channel,
|
||||||
|
ts: messageTs,
|
||||||
|
text: displayText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await updatePromise;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue