Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
18 changes: 16 additions & 2 deletions apps/extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Before committing extension changes, run `pnpm format`. Prefer `pnpm --filter ki
## Agent Modes

- Safe mode exposes read tools (`get_page_snapshot`, `find_in_page`, `get_element_details`, `search_memories`, `get_memory`, and when the model supports images `get_viewport_screenshot`), workflow read tools (`search_workflows`, `get_workflow`), and card-gated tools (`save_workflow`, `save_memory`).
- `search_workflows` without a query lists workflows scoped to the selected tab. With a query it searches every site, ranks in-scope matches first, and reports `inScope` and `startUrl` per result.
- `save_workflow` and `save_memory` are card-gated — the executor blocks the tool turn until the user approves or rejects on the approval card.
- `run_workflow` is gated behind the "Allow workflows in safe mode" toggle. In dangerous mode the toggle is bypassed and `run_workflow` is always available.
- `delete_workflow` and `eval` are dangerous-mode only and never exposed in safe mode.
Expand All @@ -70,13 +71,26 @@ Before committing extension changes, run `pnpm format`. Prefer `pnpm --filter ki

## Workflows

A workflow is a stored async function script with signature `({ page, state }) => result`.
A workflow is a stored async function script with signature `({ page, state, input }) => result`.
The script must return `{ done: true, result }` to finish, or `{ navigate: "<url>", state }` to continue on another page in the same origin scope.
Page helpers (`page.click`, `page.fill`, `page.text`, `page.textAll`, `page.attr`, `page.exists`) let the script read and interact with the page.
Page helpers (`page.click`, `page.fill`, `page.text`, `page.textAll`, `page.attr`, `page.exists`, `await page.waitFor(selector, timeoutMs?)`) let the script read and interact with the page.
Every workflow is scoped to an origin and an optional path prefix; `run_workflow` refuses to execute when the selected tab origin does not match the stored scope.
Approval is per script version: the SHA-256 hash of the approved script is stored as `approvedScriptHash`.
Any edit to the script clears approval and requires the user to re-approve on the save card (`aria-label="Save workflow"`).

### Params and input

A workflow declares `params` (`name`, `description`, optional `example`, optional `required`) for values that vary between runs.
`run_workflow` passes `input` to the script and rejects a run when a declared required param is missing.
`input` is re-injected on every page, so a navigation never loses it. `state.input` mirrors `input` on the first page only, for scripts written before params existed.
The settings Run button opens a param form when the workflow declares params; required values gate the Run button.

### Dry runs

A dry run records `page.click` and `page.fill` instead of performing them, so content those actions would produce never appears.
A selector miss **after** the first recorded action reports success with the recorded actions and a note; a miss **before** any action is a real failure.
Do not treat a dry run that stops after recorded actions as a broken workflow, and do not re-save the script because of it. Only a real run, started by the user, verifies the rest.

## Prompt Context

