feat(agent): complete instance-chat robustness fixes (Fix 2-6)

Fix 2 — Callback timeout wiring:
- Store callbackTimer in pendingCallbackTimers Map after creation
- handleOpenClawAppCallback clears the timer immediately on arrival,
  preventing spurious "timeout" errors when the bridge replies in time

Fix 3 — Provider scope isolation:
- Override agentStatusProvider and robotStateProvider in child ProviderScope
  so the robot avatar/FAB reflects the instance chat state, not iAgent's

Fix 4 — Voice routing to OpenClaw:
- AgentInstanceChatDatasource.sendVoiceMessage() now calls transcribeAudio()
  then routes the transcript through instance-specific createTask() endpoint,
  ensuring voice messages reach the user's OpenClaw agent, not iAgent

Fix 5 — Attachment UI in instance mode:
- Attachment button shown as disabled (onPressed: null) with explanatory
  tooltip ("附件功能暂不支持智能体对话") when agentName != null
- Prevents misleading UX where attachments appear to work but are silently
  dropped by the OpenClaw bridge

Fix 6 — DB schema template:
- Add agent_instance_id UUID NULL to agent_sessions table in migration 002
  (tenant schema template) so new tenants get the column from creation
- Add covering index idx_agent_sessions_instance for efficient instance queries

All TypeScript and Flutter analyze checks pass clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-09 19:49:36 -07:00
parent 8865985019
commit ea3cbf64a5
5 changed files with 118 additions and 25 deletions

View File

@ -100,15 +100,19 @@ class AgentInstanceChatDatasource implements ChatRemoteDatasource {
Future<String> transcribeAudio({required String audioPath}) =>
_delegate.transcribeAudio(audioPath: audioPath);
/// For instance chat, voice is handled by transcribing locally then routing
/// the text prompt through the instance-specific [createTask] endpoint.
/// This ensures voice messages reach the user's OpenClaw instance, not iAgent.
@override
Future<Map<String, dynamic>> sendVoiceMessage({
required String sessionId,
required String audioPath,
String language = 'zh',
}) =>
_delegate.sendVoiceMessage(
sessionId: sessionId,
audioPath: audioPath,
language: language,
);
}) async {
final transcript = await _delegate.transcribeAudio(audioPath: audioPath);
return createTask(
sessionId: sessionId,
message: transcript,
);
}
}

View File

@ -5,6 +5,8 @@ import '../../../chat/presentation/providers/chat_providers.dart';
import '../../../my_agents/presentation/pages/my_agents_page.dart';
import '../../data/datasources/agent_instance_chat_remote_datasource.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/widgets/floating_robot_fab.dart';
import '../../../../core/widgets/robot_painter.dart';
/// Full-screen chat page for a user conversing with their own OpenClaw instance.
///
@ -36,6 +38,21 @@ class AgentInstanceChatPage extends StatelessWidget {
final ds = ref.watch(chatRemoteDatasourceProvider);
return ds.listSessions();
}),
// Derived providers must also be overridden so they read the child-scope
// chatProvider instead of the parent's iAgent chatProvider.
agentStatusProvider.overrideWith(
(ref) => ref.watch(chatProvider).agentStatus,
),
robotStateProvider.overrideWith((ref) {
return switch (ref.watch(chatProvider).agentStatus) {
AgentStatus.idle => RobotState.idle,
AgentStatus.thinking => RobotState.thinking,
AgentStatus.executing => RobotState.executing,
AgentStatus.awaitingApproval => RobotState.alert,
AgentStatus.error => RobotState.alert,
};
}),
],
child: ChatPage(agentName: instance.name),
);

View File

