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
5 changes: 5 additions & 0 deletions packages/dsh-plugin-browserskill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ the model. The six `browser_*` tool schemas are added to the system prompt after
the `browser-skill` skill is successfully invoked, either by the model or through
`/browser-skill`. Set `lazyTools: false` to make the tools available immediately.

After a plugin reload, a live or resumed conversation's successful skill invocation
restores the tools from its stored history. If an older plugin reports
`unknown tool "browser_session"`, invoke `skill browser-skill` again, or set
`lazyTools: false` in the profile patch as a temporary workaround.

## Live browser view

The dsh Web UI prefers a **Browser Skill** tab in DSH's native right sidebar.
Expand Down
12 changes: 12 additions & 0 deletions packages/dsh-plugin-browserskill/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ plugin is unloaded. Repeated invocations do not register duplicate tools. Enteri
a resumed conversation whose history contains a successful skill invocation also
registers the tools. Setting `lazyTools: false` registers them at plugin startup.

Reload recovery recognizes both current DSH tool-result messages (a tool source
and a matching `tool-result` content block) and older flat `callId`/`isError`
messages. It scans each session object's existing append-only history once per
plugin lifetime, using `snapshotEvents()` on newer hosts or the legacy `events`
getter. It then folds new events without reading or copying the full log on
streaming updates. Pending call IDs are isolated by session and removed when their
results arrive. A failed history read is retried on a later event; sessions missed
at startup are discovered through service readiness or their first later event.
Failed registration retains the invocation proof and retries on the next turn,
session entry, service discovery, or successful skill invocation. Streaming
events continue to fold without repeated registration attempts or warnings.

## Observation subscriptions and routes

- **Native presentation**: the client optionally injects `sidebarRight` and
Expand Down
1 change: 1 addition & 0 deletions packages/dsh-plugin-browserskill/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/schemastery": "^3.18.1",
Expand Down
3 changes: 3 additions & 0 deletions packages/dsh-plugin-browserskill/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ unknown effects or switch backends to bypass limits. Borrow confirmation still a

## Recover

- Unknown browser tool after plugin reload: invoke `skill` with `name: "browser-skill"`
again (users can enter `/browser-skill`), then retry the intended browser tool once
after its schema appears. If it remains unavailable, report the failure.
- Stale ref: observe, then retry the intended action once.
- Unknown tab/session: list owned resources or start a session; never guess IDs.
- Failed or interrupted session stop: accepted cleanup continues in the background.
Expand Down
180 changes: 148 additions & 32 deletions packages/dsh-plugin-browserskill/src/lazy-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* the whole suite for the rest of the process lifetime; repeated invocations
* are idempotent no-ops. Session resume is covered by scanning durable
* session events for a past successful invocation (tool/call + tool/result
* pair, or a skill-invocation sourced message) when a session is entered.
* pair, or a skill-invocation sourced message) when a session is entered or
* first observed after a reload. Later events are consumed incrementally.
*
* Verified against dsh 0.1 (recorded in the PR ticket):
* - `tools/result(exec, result)`: exec carries normalized `name`/`arguments`,
Expand All @@ -19,16 +20,17 @@
* tool-skill's per-step catalog digest treats visibility changes as a
* first-class cache-invalidation input — the suite simply appears in the
* NEXT step's assembly.
* - Durable events: `tool/call` {callId, name, arguments(JSON string)} pairs
* with `tool/result` {message: {callId, isError}} — the history signal.
* - Durable results use message.source.callId and a tool-result content
* block. Older hosts stored callId/isError directly on the message.
*/

import type { Context } from "@deepseek-ai/cordis";
import { BSK_SKILL_NAME } from "./skill-content.generated";