- Keep `EXTENSION_AGENT_SYSTEM_PROMPT` stable and mode-aware in `src/shared/agent-llm-harness.ts`.
Expand Down
5 changes: 4 additions & 1 deletion apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,10 @@ export const AgentChatPanel = ({

// Execute the workflow as a tool call and append the result.
const toolCall = createWorkflowToolCall({
arguments: { workflowId: request.workflowId },
arguments: {
workflowId: request.workflowId,
...(request.input === undefined ? {} : { input: request.input }),
},
name: 'run_workflow',
tabId: runSelectedTabId,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable max-expects, jsx-no-new-object-as-prop -- test rendering helpers and fixture assertions */
// @vitest-environment jsdom

import { describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { WorkflowToolCallEvent } from '@/src/shared/agent-conversation';
import type { WorkflowToolContext } from './agent-workflow-tool-runtime';
import { executeWorkflowToolCall } from './agent-workflow-tool-runtime';
import { executeWorkflowToolCall, resolveWorkflowStartUrl } from './agent-workflow-tool-runtime';

// ---------- mocks ----------

Expand Down Expand Up @@ -55,7 +55,7 @@ describe('search_workflows', () => {
const result = await executeWorkflowToolCall(createToolCall('search_workflows'), ctx);
expect(result).toStrictEqual({
ok: true,
value: { message: 'No workflows for this site.', results: [] },
value: { message: 'No workflows saved yet. Use save_workflow to create one.', results: [] },
});
});

Expand Down Expand Up @@ -289,7 +289,8 @@ describe('save_workflow', () => {
ctx
);
expect(result).toStrictEqual({
error: 'Workflow not found.',
error:
'Workflow not found — the workflowId does not match any saved workflow. Use search_workflows to find it, or omit workflowId to create a new workflow.',
ok: false,
});
});
Expand Down Expand Up @@ -372,7 +373,7 @@ describe('save_workflow', () => {
ctx
);
expect(result).toStrictEqual({
error: 'startUrl is not a valid URL.',
error: `startUrl must be an absolute URL inside the scope, e.g. "https://example.com/path", or a path starting with "/". Received: not-a-valid-url`,
ok: false,
});
});
Expand Down Expand Up @@ -464,7 +465,8 @@ describe('run_workflow', () => {
ctx
);
expect(result).toStrictEqual({
error: 'Workflows are disabled in safe mode. The user can enable them in settings.',
error:
'Workflow runs are disabled in safe mode. Ask the user to enable "Allow workflows in safe mode" in settings, or to switch this conversation to dangerous mode.',
ok: false,
});
});
Expand Down Expand Up @@ -546,7 +548,10 @@ describe('run_workflow', () => {
createToolCall('run_workflow', { workflowId: 'nonexistent' }),
ctx
);
expect(result).toStrictEqual({ error: 'Workflow not found.', ok: false });
expect(result).toStrictEqual({
error: 'Workflow not found. Use search_workflows to list saved workflows and their ids.',
ok: false,
});
});
});

Expand Down Expand Up @@ -698,3 +703,210 @@ describe('save_memory', () => {
);
});
});

// ---------- workflow params ----------

describe('workflow params through tools', () => {
it('rejects save_workflow with duplicate param names', async () => {
const ctx = createBaseCtx();
const result = await executeWorkflowToolCall(
createToolCall('save_workflow', {
description: 'Test',
name: 'Test',
params: [
{ description: 'One', name: 'city' },
{ description: 'Two', name: 'city' },
],
scopeOrigin: 'https://example.com',
script: 'return { done: true, result: 1 };',
}),
ctx
);

expect(result).toStrictEqual({ error: 'params must not contain duplicate names.', ok: false });
});

it('carries params into the approval draft on create', async () => {
const requestApproval = vi.fn().mockResolvedValue({ savedId: 'id-1', status: 'approved' });
const ctx = createBaseCtx({ requestApproval });

await executeWorkflowToolCall(
createToolCall('save_workflow', {
description: 'Test',
name: 'Test',
params: [{ description: 'City', example: 'SFO', name: 'destination', required: true }],
scopeOrigin: 'https://example.com',
script: 'return { done: true, result: 1 };',
}),
ctx
);

expect(requestApproval).toHaveBeenCalledWith(
'workflow',
expect.objectContaining({
params: [{ description: 'City', example: 'SFO', name: 'destination', required: true }],
})
);
});

it('uses the empty params array as the cleared sentinel on update', async () => {
const requestApproval = vi.fn().mockResolvedValue({ savedId: 'wf-1', status: 'approved' });
const stored = {
approvedScriptHash: 'hash',
createdAt: 1,
description: 'Old',
id: 'wf-1',
name: 'Old',
scopeOrigin: 'https://example.com',
script: 'return { done: true, result: 1 };',
updatedAt: 1,
};
const ctx = createBaseCtx({
requestApproval,
storage: {
getItem: vi.fn().mockResolvedValue([stored]),
removeItem: vi.fn().mockResolvedValue(undefined),
setItem: vi.fn().mockResolvedValue(undefined),
},
});

await executeWorkflowToolCall(
createToolCall('save_workflow', {
description: 'New',
name: 'New',
scopeOrigin: 'https://example.com',
script: 'return { done: true, result: 2 };',
workflowId: 'wf-1',
}),
ctx
);

expect(requestApproval).toHaveBeenCalledWith(
'workflow',
expect.objectContaining({ params: [], workflowId: 'wf-1' })
);
});

it('returns params in search_workflows results', async () => {
const stored = {
approvedScriptHash: 'hash',
createdAt: 1,
description: 'Flights',
id: 'wf-1',
name: 'Flights',
params: [{ description: 'City', name: 'destination', required: true }],
scopeOrigin: 'https://example.com',
script: 'return { done: true, result: 1 };',
updatedAt: 1,
};
const ctx = createBaseCtx({
storage: {
getItem: vi.fn().mockResolvedValue([stored]),
removeItem: vi.fn().mockResolvedValue(undefined),
setItem: vi.fn().mockResolvedValue(undefined),
},
});

const result = await executeWorkflowToolCall(createToolCall('search_workflows', {}), ctx);

expect(result).toStrictEqual({
ok: true,
value: {
results: [
expect.objectContaining({
params: [{ description: 'City', name: 'destination', required: true }],
}),
],
},
});
});
});