@ -720,10 +720,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
if (!isStreaming)
Padding(
padding: const EdgeInsets.only(left: 4),
child: IconButton(
icon: const Icon(Icons.add_circle_outline, size: 22),
tooltip: AppLocalizations.of(context).chatAddImageTooltip,
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
child: Tooltip(
message: widget.agentName != null
? '附件功能暂不支持智能体对话'
: AppLocalizations.of(context).chatAddImageTooltip,
child: IconButton(
icon: const Icon(Icons.add_circle_outline, size: 22),
onPressed: (isAwaitingApproval || widget.agentName != null)
? null
: _showAttachmentOptions,
),
),
),
Expanded(

View File

@ -26,6 +26,11 @@ export class AgentController {
private readonly logger = new Logger(AgentController.name);
/** Tracks running task promises so cancel/inject can await cleanup. */
private readonly runningTasks = new Map<string, Promise<void>>();
/**
* Tracks pending OpenClaw callback timers keyed by taskId.
* Cleared when the bridge callback arrives, preventing spurious timeout errors.
*/
private readonly pendingCallbackTimers = new Map<string, NodeJS.Timeout>();
constructor(
private readonly engineRegistry: EngineRegistry,
@ -830,7 +835,9 @@ export class AgentController {
// Validate that this instance belongs to the requesting user
const jwtPayload = this.decodeJwt(req.headers?.['authorization'] as string | undefined);
const userId: string | undefined = jwtPayload?.sub;
if (userId && instance.userId !== userId) {
// Strict ownership check: reject if userId cannot be resolved (JWT decode failure)
// or if the instance belongs to a different user.
if (!userId || instance.userId !== userId) {
throw new ForbiddenException('Instance does not belong to you');
}
@ -892,10 +899,47 @@ export class AgentController {
this.gateway.emitStreamEvent(session.id, { type: 'session_info', sessionId: session.id });
this.gateway.emitStreamEvent(session.id, { type: 'task_info', taskId: task.id });
// Fire-and-forget POST to OpenClaw bridge
// Fire-and-forget POST to OpenClaw bridge.
//
// Reliability guarantees:
// 1. AbortController cuts the connection after BRIDGE_REQUEST_TIMEOUT_MS if the
// bridge never responds (network hang / bridge frozen).
// 2. HTTP-level errors (4xx / 5xx from bridge) are detected via `response.ok`
// and treated the same as network failures.
// 3. A separate callback-level timeout (CALLBACK_TIMEOUT_MS) fires if the bridge
// accepted the request but never POSTed the callback (e.g. bridge crashed
// mid-execution). After this deadline the task is marked FAILED and an error
// event is emitted so Flutter can show the user an error instead of spinning.
const BRIDGE_REQUEST_TIMEOUT_MS = 15_000; // 15 s to accept the request
const CALLBACK_TIMEOUT_MS = 150_000; // 2.5 min for the LLM to reply
const abortController = new AbortController();
const requestTimer = setTimeout(() => abortController.abort(), BRIDGE_REQUEST_TIMEOUT_MS);
// Callback-level timeout: if the bridge never calls back, fail the task ourselves.
const callbackTimer = setTimeout(async () => {
this.logger.warn(`[Task ${task.id}] OpenClaw callback timeout after ${CALLBACK_TIMEOUT_MS}ms`);
this.gateway.emitStreamEvent(session.id, {
type: 'error',
message: '智能体响应超时,请稍后重试',
});
const staleTask = await this.taskRepository.findById(task.id);
if (staleTask && staleTask.status === TaskStatus.RUNNING) {
staleTask.status = TaskStatus.FAILED;
staleTask.result = 'Callback timeout';
staleTask.completedAt = new Date();
await this.taskRepository.save(staleTask).catch(() => {});
}
}, CALLBACK_TIMEOUT_MS);
// Unref so this timer doesn't keep the process alive if the server shuts down.
if ((callbackTimer as any).unref) (callbackTimer as any).unref();
// Track timer so the callback handler can cancel it when the bridge replies in time.
this.pendingCallbackTimers.set(task.id, callbackTimer);
fetch(bridgeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
prompt: body.prompt,
sessionKey,
@ -903,12 +947,25 @@ export class AgentController {
callbackUrl,
callbackData: { sessionId: session.id, taskId: task.id },
}),
}).catch((err: Error) => {
})
.then(async (res) => {
clearTimeout(requestTimer);
if (!res.ok) {
// HTTP 4xx / 5xx from the bridge — treat as failure
const body = await res.text().catch(() => '');
throw new Error(`Bridge returned HTTP ${res.status}: ${body.slice(0, 200)}`);
}
this.logger.log(`[Task ${task.id}] Bridge accepted request (HTTP ${res.status}), awaiting callback`);
})
.catch((err: Error) => {
clearTimeout(requestTimer);
clearTimeout(callbackTimer);
const isAbort = err.name === 'AbortError';
const msg = isAbort
? `连接智能体超时(${BRIDGE_REQUEST_TIMEOUT_MS / 1000}s请确认智能体正在运行`
: `无法连接到智能体:${err.message}`;
this.logger.error(`[Task ${task.id}] Bridge request failed: ${err.message}`);
this.gateway.emitStreamEvent(session.id, {
type: 'error',
message: `无法连接到智能体:${err.message}`,
});
this.gateway.emitStreamEvent(session.id, { type: 'error', message: msg });
task.status = TaskStatus.FAILED;
task.result = err.message;
task.completedAt = new Date();
@ -968,6 +1025,13 @@ export class AgentController {
return { received: true };
}
// Cancel the local callback-timeout timer — the bridge replied in time.
const pendingTimer = this.pendingCallbackTimers.get(taskId);
if (pendingTimer) {
clearTimeout(pendingTimer);
this.pendingCallbackTimers.delete(taskId);
}
const task = await this.taskRepository.findById(taskId);
if (ok && result) {

View File

@ -7,15 +7,17 @@ SET LOCAL search_path TO it0_t_{TENANT_ID};
-- Agent Sessions
CREATE TABLE agent_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL,
engine_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
system_prompt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB DEFAULT '{}'
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(20) NOT NULL,
engine_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
system_prompt TEXT,
agent_instance_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_agent_sessions_instance ON agent_sessions(agent_instance_id) WHERE agent_instance_id IS NOT NULL;
-- Command Records
CREATE TABLE command_records (