/** Structural view of the pieces of the session seam we consume. */
interface SessionLike {
events: readonly SessionEventLike[];
events?: readonly SessionEventLike[];
snapshotEvents?(): readonly SessionEventLike[];
}
interface SessionEventLike {
type: string;
Expand All @@ -43,6 +45,67 @@ interface ToolResultExecutionLike {
arguments?: unknown;
}

function record(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}

function isCallId(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}

/** Accept current DSH messages and the older flat tool-result shape. */
function toolResultOf(data: unknown): { callId: string; isError: boolean } | undefined {
const message = record(record(data)?.message);
if (message === undefined) return;
const source = record(message.source);
if (source?.kind === "tool") {
const blocks = message.content;
const block = Array.isArray(blocks) && blocks.length === 1 ? record(blocks[0]) : undefined;
if (
isCallId(source.callId) &&
block?.type === "tool-result" &&
block.toolCallId === source.callId &&
typeof block.isError === "boolean"
) {
return { callId: source.callId, isError: block.isError };
}
return;
}
if (isCallId(message.callId) && typeof message.isError === "boolean") {
return { callId: message.callId, isError: message.isError };
}
}

/** One session's append-only history fold; retain only unsettled skill calls. */
class SkillInvocationState {
successful = false;
private readonly pending = new Set<string>();

consume(event: SessionEventLike): void {
if (this.successful) return;
if (event.type === "user/message" && isSkillInvocationMessage(event.data)) {
this.successful = true;
} else if (event.type === "tool/call") {
const data = record(event.data);
if (
data?.name === "skill" &&
skillNameOf(data.arguments) === BSK_SKILL_NAME &&
isCallId(data.callId)
) {
this.pending.add(data.callId);
}
} else if (event.type === "tool/result") {
const result = toolResultOf(event.data);
if (result !== undefined && this.pending.delete(result.callId)) {
this.successful = !result.isError;
}
}
if (this.successful) this.pending.clear();
}
}

/** Parse a tool arguments payload that may be normalized (object) or raw JSON. */
function skillNameOf(args: unknown): string | undefined {
if (typeof args === "string") {
Expand Down Expand Up @@ -73,22 +136,10 @@ function isSkillInvocationMessage(data: unknown): boolean {
* `/browser-skill` user gesture landed as a skill-invocation message.
*/
export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]): boolean {
const skillCallIds = new Set<unknown>();
for (const event of events) {
if (event.type !== "tool/call") continue;
const data = event.data as { callId?: unknown; name?: unknown; arguments?: unknown };
if (data?.name === "skill" && skillNameOf(data.arguments) === BSK_SKILL_NAME) {
skillCallIds.add(data.callId);
}
}
const state = new SkillInvocationState();
for (const event of events) {
if (event.type === "tool/result") {
const message = (event.data as { message?: unknown })?.message;
if (typeof message !== "object" || message === null) continue;
const { callId, isError } = message as { callId?: unknown; isError?: unknown };
if (isError === false && skillCallIds.has(callId)) return true;
}
if (isSkillInvocationMessage(event.data)) return true;
state.consume(event);
if (state.successful) return true;
}
return false;
}
Expand All @@ -99,16 +150,22 @@ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]
* @param registerSuite - registers the six browser tools and returns their disposer.
*/
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
let disposed = false;
let revealPending = false;
let sessionStates = new WeakMap<SessionLike, SkillInvocationState>();
let suiteDisposer: (() => void) | undefined;
const disposers: (() => void)[] = [];
const ensureSuite = (): void => {
if (suiteDisposer !== undefined) return;
if (disposed || suiteDisposer !== undefined) return;
try {
suiteDisposer = registerSuite();
revealPending = false;
sessionStates = new WeakMap();
} catch (error) {
// A failed reveal must not strand the plugin: stay hidden, log, retry on
// the next trigger instead of latching a half-registered suite.
// Keep successful invocation proof, but retry only at a lifecycle
// boundary or a new invocation, never on every streaming chunk.
suiteDisposer = undefined;
revealPending = true;
console.warn(
`[dsh-plugin-browserskill] lazy tool registration failed: ${error instanceof Error ? error.message : String(error)}`,
);
Expand All @@ -117,37 +174,96 @@ export function armLazyTools(ctx: Context, registerSuite: () => () => void): ()

// Live trigger: a successful model invocation of skill/browser-skill.
const onToolResult = (exec: ToolResultExecutionLike, result: { isError: boolean }): void => {
if (result.isError) return;
if (result.isError !== false) return;
if (exec.name !== "skill") return;
if (skillNameOf(exec.arguments) === BSK_SKILL_NAME) ensureSuite();
};
disposers.push(ctx.on("tools/result" as never, onToolResult as never));

const scanSession = (session: SessionLike): void => {
const stateFor = (session: SessionLike): SkillInvocationState | undefined => {
const known = sessionStates.get(session);
if (known !== undefined) return known;
try {
if (hasSuccessfulSkillInvocation(session.events)) ensureSuite();
const state = new SkillInvocationState();
// Newer hosts expose an immutable snapshot method; earlier 0.1 hosts
// expose an events getter. Both are read only once per live session.
const history =
typeof session.snapshotEvents === "function" ? session.snapshotEvents() : session.events;
if (history === undefined) return undefined;
for (const event of history) {
state.consume(event);
if (state.successful) break;
}
// A failed read is not a completed scan: retry on the next event.
sessionStates.set(session, state);
return state;
} catch {
// A session object that cannot be read must never break plugin startup.
return undefined;
}
};

const scanSession = (session: SessionLike): void => {
if (disposed || suiteDisposer !== undefined) return;
if (revealPending || stateFor(session)?.successful) ensureSuite();
};

// Live gesture/append feed: covers /browser-skill user gestures (no tool
// call happens on that path) landing as skill-invocation messages.
const onSessionEvent = (_session: SessionLike, event: SessionEventLike): void => {
if (isSkillInvocationMessage(event?.data)) ensureSuite();
const onSessionEvent = (session: SessionLike, event: SessionEventLike): void => {
if (disposed || suiteDisposer !== undefined) return;
if (
(revealPending && event?.type === "turn/start") ||
(event?.type === "user/message" && isSkillInvocationMessage(event.data))
) {
ensureSuite();
return;
}
if (session == null || event == null) return;
// The first event can expose a session missed during startup. Scan its
// existing history once; never read/copy the log on subsequent tokens.
const state = stateFor(session);
state?.consume(event);
if (state?.successful && !revealPending) ensureSuite();
};
disposers.push(ctx.on("session/event" as never, onSessionEvent as never));

// History restore: sessions entered from now on, plus any already live.
const onSessionCreated = (session: SessionLike): void => scanSession(session);
disposers.push(ctx.on("session/created" as never, onSessionCreated as never));
const sessions = ctx.get("sessions") as SessionsLike | null | undefined;
if (sessions != null && typeof sessions.list === "function") {
for (const session of sessions.list()) scanSession(session);
}
const scanExisting = (context: Context): void => {
if (disposed || suiteDisposer !== undefined) return;
// Invocation proof is already retained: retry once for this discovery,
// independently of how many sessions the registry currently contains.
if (revealPending) {
ensureSuite();
return;
}
try {
const sessions = context.get("sessions") as SessionsLike | null | undefined;
if (sessions != null && typeof sessions.list === "function") {
for (const session of sessions.list()) {
scanSession(session);
// A failed attempt also ends this batch; later lifecycle events retry.
if (revealPending || suiteDisposer !== undefined) break;
}
}
} catch {
// An unavailable registry must not prevent later session/event recovery.
}
};
scanExisting(ctx);
// Also restore as soon as a late sessions service is available, before the
// next tool lookup. Cordis owns this watcher's lifetime with the plugin.
const watcher = ctx.inject(["sessions"], scanExisting);
disposers.push(() => {
void watcher.dispose();
});

return () => {
if (disposed) return;
disposed = true;
for (const dispose of disposers.splice(0)) dispose();
suiteDisposer?.();
sessionStates = new WeakMap();
};
}
Loading
Loading