describe('startUrl resolution', () => {
it('resolves a path startUrl against the scope origin', () => {
expect(resolveWorkflowStartUrl('/', 'https://example.com')).toBe('https://example.com/');
expect(resolveWorkflowStartUrl('/travel/flights', 'https://example.com')).toBe(
'https://example.com/travel/flights'
);
});

it('leaves absolute and unresolvable values untouched', () => {
expect(resolveWorkflowStartUrl('https://example.com/x', 'https://example.com')).toBe(
'https://example.com/x'
);
expect(resolveWorkflowStartUrl('travel', 'https://example.com')).toBe('travel');
expect(resolveWorkflowStartUrl(undefined, 'https://example.com')).toBeUndefined();
});

it('accepts a path startUrl in save_workflow and stores it absolute', async () => {
const requestApproval = vi.fn().mockResolvedValue({ savedId: 'id-1', status: 'approved' });
const ctx = createBaseCtx({ requestApproval });

const result = await executeWorkflowToolCall(
createToolCall('save_workflow', {
description: 'Test',
name: 'Test',
scopeOrigin: 'https://example.com',
script: 'return { done: true, result: 1 };',
startUrl: '/search',
}),
ctx
);

expect(result).toStrictEqual({ ok: true, value: { saved: true, workflowId: 'id-1' } });
expect(requestApproval).toHaveBeenCalledWith(
'workflow',
expect.objectContaining({ startUrl: 'https://example.com/search' })
);
});
});

describe('run results name the workflow', () => {
const stored = {
approvedScriptHash: 'hash',
createdAt: 1,
description: 'Flights',
id: 'wf-1',
name: 'Flight price search',
scopeOrigin: 'https://example.com',
script: 'return { done: true, result: 1 };',
updatedAt: 1,
};

const ctxWithStored = () =>
createBaseCtx({
storage: {
getItem: vi.fn().mockResolvedValue([stored]),
removeItem: vi.fn().mockResolvedValue(undefined),
setItem: vi.fn().mockResolvedValue(undefined),
},
});

it('includes the workflow name in a successful result', async () => {
vi.mocked(runWorkflow).mockResolvedValueOnce({ ok: true, pagesVisited: 1, result: 'done' });

const result = await executeWorkflowToolCall(
createToolCall('run_workflow', { workflowId: 'wf-1' }),
ctxWithStored()
);

expect(result).toStrictEqual({
ok: true,
value: { pagesVisited: 1, result: 'done', workflowName: 'Flight price search' },
});
});

it('names the workflow in a failure', async () => {
vi.mocked(runWorkflow).mockResolvedValueOnce({ error: 'Tab is at X.', ok: false });

const result = await executeWorkflowToolCall(
createToolCall('run_workflow', { workflowId: 'wf-1' }),
ctxWithStored()
);

expect(result).toStrictEqual({
error: 'Workflow "Flight price search" failed: Tab is at X.',
ok: false,
});
});
});
Loading
Loading