Skip to content

Commit 06a5e02

Browse files
authored
feat(cli, vscode): Support bash command execution for Pochi workflows (#554)
* feat: Support bash command execution for Pochi workflows - Add new bash command execution functionality for workflows - Parse allowed-tools from workflow frontmatter as raw strings - Extract and validate bash commands from workflow content - Execute validated commands with proper abort signal handling - Integrate bash command execution into live chat kit - Update workflow prompt generation to include allowed-tools attribute - Add micromatch dependency for pattern matching * update * update * update * update * update: workflow utils * update * update * update: cli * update * update * update: createBashOutputsReminder * update: remove executeWorkflowBashCommands * update * update: onOverrideMessages * revert workflows.mdx
1 parent 1186a74 commit 06a5e02

File tree

13 files changed

+334
-2
lines changed

13 files changed

+334
-2
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { exec } from "node:child_process";
2+
import { prompts } from "@getpochi/common";
3+
import { extractWorkflowBashCommands } from "@getpochi/common/message-utils";
4+
import type { UIMessage } from "ai";
5+
6+
export function createOnOverrideMessages(cwd: string) {
7+
return async function onOverrideMessages({
8+
messages,
9+
}: { messages: UIMessage[] }) {
10+
const lastMessage = messages.at(-1);
11+
if (lastMessage?.role === "user") {
12+
await appendWorkflowBashOutputs(cwd, lastMessage);
13+
}
14+
};
15+
}
16+
17+
async function appendWorkflowBashOutputs(cwd: string, message: UIMessage) {
18+
if (message.role !== "user") return;
19+
20+
const commands = extractWorkflowBashCommands(message);
21+
if (!commands.length) return [];
22+
23+
const bashCommandResults: {
24+
command: string;
25+
output: string;
26+
error?: string;
27+
}[] = [];
28+
for (const command of commands) {
29+
try {
30+
const { output, error } = await executeBashCommand(cwd, command);
31+
bashCommandResults.push({ command, output, error });
32+
} catch (e) {
33+
const error = e instanceof Error ? e.message : String(e);
34+
bashCommandResults.push({ command, output: "", error });
35+
// The AbortError is a specific error that should stop the whole process.
36+
if (e instanceof Error && e.name === "AbortError") {
37+
break;
38+
}
39+
}
40+
}
41+
42+
if (bashCommandResults.length) {
43+
prompts.injectBashOutputs(message, bashCommandResults);
44+
}
45+
}
46+
47+
function executeBashCommand(
48+
cwd: string,
49+
command: string,
50+
): Promise<{ output: string; error?: string }> {
51+
return new Promise((resolve) => {
52+
exec(command, { cwd }, (error, stdout, stderr) => {
53+
if (error) {
54+
resolve({ output: stdout, error: stderr || error.message });
55+
} else {
56+
resolve({ output: stdout });
57+
}
58+
});
59+
});
60+
}

packages/cli/src/task-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type z from "zod/v4";
2727
import { readEnvironment } from "./lib/read-environment";
2828
import { StepCount } from "./lib/step-count";
2929
import { Chat } from "./livekit";
30+
import { createOnOverrideMessages } from "./on-override-messages";
3031
import { executeToolCall } from "./tools";
3132
import type { ToolCallOptions } from "./types";
3233

@@ -144,6 +145,7 @@ export class TaskRunner {
144145
isSubTask: options.isSubTask,
145146
customAgent: options.customAgent,
146147
outputSchema: options.outputSchema,
148+
onOverrideMessages: createOnOverrideMessages(this.cwd),
147149
getters: {
148150
getLLM: () => options.llm,
149151
getEnvironment: async () => ({

packages/common/src/base/prompts/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createCompactPrompt } from "./compact";
22
import { createEnvironmentPrompt, injectEnvironment } from "./environment";
33
import { generateTitle } from "./generate-title";
4+
import { injectBashOutputs } from "./inject-bash-outputs";
45
import { createSystemPrompt } from "./system";
56

67
export const prompts = {
@@ -16,6 +17,7 @@ export const prompts = {
1617
parseInlineCompact,
1718
generateTitle,
1819
workflow: createWorkflowPrompt,
20+
injectBashOutputs,
1921
};
2022

2123
function createSystemReminder(content: string) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { TextUIPart, UIMessage } from "ai";
2+
import { prompts } from "./index";
3+
4+
export function injectBashOutputs(
5+
message: UIMessage,
6+
outputs: {
7+
command: string;
8+
output: string;
9+
error?: string | undefined;
10+
}[],
11+
) {
12+
const bashCommandOutputs = outputs.map(({ command, output, error }) => {
13+
let result = `$ ${command}`;
14+
if (output) {
15+
result += `\n${output}`;
16+
}
17+
if (error) {
18+
result += `\nERROR: ${error}`;
19+
}
20+
return result;
21+
});
22+
23+
const reminderPart = {
24+
type: "text",
25+
text: prompts.createSystemReminder(
26+
`Bash command outputs:\n${bashCommandOutputs.join("\n\n")}`,
27+
),
28+
} satisfies TextUIPart;
29+
30+
const workflowPartIndex = message.parts.findIndex(isWorkflowTextPart);
31+
const indexToInsert = workflowPartIndex === -1 ? 0 : workflowPartIndex;
32+
message.parts = [
33+
...message.parts.slice(0, indexToInsert),
34+
reminderPart,
35+
...message.parts.slice(indexToInsert),
36+
];
37+
}
38+
39+
function isWorkflowTextPart(part: UIMessage["parts"][number]) {
40+
return (
41+
part.type === "text" && /<workflow[^>]*>(.*?)<\/workflow>/gs.test(part.text)
42+
);
43+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { UIMessage } from "ai";
2+
import { describe, expect, it } from "vitest";
3+
import { extractWorkflowBashCommands } from "../workflow";
4+
5+
describe("extractWorkflowBashCommands", () => {
6+
it("should extract bash commands from a workflow", () => {
7+
const message: UIMessage = {
8+
id: "1",
9+
role: "user",
10+
parts: [
11+
{
12+
type: "text",
13+
text: `<workflow id="test-workflow" path=".pochi/workflows/test.md">
14+
## Context
15+
- Current git status: !\`git status\`
16+
- Current git diff (staged and unstaged changes): !\`git diff HEAD\`
17+
## Task
18+
Create a git commit.
19+
</workflow>`,
20+
},
21+
],
22+
};
23+
24+
const commands = extractWorkflowBashCommands(message);
25+
26+
expect(commands).toEqual(["git status", "git diff HEAD"]);
27+
});
28+
29+
it("should handle messages with no workflows", () => {
30+
const message: UIMessage = {
31+
id: "1",
32+
role: "user",
33+
parts: [
34+
{
35+
type: "text",
36+
text: "This is a regular message with no workflows.",
37+
},
38+
],
39+
};
40+
41+
const commands = extractWorkflowBashCommands(message);
42+
43+
expect(commands).toEqual([]);
44+
});
45+
46+
it("should handle workflows with no bash commands", () => {
47+
const message: UIMessage = {
48+
id: "1",
49+
role: "user",
50+
parts: [
51+
{
52+
type: "text",
53+
text: `<workflow id="test-workflow" path=".pochi/workflows/test.md">
54+
This workflow has no bash commands.
55+
</workflow>`,
56+
},
57+
],
58+
};
59+
60+
const commands = extractWorkflowBashCommands(message);
61+
62+
expect(commands).toEqual([]);
63+
});
64+
65+
it("should handle multiple workflow parts", () => {
66+
const message: UIMessage = {
67+
id: "1",
68+
role: "user",
69+
parts: [
70+
{
71+
type: "text",
72+
text: `<workflow>First command: !\`command1\`</workflow>`,
73+
},
74+
{
75+
type: "text",
76+
text: "Some text in between",
77+
},
78+
{
79+
type: "text",
80+
text: `<workflow>Second command: !\`command2\`</workflow>`,
81+
},
82+
],
83+
};
84+
85+
const commands = extractWorkflowBashCommands(message);
86+
expect(commands).toEqual(["command1", "command2"]);
87+
});
88+
});

packages/common/src/message-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export {
88
isAssistantMessageWithOutputError,
99
} from "./assistant-message";
1010
export { mergeTodos, findTodos } from "./todo";
11+
export { extractWorkflowBashCommands } from "./workflow";
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { UIMessage } from "ai";
2+
3+
/**
4+
* Extracts bash commands from a markdown string and returns the list of commands.
5+
*
6+
* @param content The markdown content to parse.
7+
* @returns An array of bash commands.
8+
*/
9+
function extractBashCommands(content: string): string[] {
10+
const commands: string[] = [];
11+
const commandRegex = /!\`(.+?)\`/g;
12+
13+
let match: RegExpExecArray | null;
14+
// biome-ignore lint/suspicious/noAssignInExpressions: off
15+
while ((match = commandRegex.exec(content)) !== null) {
16+
const actualCommand = match[1].trim();
17+
if (actualCommand) {
18+
commands.push(actualCommand);
19+
}
20+
}
21+
22+
return commands;
23+
}
24+
25+
const tag = "workflow";
26+
const workflowRegex = new RegExp(`<${tag}([^>]*)>(.*?)<\/${tag}>`, "gs");
27+
28+
export function extractWorkflowBashCommands(message: UIMessage): string[] {
29+
const workflowContents: string[] = [];
30+
31+
for (const part of message.parts) {
32+
if (part.type === "text") {
33+
const matches = part.text.matchAll(workflowRegex);
34+
for (const match of matches) {
35+
const content = match[2];
36+
workflowContents.push(content);
37+
}
38+
}
39+
}
40+
let commands: string[] = [];
41+
for (const x of workflowContents) {
42+
commands = commands.concat(extractBashCommands(x));
43+
}
44+
return commands;
45+
}

packages/common/src/vscode-webui-bridge/webview-stub.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ const VSCodeHostStub = {
6868
): Promise<unknown> => {
6969
return Promise.resolve(undefined);
7070
},
71+
executeBashCommand: (
72+
_command: string,
73+
_abortSignal: ThreadAbortSignalSerialization,
74+
): Promise<{ output: string; error?: string }> => {
75+
return Promise.resolve({} as { output: string; error?: string });
76+
},
7177
listFilesInWorkspace: (): Promise<{ filepath: string; isDir: boolean }[]> => {
7278
return Promise.resolve([{ filepath: "test", isDir: false }]);
7379
},

packages/common/src/vscode-webui-bridge/webview.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ export interface VSCodeHostApi {
156156

157157
readCustomAgents(): Promise<ThreadSignalSerialization<CustomAgentFile[]>>;
158158

159+
executeBashCommand: (
160+
command: string,
161+
abortSignal: ThreadAbortSignalSerialization,
162+
) => Promise<{ output: string; error?: string }>;
163+
159164
readMinionId(): Promise<string | null>;
160165

161166
/**

packages/livekit/src/chat/live-chat-kit.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type LiveChatKitOptions<T> = {
3434

3535
onOverrideMessages?: (options: {
3636
messages: Message[];
37+
abortSignal: AbortSignal;
3738
}) => void | Promise<void>;
3839

3940
customAgent?: CustomAgent;
@@ -128,7 +129,7 @@ export class LiveChatKit<
128129
}
129130
}
130131
if (onOverrideMessages) {
131-
await onOverrideMessages({ messages });
132+
await onOverrideMessages({ messages, abortSignal });
132133
}
133134
};
134135

0 commit comments

Comments
 (0)