mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 07:37:27 +00:00
tools/process: add attach action and input-wait metadata
This commit is contained in:
@@ -512,7 +512,7 @@ export function createExecTool(
|
|||||||
type: "text",
|
type: "text",
|
||||||
text: `${getWarningText()}Command still running (session ${run.session.id}, pid ${
|
text: `${getWarningText()}Command still running (session ${run.session.id}, pid ${
|
||||||
run.session.pid ?? "n/a"
|
run.session.pid ?? "n/a"
|
||||||
}). Use process (list/poll/log/write/kill/clear/remove) for follow-up.`,
|
}). Use process (attach/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
|
|||||||
@@ -15,13 +15,21 @@ import {
|
|||||||
markExited,
|
markExited,
|
||||||
setJobTtlMs,
|
setJobTtlMs,
|
||||||
} from "./bash-process-registry.js";
|
} from "./bash-process-registry.js";
|
||||||
import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js";
|
import {
|
||||||
|
clampWithDefault,
|
||||||
|
deriveSessionName,
|
||||||
|
pad,
|
||||||
|
readEnvInt,
|
||||||
|
sliceLogLines,
|
||||||
|
truncateMiddle,
|
||||||
|
} from "./bash-tools.shared.js";
|
||||||
import { recordCommandPoll, resetCommandPollCount } from "./command-poll-backoff.js";
|
import { recordCommandPoll, resetCommandPollCount } from "./command-poll-backoff.js";
|
||||||
import { encodeKeySequence, encodePaste } from "./pty-keys.js";
|
import { encodeKeySequence, encodePaste } from "./pty-keys.js";
|
||||||
|
|
||||||
export type ProcessToolDefaults = {
|
export type ProcessToolDefaults = {
|
||||||
cleanupMs?: number;
|
cleanupMs?: number;
|
||||||
scopeKey?: string;
|
scopeKey?: string;
|
||||||
|
inputWaitIdleMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WritableStdin = {
|
type WritableStdin = {
|
||||||
@@ -50,7 +58,10 @@ function defaultTailNote(totalLines: number, usingDefaultTail: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const processSchema = Type.Object({
|
const processSchema = Type.Object({
|
||||||
action: Type.String({ description: "Process action" }),
|
action: Type.String({
|
||||||
|
description:
|
||||||
|
"Process action (list|attach|poll|log|write|send-keys|submit|paste|kill|clear|remove)",
|
||||||
|
}),
|
||||||
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
|
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
|
||||||
data: Type.Optional(Type.String({ description: "Data to write for write" })),
|
data: Type.Optional(Type.String({ description: "Data to write for write" })),
|
||||||
keys: Type.Optional(
|
keys: Type.Optional(
|
||||||
@@ -72,6 +83,9 @@ const processSchema = Type.Object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const MAX_POLL_WAIT_MS = 120_000;
|
const MAX_POLL_WAIT_MS = 120_000;
|
||||||
|
const DEFAULT_INPUT_WAIT_IDLE_MS = 15_000;
|
||||||
|
const MIN_INPUT_WAIT_IDLE_MS = 1_000;
|
||||||
|
const MAX_INPUT_WAIT_IDLE_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
function resolvePollWaitMs(value: unknown) {
|
function resolvePollWaitMs(value: unknown) {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
@@ -124,9 +138,39 @@ export function createProcessTool(
|
|||||||
setJobTtlMs(defaults.cleanupMs);
|
setJobTtlMs(defaults.cleanupMs);
|
||||||
}
|
}
|
||||||
const scopeKey = defaults?.scopeKey;
|
const scopeKey = defaults?.scopeKey;
|
||||||
|
const inputWaitIdleMs = clampWithDefault(
|
||||||
|
defaults?.inputWaitIdleMs ?? readEnvInt("OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS"),
|
||||||
|
DEFAULT_INPUT_WAIT_IDLE_MS,
|
||||||
|
MIN_INPUT_WAIT_IDLE_MS,
|
||||||
|
MAX_INPUT_WAIT_IDLE_MS,
|
||||||
|
);
|
||||||
const supervisor = getProcessSupervisor();
|
const supervisor = getProcessSupervisor();
|
||||||
const isInScope = (session?: { scopeKey?: string } | null) =>
|
const isInScope = (session?: { scopeKey?: string } | null) =>
|
||||||
!scopeKey || session?.scopeKey === scopeKey;
|
!scopeKey || session?.scopeKey === scopeKey;
|
||||||
|
const resolveStdinWritable = (session: ProcessSession) => {
|
||||||
|
const stdin = session.stdin ?? session.child?.stdin;
|
||||||
|
return Boolean(stdin && !stdin.destroyed);
|
||||||
|
};
|
||||||
|
const describeRunningSession = (session: ProcessSession) => {
|
||||||
|
const record = supervisor.getRecord(session.id);
|
||||||
|
const lastOutputAt = record?.lastOutputAtMs ?? session.startedAt;
|
||||||
|
const idleMs = Math.max(0, Date.now() - lastOutputAt);
|
||||||
|
const stdinWritable = resolveStdinWritable(session);
|
||||||
|
const waitingForInput = stdinWritable && idleMs >= inputWaitIdleMs;
|
||||||
|
return {
|
||||||
|
stdinWritable,
|
||||||
|
waitingForInput,
|
||||||
|
lastOutputAt,
|
||||||
|
idleMs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const buildInputWaitHint = (hints: { waitingForInput: boolean; idleMs: number }) => {
|
||||||
|
if (!hints.waitingForInput) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const idleLabel = formatDurationCompact(hints.idleMs) ?? `${Math.round(hints.idleMs / 1000)}s`;
|
||||||
|
return `\n\nNo new output for ${idleLabel}; this session may be waiting for input. Use process attach, then process write/send-keys/submit to continue.`;
|
||||||
|
};
|
||||||
|
|
||||||
const cancelManagedSession = (sessionId: string) => {
|
const cancelManagedSession = (sessionId: string) => {
|
||||||
const record = supervisor.getRecord(sessionId);
|
const record = supervisor.getRecord(sessionId);
|
||||||
@@ -150,12 +194,13 @@ export function createProcessTool(
|
|||||||
name: "process",
|
name: "process",
|
||||||
label: "process",
|
label: "process",
|
||||||
description:
|
description:
|
||||||
"Manage running exec sessions: list, poll, log, write, send-keys, submit, paste, kill.",
|
"Manage running exec sessions: list, attach, poll, log, write, send-keys, submit, paste, kill.",
|
||||||
parameters: processSchema,
|
parameters: processSchema,
|
||||||
execute: async (_toolCallId, args, _signal, _onUpdate): Promise<AgentToolResult<unknown>> => {
|
execute: async (_toolCallId, args, _signal, _onUpdate): Promise<AgentToolResult<unknown>> => {
|
||||||
const params = args as {
|
const params = args as {
|
||||||
action:
|
action:
|
||||||
| "list"
|
| "list"
|
||||||
|
| "attach"
|
||||||
| "poll"
|
| "poll"
|
||||||
| "log"
|
| "log"
|
||||||
| "write"
|
| "write"
|
||||||
@@ -181,18 +226,25 @@ export function createProcessTool(
|
|||||||
if (params.action === "list") {
|
if (params.action === "list") {
|
||||||
const running = listRunningSessions()
|
const running = listRunningSessions()
|
||||||
.filter((s) => isInScope(s))
|
.filter((s) => isInScope(s))
|
||||||
.map((s) => ({
|
.map((s) => {
|
||||||
sessionId: s.id,
|
const runtime = describeRunningSession(s);
|
||||||
status: "running",
|
return {
|
||||||
pid: s.pid ?? undefined,
|
sessionId: s.id,
|
||||||
startedAt: s.startedAt,
|
status: "running",
|
||||||
runtimeMs: Date.now() - s.startedAt,
|
pid: s.pid ?? undefined,
|
||||||
cwd: s.cwd,
|
startedAt: s.startedAt,
|
||||||
command: s.command,
|
runtimeMs: Date.now() - s.startedAt,
|
||||||
name: deriveSessionName(s.command),
|
cwd: s.cwd,
|
||||||
tail: s.tail,
|
command: s.command,
|
||||||
truncated: s.truncated,
|
name: deriveSessionName(s.command),
|
||||||
}));
|
tail: s.tail,
|
||||||
|
truncated: s.truncated,
|
||||||
|
stdinWritable: runtime.stdinWritable,
|
||||||
|
waitingForInput: runtime.waitingForInput,
|
||||||
|
lastOutputAt: runtime.lastOutputAt,
|
||||||
|
idleMs: runtime.idleMs,
|
||||||
|
};
|
||||||
|
});
|
||||||
const finished = listFinishedSessions()
|
const finished = listFinishedSessions()
|
||||||
.filter((s) => isInScope(s))
|
.filter((s) => isInScope(s))
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
@@ -213,7 +265,11 @@ export function createProcessTool(
|
|||||||
.toSorted((a, b) => b.startedAt - a.startedAt)
|
.toSorted((a, b) => b.startedAt - a.startedAt)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
||||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`;
|
const inputWaitTag =
|
||||||
|
s.status === "running" && "waitingForInput" in s && s.waitingForInput
|
||||||
|
? " [input-wait]"
|
||||||
|
: "";
|
||||||
|
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}${inputWaitTag}`;
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -291,6 +347,81 @@ export function createProcessTool(
|
|||||||
});
|
});
|
||||||
|
|
||||||
switch (params.action) {
|
switch (params.action) {
|
||||||
|
case "attach": {
|
||||||
|
if (scopedSession) {
|
||||||
|
if (!scopedSession.backgrounded) {
|
||||||
|
return failText(`Session ${params.sessionId} is not backgrounded.`);
|
||||||
|
}
|
||||||
|
const runtime = describeRunningSession(scopedSession);
|
||||||
|
const window = resolveLogSliceWindow(params.offset, params.limit);
|
||||||
|
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||||
|
scopedSession.aggregated,
|
||||||
|
window.effectiveOffset,
|
||||||
|
window.effectiveLimit,
|
||||||
|
);
|
||||||
|
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
|
||||||
|
const waitingHint = buildInputWaitHint(runtime);
|
||||||
|
const controlHint = runtime.stdinWritable
|
||||||
|
? "\n\nInteractive controls: process write, process send-keys, process submit."
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
(slice || "(no output yet)") + logDefaultTailNote + waitingHint + controlHint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
status: "running",
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
total: totalLines,
|
||||||
|
totalLines,
|
||||||
|
totalChars,
|
||||||
|
truncated: scopedSession.truncated,
|
||||||
|
name: deriveSessionName(scopedSession.command),
|
||||||
|
stdinWritable: runtime.stdinWritable,
|
||||||
|
waitingForInput: runtime.waitingForInput,
|
||||||
|
idleMs: runtime.idleMs,
|
||||||
|
lastOutputAt: runtime.lastOutputAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (scopedFinished) {
|
||||||
|
const window = resolveLogSliceWindow(params.offset, params.limit);
|
||||||
|
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||||
|
scopedFinished.aggregated,
|
||||||
|
window.effectiveOffset,
|
||||||
|
window.effectiveLimit,
|
||||||
|
);
|
||||||
|
const status = scopedFinished.status === "completed" ? "completed" : "failed";
|
||||||
|
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
(slice || "(no output recorded)") +
|
||||||
|
logDefaultTailNote +
|
||||||
|
"\n\nSession already exited.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
status,
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
total: totalLines,
|
||||||
|
totalLines,
|
||||||
|
totalChars,
|
||||||
|
truncated: scopedFinished.truncated,
|
||||||
|
exitCode: scopedFinished.exitCode ?? undefined,
|
||||||
|
exitSignal: scopedFinished.exitSignal ?? undefined,
|
||||||
|
name: deriveSessionName(scopedFinished.command),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return failText(`No session found for ${params.sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
case "poll": {
|
case "poll": {
|
||||||
if (!scopedSession) {
|
if (!scopedSession) {
|
||||||
if (scopedFinished) {
|
if (scopedFinished) {
|
||||||
@@ -353,6 +484,7 @@ export function createProcessTool(
|
|||||||
? "completed"
|
? "completed"
|
||||||
: "failed"
|
: "failed"
|
||||||
: "running";
|
: "running";
|
||||||
|
const runtime = describeRunningSession(scopedSession);
|
||||||
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n").trim();
|
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n").trim();
|
||||||
const hasNewOutput = output.length > 0;
|
const hasNewOutput = output.length > 0;
|
||||||
const retryInMs = exited
|
const retryInMs = exited
|
||||||
@@ -371,7 +503,7 @@ export function createProcessTool(
|
|||||||
? `\n\nProcess exited with ${
|
? `\n\nProcess exited with ${
|
||||||
exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`
|
exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`
|
||||||
}.`
|
}.`
|
||||||
: "\n\nProcess still running."),
|
: buildInputWaitHint(runtime) || "\n\nProcess still running."),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
@@ -380,6 +512,10 @@ export function createProcessTool(
|
|||||||
exitCode: exited ? exitCode : undefined,
|
exitCode: exited ? exitCode : undefined,
|
||||||
aggregated: scopedSession.aggregated,
|
aggregated: scopedSession.aggregated,
|
||||||
name: deriveSessionName(scopedSession.command),
|
name: deriveSessionName(scopedSession.command),
|
||||||
|
stdinWritable: runtime.stdinWritable,
|
||||||
|
waitingForInput: runtime.waitingForInput,
|
||||||
|
idleMs: runtime.idleMs,
|
||||||
|
lastOutputAt: runtime.lastOutputAt,
|
||||||
...(typeof retryInMs === "number" ? { retryInMs } : {}),
|
...(typeof retryInMs === "number" ? { retryInMs } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -405,6 +541,7 @@ export function createProcessTool(
|
|||||||
window.effectiveLimit,
|
window.effectiveLimit,
|
||||||
);
|
);
|
||||||
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
|
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
|
||||||
|
const runtime = describeRunningSession(scopedSession);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: (slice || "(no output yet)") + logDefaultTailNote }],
|
content: [{ type: "text", text: (slice || "(no output yet)") + logDefaultTailNote }],
|
||||||
details: {
|
details: {
|
||||||
@@ -415,6 +552,10 @@ export function createProcessTool(
|
|||||||
totalChars,
|
totalChars,
|
||||||
truncated: scopedSession.truncated,
|
truncated: scopedSession.truncated,
|
||||||
name: deriveSessionName(scopedSession.command),
|
name: deriveSessionName(scopedSession.command),
|
||||||
|
stdinWritable: runtime.stdinWritable,
|
||||||
|
waitingForInput: runtime.waitingForInput,
|
||||||
|
idleMs: runtime.idleMs,
|
||||||
|
lastOutputAt: runtime.lastOutputAt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user