Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ OPENHUMAN_MODEL=
OPENHUMAN_WORKSPACE=
# [optional] Default: 0.7
OPENHUMAN_TEMPERATURE=0.7
# [optional] Skill + agent tool execution timeout in seconds (default 120, max 3600)
# OPENHUMAN_TOOL_TIMEOUT_SECS=

# ---------------------------------------------------------------------------
# Runtime flags
Expand Down
4 changes: 4 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ VITE_DEV_JWT_TOKEN=

# [optional] Dev-only: force onboarding flow to always show
VITE_DEV_FORCE_ONBOARDING=false

# [optional] Client-side timeout for skill callTool/triggerSync (seconds; default 120, max 3600).
# Should match OPENHUMAN_TOOL_TIMEOUT_SECS on the core when set.
# VITE_TOOL_TIMEOUT_SECS=
22 changes: 22 additions & 0 deletions app/src/chat/chatSendError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** Structured chat send / delivery errors (issue #219) — stable `code` for analytics and tests. */

export type ChatSendErrorCode =
| 'socket_disconnected'
| 'local_model_failed'
| 'cloud_send_failed'
| 'voice_transcription'
| 'microphone_unavailable'
| 'microphone_recording'
| 'microphone_access'
| 'voice_playback'
| 'safety_timeout';

export interface ChatSendError {
code: ChatSendErrorCode;
message: string;
}

export const chatSendError = (code: ChatSendErrorCode, message: string): ChatSendError => ({
code,
message,
});
48 changes: 48 additions & 0 deletions app/src/lib/skills/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import { useCallback, useEffect, useRef, useState } from 'react';

import type { SkillSyncStatsLike } from '../../pages/skillsSyncUi';
import { runtimeSkillDataStats } from '../../utils/tauriCommands';
import type { SkillConnectionStatus, SkillHostConnectionState } from './types';
import { onSkillStateChange } from './skillEvents';
import {
Expand Down Expand Up @@ -209,3 +211,49 @@ export function useSkillConnectionInfo(skillId: string): {
isInitialized: !!hostState?.is_initialized,
};
}

/**
* Disk usage under the skill data directory (from core RPC). Refreshes on skill events
* and periodically while the skill is connected.
*/
export function useSkillDataDirectoryStats(
skillId: string,
fetchEnabled: boolean,
): Pick<SkillSyncStatsLike, 'localDataBytes' | 'localFileCount'> | undefined {
const [stats, setStats] = useState<
Pick<SkillSyncStatsLike, 'localDataBytes' | 'localFileCount'> | undefined
>(undefined);

useEffect(() => {
if (!fetchEnabled) {
setStats(undefined);
return;
}
let cancelled = false;
const load = async () => {
try {
const d = await runtimeSkillDataStats(skillId);
if (!cancelled) {
setStats({
localDataBytes: d.total_bytes,
localFileCount: d.file_count,
});
}
} catch {
if (!cancelled) setStats(undefined);
}
};
void load();
const interval = setInterval(load, 30_000);
const unsub = onSkillStateChange((id) => {
if (!id || id === skillId) void load();
});
return () => {
cancelled = true;
clearInterval(interval);
unsub();
};
}, [skillId, fetchEnabled]);

return stats;
}
84 changes: 56 additions & 28 deletions app/src/lib/skills/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ import {
runtimeSkillDataRead,
runtimeSkillDataWrite,
} from "../../utils/tauriCommands";
import { toolExecutionTimeoutMsFromEnv, withTimeout } from "../../utils/withTimeout";
// Env vars kept for reverse RPC compatibility (may be used by skills via state)


class SkillManager {
private runtimes = new Map<string, SkillRuntime>();
private resyncAfterReconnectInProgress = false;

/**
* Get skill-specific load parameters (e.g., wallet address for wallet skill)
Expand Down Expand Up @@ -110,6 +112,21 @@ class SkillManager {
}
}

/**
* After realtime socket reconnect: refresh tool lists for every running skill so
* `tool:sync` matches the Rust engine (issue #215).
*/
async resyncRunningSkillsAfterReconnect(): Promise<void> {
if (this.resyncAfterReconnectInProgress) return;
this.resyncAfterReconnectInProgress = true;
try {
const ids = [...this.runtimes.keys()];
await Promise.all(ids.map((id) => this.activateSkill(id)));
} finally {
Comment on lines +119 to +125
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't re-activate setup-incomplete skills on reconnect.

startSkill() adds runtimes to this.runtimes before setup/OAuth completion, but this path calls activateSkill() for every key. Since app/src/services/socketService.ts:163-173 invokes this on every connect, setup-incomplete skills can get listTools() / syncToolsToBackend() prematurely. Please gate this on already-ready skills, or track activated IDs separately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/skills/manager.ts` around lines 119 - 125,
resyncRunningSkillsAfterReconnect currently re-activates every id from
this.runtimes (calling activateSkill) even for runtimes added by startSkill
before setup/OAuth completes; change it to only re-activate skills that are
already setup/ready (or track an explicit activatedIds set) so incomplete setups
don't trigger listTools()/syncToolsToBackend prematurely. Specifically, in
resyncRunningSkillsAfterReconnect, replace the blind ids =
[...this.runtimes.keys()] with a filtered list based on a readiness indicator on
the runtime (e.g., runtime.setupComplete or runtime.isReady or runtime.status
=== 'ready') or use a maintained activatedIds collection updated by
startSkill/activateSkill, then call activateSkill only for those ready/activated
ids.

this.resyncAfterReconnectInProgress = false;
}
}

/**
* Activate a skill that has completed setup — list its tools and mark as ready.
*/
Expand Down Expand Up @@ -197,7 +214,12 @@ class SkillManager {
console.error(`[SkillManager] callTool failed — skill "${skillId}" has no running runtime`);
throw new Error(`Skill ${skillId} is not running`);
}
const result = await runtime.callTool(name, args);
const timeoutMs = toolExecutionTimeoutMsFromEnv();
const result = await withTimeout(
runtime.callTool(name, args),
timeoutMs,
`[SkillManager] callTool skill="${skillId}" tool="${name}"`,
);
console.log(`[SkillManager] callTool result skill="${skillId}" tool="${name}" isError=${result.isError}`);
return result;
}
Expand Down Expand Up @@ -229,16 +251,25 @@ class SkillManager {
* Progress updates are published to Redux via the skill's state fields.
*/
async triggerSync(skillId: string): Promise<void> {
const timeoutMs = toolExecutionTimeoutMsFromEnv();
const runtime = this.runtimes.get(skillId);
if (runtime) {
await runtime.triggerSync();
await withTimeout(
runtime.triggerSync(),
timeoutMs,
`[SkillManager] triggerSync skill="${skillId}"`,
);
} else {
// Try via core RPC pass-through
try {
await callCoreRpc({
method: "openhuman.skills_sync",
params: { skill_id: skillId },
});
await withTimeout(
callCoreRpc({
method: "openhuman.skills_sync",
params: { skill_id: skillId },
}),
timeoutMs,
`[SkillManager] skills_sync skill="${skillId}"`,
);
} catch {
// Skill not running — skip sync silently
}
Comment on lines 264 to 275
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't swallow withTimeout(...) failures in the RPC fallback.

This blanket catch now hides the new timeout rejection too, so triggerSync() looks successful whenever there is no local runtime—even if openhuman.skills_sync timed out or the core returned a real error. Only ignore the specific “skill not running” case here and rethrow the rest.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/skills/manager.ts` around lines 264 - 275, The current catch
around the withTimeout(callCoreRpc(...)) in the SkillManager sync path swallows
all errors (including timeouts and real RPC errors); update the catch to only
suppress the specific "skill not running" error and rethrow everything else.
Inside the try/catch around withTimeout(callCoreRpc({ method:
"openhuman.skills_sync", params: { skill_id: skillId } }, ...)), inspect the
caught error (e.g., error?.message or error?.code) and if it matches the known
"skill not running" indicator preserve the silent skip, otherwise throw the
error again so timeouts and other RPC failures propagate; keep references to
withTimeout and callCoreRpc as the loci of change.

Expand Down Expand Up @@ -366,30 +397,27 @@ class SkillManager {
// Revoke OAuth credential before stopping so the running skill can clean up
// its in-memory state and the event loop deletes oauth_credential.json.
let revokeSucceeded = false;
if (credentialId) {
try {
await rpcRevokeOAuth(skillId, credentialId);
revokeSucceeded = true;
} catch (err) {
console.debug(
"[SkillManager] oauth/revoked failed (runtime may be stopped):",
err,
);
}
try {
await rpcRevokeOAuth(skillId, credentialId ?? "default");
revokeSucceeded = true;
} catch (err) {
console.debug(
"[SkillManager] oauth/revoked failed (runtime may be stopped):",
err,
);
}

await this.stopSkill(skillId);

// Host-side fallback: if the RPC couldn't reach the runtime (already stopped,
// or non-OAuth skill), delete the persisted credential file so it isn't
// restored on next start.
if (!revokeSucceeded) {
await removePersistedOAuthCredential(skillId).catch((err) => {
console.debug(
"[SkillManager] host-side credential cleanup failed:",
err,
);
});
try {
await this.stopSkill(skillId);
} finally {
if (!revokeSucceeded) {
await removePersistedOAuthCredential(skillId).catch((err) => {
console.debug(
"[SkillManager] host-side credential cleanup failed:",
err,
);
});
}
}

await rpcSetSetupComplete(skillId, false).catch(() => {});
Expand Down
32 changes: 22 additions & 10 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
import Markdown from 'react-markdown';
import { useNavigate } from 'react-router-dom';

import { type ChatSendError, chatSendError } from '../chat/chatSendError';
import { useLocalModelStatus } from '../hooks/useLocalModelStatus';
import { creditsApi, type TeamUsage } from '../services/api/creditsApi';
import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi';
Expand Down Expand Up @@ -107,7 +108,7 @@ const Conversations = () => {
const [selectedModel, setSelectedModel] = useState('agentic-v1');
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [isSending, setIsSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [sendError, setSendError] = useState<ChatSendError | null>(null);
const socketStatus = useAppSelector(selectSocketStatus);
const [toolTimelineByThread, setToolTimelineByThread] = useState<
Record<string, ToolTimelineEntry[]>
Expand Down Expand Up @@ -568,7 +569,7 @@ const Conversations = () => {

if (!trimmed || !selectedThreadId || isSending) return;
if (!isLocalModelActiveRef.current && socketStatus !== 'connected') {
setSendError('Realtime socket is not connected.');
setSendError(chatSendError('socket_disconnected', 'Realtime socket is not connected.'));
return;
}

Expand Down Expand Up @@ -602,6 +603,12 @@ const Conversations = () => {
sendingTimeoutRef.current = setTimeout(() => {
console.warn('[chat] safety timeout: clearing isSending after 120s with no response');
setIsSending(false);
setSendError(
chatSendError(
'safety_timeout',
'No response from the assistant after 2 minutes. Try again or check your connection.'
)
);
dispatch(setActiveThread(null));
sendingTimeoutRef.current = null;
}, 120_000);
Expand Down Expand Up @@ -648,7 +655,7 @@ const Conversations = () => {
} catch (err) {
pendingReactionRef.current.delete(sendingThreadId);
const msg = err instanceof Error ? err.message : String(err);
setSendError(msg);
setSendError(chatSendError('local_model_failed', msg));
dispatch(
addInferenceResponse({
content: 'Local model error — please try again.',
Expand All @@ -674,7 +681,7 @@ const Conversations = () => {
sendingTimeoutRef.current = null;
}
const msg = err instanceof Error ? err.message : String(err);
setSendError(msg);
setSendError(chatSendError('cloud_send_failed', msg));
setIsSending(false);
dispatch(setActiveThread(null));
}
Expand Down Expand Up @@ -719,7 +726,7 @@ const Conversations = () => {
await handleSendMessage(transcript);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSendError(`Voice transcription failed: ${message}`);
setSendError(chatSendError('voice_transcription', `Voice transcription failed: ${message}`));
setVoiceStatus(null);
} finally {
setIsTranscribing(false);
Expand All @@ -730,7 +737,10 @@ const Conversations = () => {
if (!rustChat || isSending || isTranscribing) return;
if (!canUseMicrophoneApi) {
setSendError(
'Microphone capture is unavailable in this runtime. Use Text mode, or run the desktop app bundle with microphone permissions enabled.'
chatSendError(
'microphone_unavailable',
'Microphone capture is unavailable in this runtime. Use Text mode, or run the desktop app bundle with microphone permissions enabled.'
)
);
return;
}
Expand Down Expand Up @@ -766,7 +776,7 @@ const Conversations = () => {
setIsRecording(false);
mediaStreamRef.current?.getTracks().forEach(track => track.stop());
mediaStreamRef.current = null;
setSendError('Microphone recording failed.');
setSendError(chatSendError('microphone_recording', 'Microphone recording failed.'));
};
recorder.onstop = () => {
void transcribeAndSendAudio(recorder.mimeType);
Expand All @@ -779,7 +789,7 @@ const Conversations = () => {
recorder.start();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSendError(`Microphone access failed: ${message}`);
setSendError(chatSendError('microphone_access', `Microphone access failed: ${message}`));
setVoiceStatus(null);
}
};
Expand Down Expand Up @@ -815,7 +825,7 @@ const Conversations = () => {
await audio.play();
} catch {
if (!cancelled) {
setSendError('Failed to play voice reply.');
setSendError(chatSendError('voice_playback', 'Failed to play voice reply.'));
}
} finally {
if (!cancelled) {
Expand Down Expand Up @@ -1318,7 +1328,9 @@ const Conversations = () => {

{sendError && (
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-coral-500">{sendError}</p>
<p className="text-xs text-coral-500" data-chat-send-error-code={sendError.code}>
{sendError.message}
</p>
<button
onClick={() => setSendError(null)}
className="text-xs text-stone-500 hover:text-stone-300 transition-colors ml-2 flex-shrink-0">
Expand Down
36 changes: 33 additions & 3 deletions app/src/pages/Skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ import {
} from '../components/skills/shared';
import SkillDebugModal from '../components/skills/SkillDebugModal';
import SkillSetupModal from '../components/skills/SkillSetupModal';
import { useAvailableSkills, useSkillConnectionStatus, useSkillState } from '../lib/skills/hooks';
import {
useAvailableSkills,
useSkillConnectionStatus,
useSkillDataDirectoryStats,
useSkillState,
} from '../lib/skills/hooks';
import { skillManager } from '../lib/skills/manager';
import { installSkill } from '../lib/skills/skillsApi';
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
import { IS_DEV } from '../utils/config';
import { deriveSkillSyncSummaryText, deriveSkillSyncUiState } from './skillsSyncUi';
import {
deriveSkillSyncSummaryText,
deriveSkillSyncUiState,
type SkillSyncStatsLike,
} from './skillsSyncUi';

/** Status dot color for skill connection status */
function statusDotClass(status: SkillConnectionStatus): string {
Expand All @@ -41,7 +50,28 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
const connectionStatus = useSkillConnectionStatus(skill.id);
const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline;
const skillState = useSkillState<SkillHostConnectionState & Record<string, unknown>>(skill.id);
const syncStats = undefined; // TODO: sync stats will come from RPC in future
const diskStats = useSkillDataDirectoryStats(skill.id, connectionStatus === 'connected');
const syncStats = useMemo((): SkillSyncStatsLike | undefined => {
const base: SkillSyncStatsLike = { ...diskStats };
const sc = skillState?.syncCount;
if (typeof sc === 'number' && Number.isFinite(sc)) base.syncCount = sc;
const last =
typeof skillState?.lastSyncAtMs === 'number'
? skillState.lastSyncAtMs
: typeof skillState?.lastSyncTime === 'number'
? skillState.lastSyncTime
: undefined;
if (last != null && Number.isFinite(last)) base.lastSyncAtMs = last;
if (
base.syncCount == null &&
base.lastSyncAtMs == null &&
base.localDataBytes == null &&
base.localFileCount == null
) {
return undefined;
}
return base;
}, [diskStats, skillState]);
const [manualSyncing, setManualSyncing] = useState(false);
const [debugOpen, setDebugOpen] = useState(false);
const syncUi = useMemo(
Expand Down
Loading
Loading