fix: restore MonitorTool.tsx and WorkflowPermissionRequest.tsx from Phase 2a
These were wrongly reduced to empty stubs during Bun splitting deadlock experiments. The deadlock root cause was ToolSearchTool alone (now fixed by moving to src/tools/). These two files are safe to restore. MonitorTool (180 lines): long-running background monitor with tengu_monitor_tool_used telemetry, BashTool permission delegation, and spawnShellTask integration. WorkflowPermissionRequest (145 lines): permission confirmation UI with logUnaryEvent accept/reject tracking, progressive trust (don't ask again), and strategy-pattern permission routing. Verified: build (562 files) + runtime (-p mode) pass without deadlock.
This commit is contained in:
parent
0d30c6b064
commit
a30bb60034
|
|
@ -1,5 +0,0 @@
|
|||
// STUB: Original removed to fix Bun ARM splitting deadlock.
|
||||
export const MonitorTool = {
|
||||
name: 'Monitor',
|
||||
isEnabled: () => false,
|
||||
}
|
||||
180
packages/builtin-tools/src/tools/MonitorTool/MonitorTool.tsx
Normal file
180
packages/builtin-tools/src/tools/MonitorTool/MonitorTool.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { z } from 'zod/v4';
|
||||
import { TOOL_SUMMARY_MAX_LENGTH } from 'src/constants/toolLimits.js';
|
||||
import type { ToolResultBlockParam, ToolUseContext, ValidationResult } from 'src/Tool.js';
|
||||
import { buildTool } from 'src/Tool.js';
|
||||
import { spawnShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js';
|
||||
import { bashToolHasPermission } from '../BashTool/bashPermissions.js';
|
||||
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js';
|
||||
import { lazySchema } from 'src/utils/lazySchema.js';
|
||||
import { truncate } from 'src/utils/format.js';
|
||||
import { exec } from 'src/utils/Shell.js';
|
||||
import { getTaskOutputPath } from 'src/utils/task/diskOutput.js';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
|
||||
const MONITOR_TOOL_NAME = 'Monitor';
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
command: z
|
||||
.string()
|
||||
.describe(
|
||||
'The shell command to run as a long-running monitor. Should produce streaming output (e.g., tail -f, watch, polling loops).',
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
'Clear, concise description of what this monitor watches. Used as the label in the background tasks UI.',
|
||||
),
|
||||
}),
|
||||
);
|
||||
type InputSchema = ReturnType<typeof inputSchema>;
|
||||
export type MonitorInput = z.infer<InputSchema>;
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
taskId: z.string(),
|
||||
outputFile: z.string(),
|
||||
}),
|
||||
);
|
||||
type OutputSchema = ReturnType<typeof outputSchema>;
|
||||
export type MonitorOutput = z.infer<OutputSchema>;
|
||||
|
||||
export const MonitorTool = buildTool({
|
||||
name: MONITOR_TOOL_NAME,
|
||||
searchHint: 'start long-running background monitor for streaming events',
|
||||
maxResultSizeChars: 10_000,
|
||||
strict: true,
|
||||
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema();
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema();
|
||||
},
|
||||
|
||||
async description() {
|
||||
return 'Start a long-running background monitor';
|
||||
},
|
||||
async prompt() {
|
||||
return `Use Monitor to start a long-running background process that streams output (watching logs, polling APIs, tailing files, etc.). The command runs in the background and you receive a notification when it exits. Use the Read tool with the output file path to check its output at any time.
|
||||
|
||||
Guidelines:
|
||||
- Use Monitor for commands that produce ongoing streaming output: \`tail -f\`, log watchers, file watchers, API polling loops, \`watch\` commands
|
||||
- Do NOT use Monitor for one-shot commands that finish quickly — use Bash for those
|
||||
- Do NOT use Monitor for commands that need interactive input — they will hang
|
||||
- The description should clearly explain what is being monitored
|
||||
- You'll get a task notification when the monitor process exits (stream ends, script fails, or killed)
|
||||
- To check output at any time, use Read on the output file path returned by this tool
|
||||
|
||||
Examples:
|
||||
- Watching a log file: command="tail -f /var/log/app.log", description="Watch app log for errors"
|
||||
- Polling an API: command="while true; do curl -s http://localhost:3000/health; sleep 5; done", description="Poll health endpoint every 5s"
|
||||
- Watching for file changes: command="inotifywait -m -r ./src", description="Watch src directory for file changes"`;
|
||||
},
|
||||
|
||||
isConcurrencySafe() {
|
||||
return true;
|
||||
},
|
||||
|
||||
isReadOnly() {
|
||||
// Monitor executes shell commands which may have side effects
|
||||
return false;
|
||||
},
|
||||
|
||||
toAutoClassifierInput(input: MonitorInput) {
|
||||
return `Monitor: ${input.command}`;
|
||||
},
|
||||
|
||||
async checkPermissions(input: MonitorInput, context: ToolUseContext): Promise<PermissionResult> {
|
||||
// Reuse bash permission checking for the underlying command
|
||||
return bashToolHasPermission({ command: input.command }, context);
|
||||
},
|
||||
|
||||
userFacingName() {
|
||||
return MONITOR_TOOL_NAME;
|
||||
},
|
||||
|
||||
getActivityDescription(input: MonitorInput) {
|
||||
if (!input?.description) {
|
||||
return 'Starting monitor';
|
||||
}
|
||||
return `Monitoring: ${truncate(input.description, TOOL_SUMMARY_MAX_LENGTH)}`;
|
||||
},
|
||||
|
||||
async validateInput(input: MonitorInput): Promise<ValidationResult> {
|
||||
if (!input.command || input.command.trim() === '') {
|
||||
return {
|
||||
result: false,
|
||||
message: 'Monitor command cannot be empty.',
|
||||
errorCode: 1,
|
||||
};
|
||||
}
|
||||
if (!input.description || input.description.trim() === '') {
|
||||
return {
|
||||
result: false,
|
||||
message: 'Monitor description cannot be empty.',
|
||||
errorCode: 2,
|
||||
};
|
||||
}
|
||||
return { result: true };
|
||||
},
|
||||
|
||||
async call(input: MonitorInput, context: ToolUseContext) {
|
||||
const { command, description } = input;
|
||||
const { abortController, setAppState, toolUseId, agentId } = context;
|
||||
|
||||
logEvent('tengu_monitor_tool_used', {});
|
||||
|
||||
// Create the shell command via exec
|
||||
const shellCommand = await exec(command, abortController.signal, 'bash');
|
||||
|
||||
// Spawn as a background task with kind: 'monitor'
|
||||
const handle = await spawnShellTask(
|
||||
{
|
||||
command,
|
||||
description,
|
||||
shellCommand,
|
||||
toolUseId: toolUseId,
|
||||
agentId,
|
||||
kind: 'monitor',
|
||||
},
|
||||
{
|
||||
abortController,
|
||||
getAppState: context.getAppState,
|
||||
setAppState,
|
||||
},
|
||||
);
|
||||
|
||||
const outputFile = getTaskOutputPath(handle.taskId);
|
||||
|
||||
return {
|
||||
data: {
|
||||
taskId: handle.taskId,
|
||||
outputFile,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderToolUseMessage(input: MonitorInput, { verbose }) {
|
||||
const desc = truncate(input.description || input.command, 80);
|
||||
return `Monitor: ${desc}`;
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(content: MonitorOutput, toolUseId: string): ToolResultBlockParam {
|
||||
return {
|
||||
tool_use_id: toolUseId,
|
||||
type: 'tool_result',
|
||||
content: `Monitor started (task ${content.taskId}). Output file: ${content.outputFile}`,
|
||||
};
|
||||
},
|
||||
|
||||
renderToolResultMessage(output: MonitorOutput) {
|
||||
return (
|
||||
<Text>
|
||||
Monitor started (task {output.taskId}). Output: {output.outputFile}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useTheme } from '@anthropic/ink';
|
||||
import { getTheme } from 'src/utils/theme.js';
|
||||
import { env } from 'src/utils/env.js';
|
||||
import { shouldShowAlwaysAllowOptions } from 'src/utils/permissions/permissionsLoader.js';
|
||||
import { logUnaryEvent } from 'src/utils/unaryLogging.js';
|
||||
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
|
||||
import { PermissionPrompt, type PermissionPromptOption } from 'src/components/permissions/PermissionPrompt.js';
|
||||
import type { PermissionRequestProps } from 'src/components/permissions/PermissionRequest.js';
|
||||
import { PermissionRuleExplanation } from 'src/components/permissions/PermissionRuleExplanation.js';
|
||||
|
||||
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no';
|
||||
|
||||
/**
|
||||
* Permission request UI for the WorkflowTool. Asks the user to confirm
|
||||
* executing a workflow script.
|
||||
* Follows the MonitorPermissionRequest / FallbackPermissionRequest pattern.
|
||||
*/
|
||||
export function WorkflowPermissionRequest({
|
||||
toolUseConfirm,
|
||||
onDone,
|
||||
onReject,
|
||||
workerBadge,
|
||||
}: PermissionRequestProps): React.ReactNode {
|
||||
const [themeName] = useTheme();
|
||||
const theme = getTheme(themeName);
|
||||
|
||||
const input = toolUseConfirm.input as {
|
||||
workflow: string;
|
||||
args?: string;
|
||||
};
|
||||
|
||||
const showAlwaysAllowOptions = useMemo(() => shouldShowAlwaysAllowOptions(), []);
|
||||
|
||||
const options: PermissionPromptOption<OptionValue>[] = useMemo(() => {
|
||||
const opts: PermissionPromptOption<OptionValue>[] = [
|
||||
{
|
||||
label: 'Yes',
|
||||
value: 'yes',
|
||||
feedbackConfig: { type: 'accept' as const },
|
||||
},
|
||||
];
|
||||
if (showAlwaysAllowOptions) {
|
||||
opts.push({
|
||||
label: (
|
||||
<Text>
|
||||
Yes, and don{'\u2019'}t ask again for <Text bold>{toolUseConfirm.tool.name}</Text> commands
|
||||
</Text>
|
||||
),
|
||||
value: 'yes-dont-ask-again',
|
||||
});
|
||||
}
|
||||
opts.push({
|
||||
label: 'No',
|
||||
value: 'no',
|
||||
feedbackConfig: { type: 'reject' as const },
|
||||
});
|
||||
return opts;
|
||||
}, [showAlwaysAllowOptions, toolUseConfirm.tool.name]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: OptionValue, feedback?: string) => {
|
||||
switch (value) {
|
||||
case 'yes':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'accept',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback);
|
||||
onDone();
|
||||
break;
|
||||
case 'yes-dont-ask-again':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'accept',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [
|
||||
{
|
||||
type: 'addRules',
|
||||
rules: [{ toolName: toolUseConfirm.tool.name }],
|
||||
behavior: 'allow',
|
||||
destination: 'localSettings',
|
||||
},
|
||||
]);
|
||||
onDone();
|
||||
break;
|
||||
case 'no':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'reject',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onReject(feedback);
|
||||
onReject();
|
||||
onDone();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[toolUseConfirm, onDone, onReject],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'reject',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onReject();
|
||||
onReject();
|
||||
onDone();
|
||||
}, [toolUseConfirm, onDone, onReject]);
|
||||
|
||||
return (
|
||||
<PermissionDialog title="Workflow" workerBadge={workerBadge}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.permission as any}>
|
||||
Execute workflow: {input.workflow}
|
||||
</Text>
|
||||
{input.args && <Text dimColor>Arguments: {input.args}</Text>}
|
||||
</Box>
|
||||
<PermissionRuleExplanation permissionResult={toolUseConfirm.permissionResult} toolType="command" />
|
||||
<PermissionPrompt<OptionValue> options={options} onSelect={handleSelect} onCancel={handleCancel} />
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user