@foldspace_npm/harness 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +116 -29
- package/README.md +2 -2
- package/package.json +1 -1
- package/recipes/README.md +9 -5
- package/recipes/account-overview/README.md +1 -1
- package/recipes/lookup-then-view/studio/show_account_overview.json +1 -1
- package/recipes/opportunities-at-risk/README.md +1 -1
- package/recipes/records-list/README.md +1 -1
- package/src/action-observer.mjs +52 -20
- package/src/lint/ast.mjs +96 -1
- package/src/lint/rules.mjs +194 -16
- package/src/lint/safety.mjs +10 -2
package/CLAUDE.md
CHANGED
|
@@ -362,8 +362,53 @@ to what they know. The action handler is yours: call it "the code behind it".
|
|
|
362
362
|
| **Task agent** | *how* to do one reasoning job | Agent Studio | instructions + response schema |
|
|
363
363
|
| **Navigation route** | where the agent can send the user | Agent Studio | a URL template + what the page is for |
|
|
364
364
|
|
|
365
|
-
|
|
366
|
-
|
|
365
|
+
**Built into every agent - never build your own.** These need no action and no
|
|
366
|
+
code. A copy of one competes with the built-in: the agent may pick yours, and
|
|
367
|
+
yours lacks what the built-in does (consent, routes, settings). A first
|
|
368
|
+
experience can lean on them. Lint warns (`no-native-lookalike`) when an action
|
|
369
|
+
looks like one.
|
|
370
|
+
|
|
371
|
+
| Built-in | What the agent gets | Turn it on | Never build |
|
|
372
|
+
|---|---|---|---|
|
|
373
|
+
| **Knowledge search** | Answers from the agent's knowledge | Add a knowledge source or an internal article | An action that searches docs, help or FAQ |
|
|
374
|
+
| **Navigation** | Opens a page of the app | A navigation route (`create_navigation_route`) per page | A URL builder, or an action whose job is opening a page. Finding an item by name is an action; opening its page is a route |
|
|
375
|
+
| **Share current tab** | A screenshot and/or the HTML of the page, once, when the agent needs it (the user agrees first) | Agent settings → Customize → Data Access & Handoff | An action that reads, summarises or screenshots the page: no "what's on this page", no handler that returns `document.body`, `innerHTML` or page text |
|
|
376
|
+
| **Share screen** | The page with every message while sharing is on | The same setting; a developer can also set `screenSharingSettings: { active: true }` in `setConfiguration` | The same |
|
|
377
|
+
| **Shared State** | The app's own state: an open form, filters, the selection | `shareState()` in the agent code | A "current view" or "what is selected" action |
|
|
378
|
+
| **Voice** | Voice chat and dictation in the panel | Agent settings | Microphone, speech-to-text or text-to-speech code |
|
|
379
|
+
| **Ask the user** | A question card with choices | Below | A generic "ask the user", "clarify" or "choose an option" action |
|
|
380
|
+
| **Live-agent handoff, support ticket** | Hands the chat to a person, or opens a ticket | Agent settings → Customize → Data Access & Handoff | Your own handoff - unless the built-in doesn't cover their support tool: ask the human first |
|
|
381
|
+
|
|
382
|
+
When the experience needs one of these and it's off, ask the human to turn it
|
|
383
|
+
on; it changes the live agent. If an existing action already copies one (a
|
|
384
|
+
URL builder, a page reader), say so to the human and propose moving to the
|
|
385
|
+
built-in - don't delete it unasked.
|
|
386
|
+
|
|
387
|
+
**Asking the user a question is a built-in too: `ask_user_question`.** It shows
|
|
388
|
+
a card with 1-4 questions, 2-4 choices each plus "Other", and waits for the
|
|
389
|
+
answers. **Never build an action or a card that does the same** - a generic
|
|
390
|
+
"ask the user", "clarify" or "choose an option". It needs no action, handler
|
|
391
|
+
or code. It may be off for this agent: when the agent needs it, ask the human
|
|
392
|
+
to turn on **Ask questions with choices** (agent settings → **Customize → Data
|
|
393
|
+
access**, then Save); it changes the live agent, and takes about a minute to
|
|
394
|
+
reach it. Build your own input card only when the choices come from the app's
|
|
395
|
+
data or the card does the work, as `pick-from-a-list` and `create-a-record` do.
|
|
396
|
+
|
|
397
|
+
Change the built-in only when the human asks:
|
|
398
|
+
|
|
399
|
+
- **A different look:** set `--fs-ask-*` variables (`--fs-ask-primary`,
|
|
400
|
+
`--fs-ask-surface`, `--fs-ask-border`, `--fs-ask-radius`...) in the agent's
|
|
401
|
+
`customCss`. Never a new card for a new look, and never style its class names.
|
|
402
|
+
- **Their own card:** an action handler under the key `ask_user_question`,
|
|
403
|
+
with no Studio action. It runs instead of the built-in: `execute` gets
|
|
404
|
+
`params.questions`, and the card calls `callback({ status: "ANSWERED",
|
|
405
|
+
answers })` with one `{ header, selected, otherText?, skipped? }` per question,
|
|
406
|
+
or `cancel()` when everything is skipped - the same result, so the agent's
|
|
407
|
+
instructions still fit.
|
|
408
|
+
- **Their own instructions too:** only then a Studio action named
|
|
409
|
+
`ask_user_question`. It replaces the built-in completely, text included.
|
|
410
|
+
- **Removing one:** unpublish an `ask_user_question` action before deleting
|
|
411
|
+
it. A deleted published action still keeps the built-in off.
|
|
367
412
|
|
|
368
413
|
1. `create_action` as a **draft**. `generate_action_handler` works from a draft
|
|
369
414
|
schema; do not publish yet. Generating the handler from the schema is what
|
|
@@ -613,11 +658,14 @@ Do not report success without all six:
|
|
|
613
658
|
5. The action behaves against the real workflow: `[actions]
|
|
614
659
|
local-handler:execute` in the attach log after `foldspace ask` (or the
|
|
615
660
|
human) exercises the visible agent. A card also needs
|
|
616
|
-
`local-handler:render` from a real chat turn
|
|
617
|
-
call. A view card leaves the chat free:
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
661
|
+
`local-handler:render` from a real chat turn (`renderComponent` logs as
|
|
662
|
+
`render` too) - not a hand-built call. A view card leaves the chat free: `ask`
|
|
663
|
+
returns a reply, and in its screenshot the panel shows no stop button and its
|
|
664
|
+
message box does not read "Provide input, or cancel" beside a Cancel button.
|
|
665
|
+
If `ask` returns `ok: false` with `hasCard: true`, or the panel shows either of
|
|
666
|
+
those, the agent is waiting for the card: it uses `callback` or `cancel`
|
|
667
|
+
somewhere, or the SDK can't read it and it never calls `readyState`
|
|
668
|
+
(**Agentic UI components**). Take them out, or destructure the keys. Then ask a follow-up
|
|
621
669
|
question about the card (`foldspace ask` again): the reply quotes its
|
|
622
670
|
numbers exactly as the card shows them. If it does not, make `execute`
|
|
623
671
|
return them as the card shows them (rule 2). An input card holds the chat
|
|
@@ -772,17 +820,37 @@ wins**. <https://docs.foldspace.ai/customize/configuration/>
|
|
|
772
820
|
|
|
773
821
|
## Agentic UI components: what bites
|
|
774
822
|
|
|
775
|
-
An Agentic UI component is the card an action draws in the chat
|
|
776
|
-
Each of these cost a real build.
|
|
777
|
-
|
|
778
|
-
- **
|
|
779
|
-
callback, cancel, readyState
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
823
|
+
An Agentic UI component is the card an action draws in the chat
|
|
824
|
+
(`renderComponent`, or the older `render`). Each of these cost a real build.
|
|
825
|
+
|
|
826
|
+
- **Write new cards with `renderComponent`.**
|
|
827
|
+
`renderComponent({ data, host, header, callback, cancel, readyState, theme })`
|
|
828
|
+
takes one object, so a card names only what it uses and no argument can land
|
|
829
|
+
in the wrong position. Destructure the keys in the parameter: the SDK and lint
|
|
830
|
+
read them to tell the two kinds of card apart. Nested keys are fine
|
|
831
|
+
(`{ data: { items }, host }`), and so is `const { data, host } = ctx`. The SDK
|
|
832
|
+
can't read the object passed on (`mount(ctx)`) or aliased, `...rest` at the top
|
|
833
|
+
of the pattern, `arguments`, or a bound function. The agent then waits until
|
|
834
|
+
that card calls `readyState`, `callback` or `cancel`, so a view card that calls
|
|
835
|
+
none of them never gets a reply until the user presses stop. Destructure the
|
|
836
|
+
keys; where you can't, the view card calls `readyState` once it is drawn. Lint
|
|
837
|
+
warns on these shapes, since it can't check them either.
|
|
838
|
+
`render(data, host, header, callback, cancel,
|
|
839
|
+
readyState, theme)` still works, and `renderComponent` wins if an action has
|
|
840
|
+
both. Leave a working `render` alone unless you are changing that card. The
|
|
841
|
+
recipes still use `render`: when you copy one, convert it, so
|
|
842
|
+
`render: (result, host, header) =>` becomes
|
|
843
|
+
`renderComponent: ({ data: result, host, header }) =>`, and an input card's
|
|
844
|
+
`render: (result, host, header, callback, cancel) =>` becomes
|
|
845
|
+
`renderComponent: ({ data: result, host, header, callback, cancel }) =>`.
|
|
846
|
+
- **A card is a view card or an input card.** What it uses decides: a card
|
|
847
|
+
that uses `callback`, `cancel` or `readyState` is an input card, and the
|
|
848
|
+
agent waits until the card reports through one of them; one that uses none
|
|
849
|
+
of them is a view card. Foldspace's docs call these **read-only** and **input**
|
|
850
|
+
cards; they mean the same thing.
|
|
783
851
|
- **A view card shows something** - an account, a list, deals at risk -
|
|
784
|
-
with `
|
|
785
|
-
|
|
852
|
+
with `renderComponent({ data, host, header })`: no `callback`, no `cancel`.
|
|
853
|
+
As soon as it returns, the agent gets "Shown to the user. The user has not
|
|
786
854
|
acted on it." plus exactly what `execute` returned, and answers questions
|
|
787
855
|
about the card from that. So `execute` returns the facts the card shows,
|
|
788
856
|
as fields, and nothing else - or the failure the card draws with
|
|
@@ -801,23 +869,30 @@ Each of these cost a real build.
|
|
|
801
869
|
draws a default "Cancel" control on a card; not yet checked in the live
|
|
802
870
|
product.
|
|
803
871
|
- **When the conversation should go on while the user decides,** an input
|
|
804
|
-
card calls `readyState(value)`
|
|
805
|
-
drawn: the agent gets `value` as what is shown and carries on. ⚠️ The
|
|
872
|
+
card calls `readyState(value)` once it is drawn: the agent gets `value` as what is shown and carries on. ⚠️ The
|
|
806
873
|
user's answer then arrives as a follow-up turn - seen in the SDK's
|
|
807
874
|
headless mode (`mode: "HEADLESS"`, the customer's own chat UI), not yet
|
|
808
875
|
checked in the chat panel. No recipe uses it yet.
|
|
809
|
-
- **Never set `awaitUserInput`** on either kind:
|
|
810
|
-
the agent waits. An input card does not need it. The chat panel ignores
|
|
876
|
+
- **Never set `awaitUserInput`** on either kind: it is deprecated. What the
|
|
877
|
+
card uses decides whether the agent waits. An input card does not need it. The chat panel ignores
|
|
811
878
|
it; in headless mode `awaitUserInput: true` on a view card leaves the
|
|
812
879
|
agent waiting for a callback that never comes, until the user presses
|
|
813
880
|
stop. Lint stops the build on `awaitUserInput: true` on a view card, and
|
|
814
|
-
warns on any other `awaitUserInput
|
|
815
|
-
`
|
|
816
|
-
|
|
881
|
+
warns on any other `awaitUserInput`, on a card that declares `callback`,
|
|
882
|
+
`cancel` or `readyState` but uses none of them (the SDK then treats it as a
|
|
883
|
+
view card), and on a `renderComponent` the SDK can't read.
|
|
817
884
|
- **What reaches the agent is the tool response: the data and the user's
|
|
818
885
|
choice, as fields**, with the state (chosen, declined, cancelled, failed)
|
|
819
886
|
and nothing about what to do next (**Rules**, 1). A view card needs no
|
|
820
887
|
state of its own: the SDK tells the agent it was shown.
|
|
888
|
+
- **Never report a view card through `callback`.** Calling `callback(data)`
|
|
889
|
+
right after drawing (the old way to get a card's data to the agent) tells
|
|
890
|
+
the agent the user submitted that data, and it replies as if they had.
|
|
891
|
+
A view card takes `renderComponent({ data, host, header })`, or calls
|
|
892
|
+
`readyState(value)` to send less than `execute` returned.
|
|
893
|
+
- **The agent only knows what the card reports, not what it draws.** A
|
|
894
|
+
figure or date on the card that is not in that data is unknown to the
|
|
895
|
+
agent, so put every fact a follow-up question could need in it.
|
|
821
896
|
- **A failed or signed-out call is never drawn as zeros.** A signed-out response
|
|
822
897
|
often looks like a valid empty one: a 200 with zeros, nulls or an empty list.
|
|
823
898
|
Before you draw numbers, check that the response belongs to a signed-in user -
|
|
@@ -834,11 +909,8 @@ Each of these cost a real build.
|
|
|
834
909
|
code, from the API's own numbers - in `execute` for a view card, since its
|
|
835
910
|
result is what the agent gets - show them on the card, and hand the agent
|
|
836
911
|
the same figures. In the action's Studio `instructions`, tell the agent to
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
line items will eventually add them up wrong), and, right after a view
|
|
840
|
-
card, to say one short sentence at most - never repeat what the card
|
|
841
|
-
shows.
|
|
912
|
+
quote money and counts exactly as given and never to add them up itself (a
|
|
913
|
+
model that adds up line items will eventually add them up wrong).
|
|
842
914
|
- **A card matches the panel, not the page.** The card sits in the agent's
|
|
843
915
|
panel - white by default (the agent's theme: `backgroundColor #FFFFFF`,
|
|
844
916
|
`primaryColor` for accents; a `darkTheme` when the panel is dark, and the
|
|
@@ -851,6 +923,21 @@ Each of these cost a real build.
|
|
|
851
923
|
white, so that falls back to greys and the file says so. Record where the
|
|
852
924
|
values came from in the profile's Brand section. Never copy colours by eye:
|
|
853
925
|
that is how a dark card ends up in a white panel.
|
|
926
|
+
- **Every new card follows the panel's mode.** The SDK exposes the mode and
|
|
927
|
+
colours and paints nothing, so a card with fixed colours stays light in a dark
|
|
928
|
+
panel. Take surface, text and border from the `--fs-*` variables, with
|
|
929
|
+
`brand.ts` as the fallback: `background: var(--fs-paper, ${brand.surface})`,
|
|
930
|
+
`color: var(--fs-text, ${brand.text})`,
|
|
931
|
+
`border-color: var(--fs-border, ${brand.border})`. Put
|
|
932
|
+
`color-scheme: var(--fs-color-scheme)` on the card's own element for native
|
|
933
|
+
controls, and use `[data-fs-mode="dark"]` for what has no variable, such as a
|
|
934
|
+
shadow. What CSS can't reach, such as a canvas, reads `theme` (`theme.mode`,
|
|
935
|
+
`theme.primary`) and redraws on `theme.onChange(fn)`. A mode change repaints:
|
|
936
|
+
the card does not run again and nothing reaches the agent. The colours change
|
|
937
|
+
only if the agent has a `darkTheme`; without one only the mode changes, so the
|
|
938
|
+
variables stay light. Look at the card in both modes
|
|
939
|
+
(`agent.setThemeMode("DARK")`) before you call it done. Fields:
|
|
940
|
+
<https://docs.foldspace.ai/guides/in-chat-ui/#support-dark-mode>
|
|
854
941
|
|
|
855
942
|
## Layout
|
|
856
943
|
|
package/README.md
CHANGED
|
@@ -256,13 +256,13 @@ v1 flags:
|
|
|
256
256
|
|
|
257
257
|
- **error** `no-static-agent-prompt` - `execute`, a card's `callback` or its `readyState` handing the agent instructions (`directive`, “tell the user”, …)
|
|
258
258
|
- **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
|
|
259
|
-
- **error** `card-contract` - `awaitUserInput: true` on a view card (its `render` uses no `callback`, `cancel` or `readyState`): the chat panel ignores it, but in headless mode the agent waits for a callback that never comes
|
|
259
|
+
- **error** `card-contract` - `awaitUserInput: true` on a view card (its `renderComponent` or `render` uses no `callback`, `cancel` or `readyState`): the chat panel ignores it, but in headless mode the agent waits for a callback that never comes
|
|
260
260
|
- **error** `no-test-mode-in-bundle` - `armAllInstances` used anywhere in `agent/` (called, or handed on to be called later), or `setTestMode(...)` with anything but a literal `false`. A code project's own copy of the runtime in `agent/runtime/` is not refused for the `setTestMode` inside its `armAllInstances`
|
|
261
261
|
- **error** `no-fixed-customer-address` - on a product with a subdomain per customer (`PER_CUSTOMER_SUBDOMAIN` in `agent/constants.ts`), one customer's address in `API_BASE` or anywhere in `agent/`; while that is undecided, an address whose first part looks like a customer's name (`acme.` - not `app.`, `eu1.`, `reports.`)
|
|
262
262
|
- **error** `token-stays-home` - the user's sign-in token (`getAuthToken`, a session-named storage key, `document.cookie`, followed through variables and helper functions) returned from `execute` or a card, handed to Foldspace, logged, stored again, or sent to a host that is not the product's backend
|
|
263
263
|
- **error** `no-secrets` - API keys, JWTs, bearer tokens, cookie values, secret-named strings, `.har` captures, storage dumps and `.env` files anywhere in the project (not `node_modules/`, `dist/`, `.foldspace-dev/`, `agent/runtime/`)
|
|
264
264
|
- **warning** `known-hosts-only` - `fetch`, `sendBeacon`, `WebSocket`, `EventSource`, `XMLHttpRequest`, `publicFetch` or `import()` to an address written out whose host is neither Foldspace's (`*.foldspace.ai`), the product's (`foldspace.dev.json` hosts, `API_BASE`, the app's own domain) nor written in `agent/constants.ts`
|
|
265
|
-
- **warning** `card-contract` - any other `awaitUserInput` on a view card, `awaitUserInput` on an input card (not needed),
|
|
265
|
+
- **warning** `card-contract` - any other `awaitUserInput` on a view card, `awaitUserInput` on an input card (not needed), a card that declares `callback`, `cancel` or `readyState` and uses none of them (the SDK treats it as a view card), or a `renderComponent` the SDK can't read (the object passed on or aliased, `...rest`, `arguments`): the agent waits until it calls `readyState`, `callback` or `cancel`, so a view card that calls none of them never gets a reply
|
|
266
266
|
- **warning** `no-emoji` — emoji in handler or `agent/views` string literals
|
|
267
267
|
- **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
|
|
268
268
|
|
package/package.json
CHANGED
package/recipes/README.md
CHANGED
|
@@ -40,12 +40,16 @@ those builds learned the hard way. **What is taken is the shape** — the order
|
|
|
40
40
|
calls, what is returned to the agent, where the traps are. **What is not taken**
|
|
41
41
|
is any customer's name, endpoint, identifier, styling or code. Contracts are the
|
|
42
42
|
SDK's: `execute(params)` and
|
|
43
|
-
`
|
|
44
|
-
|
|
43
|
+
`renderComponent({ data, host, header, callback, cancel, readyState, theme })`
|
|
44
|
+
for a new card, or the older
|
|
45
|
+
`render(data, host, header, callback, cancel, readyState, theme)` these recipes
|
|
46
|
+
use (<https://docs.foldspace.ai/guides/in-chat-ui/>; convert when you copy, see
|
|
47
|
+
CLAUDE.md), `foldspace.identify(context)`
|
|
45
48
|
(<https://docs.foldspace.ai/start/user-context/>), and the harness runtime.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
Never set `awaitUserInput` - the SDK waits when a card uses `callback`,
|
|
50
|
+
`cancel` or `readyState` (see the card bullet below). What the agent receives
|
|
51
|
+
for each is in CLAUDE.md (**Agentic UI components**); no recipe uses
|
|
52
|
+
`readyState` yet.
|
|
49
53
|
|
|
50
54
|
Error handling is **not** taken from those builds. They predate `reason`: one
|
|
51
55
|
turns every failure into "unexpected error", another reports a missing session
|
|
@@ -8,7 +8,7 @@ This card is the base layout. Copy it, then replace the `/__observe_me` routes w
|
|
|
8
8
|
|
|
9
9
|
A view card: `render` takes no `callback` and the action sets no `awaitUserInput`. As soon as the card is drawn, the agent gets "Shown to the user. The user has not acted on it." plus exactly what `execute` returned - the account, the totals and every row behind the tabs, or the failure (`success: false`, `error` and `reason`) - and it answers questions about the account from that. So `execute` returns the overview the card draws and nothing else. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 is signed out unless the account has both an id and a name.
|
|
10
10
|
|
|
11
|
-
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`, described as "The account's id. Never taken from the user's words: get it from `<lookup action key>`." Replace `<lookup action key>` with the key of this agent's action that returns account ids; if the agent has none, build that action first - `find-by-name` has the shape. Its `instructions`: "
|
|
11
|
+
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`, described as "The account's id. Never taken from the user's words: get it from `<lookup action key>`." Replace `<lookup action key>` with the key of this agent's action that returns account ids; if the agent has none, build that action first - `find-by-name` has the shape. Its `instructions`: "Quote money and counts exactly as given, and never add them up yourself. No emoji."
|
|
12
12
|
|
|
13
13
|
## Adapt it
|
|
14
14
|
|
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
}
|
|
12
12
|
],
|
|
13
13
|
"confirmation_required": false,
|
|
14
|
-
"instructions": "The result is the data the card was drawn from - every open deal, person and open case, not only the ones it lists - or a failure: success false, with the reason.
|
|
14
|
+
"instructions": "The result is the data the card was drawn from - every open deal, person and open case, not only the ones it lists - or a failure: success false, with the reason. Quote money and counts exactly as given, and never add them up yourself. No emoji."
|
|
15
15
|
}
|
|
@@ -8,7 +8,7 @@ The app decides what "at risk" means, on the server. In that build it was: stall
|
|
|
8
8
|
|
|
9
9
|
A view card: `render` takes no `callback` and the action sets no `awaitUserInput`. As soon as the card is drawn, the agent gets "Shown to the user. The user has not acted on it." plus exactly what `execute` returned - the count, the total, the average and the rows (no rows when the card says "No opportunities are at risk."), or the failure (`success: false`, `error` and `reason`). So `execute` returns what the card draws, with each row's id, and nothing else. The count, the total and the average are computed in code from the rows.
|
|
10
10
|
|
|
11
|
-
In Agent Studio: an action with key `show_opportunities_at_risk` and no parameters. Its `instructions`: "
|
|
11
|
+
In Agent Studio: an action with key `show_opportunities_at_risk` and no parameters. Its `instructions`: "Quote money and counts exactly as given, and never add them up yourself. No emoji."
|
|
12
12
|
|
|
13
13
|
## Adapt it
|
|
14
14
|
|
|
@@ -8,7 +8,7 @@ A view card: `render` takes no `callback` and the action sets no `awaitUserInput
|
|
|
8
8
|
|
|
9
9
|
`brand.ts` here is a placeholder: keep the `agent/views/brand.ts` that `npx foldspace observe styles --write` wrote - it has every key the recipe cards read. If the project has none yet, run that command first; do not copy this one.
|
|
10
10
|
|
|
11
|
-
In Agent Studio: an action with key `list_accounts` and no parameters. Its `instructions`: "
|
|
11
|
+
In Agent Studio: an action with key `list_accounts` and no parameters. Its `instructions`: "Quote money and counts exactly as given, and never add them up yourself. No emoji."
|
|
12
12
|
|
|
13
13
|
## Adapt it
|
|
14
14
|
|
package/src/action-observer.mjs
CHANGED
|
@@ -65,17 +65,22 @@ export function buildActionObserverScript({
|
|
|
65
65
|
catch { return Date.now(); }
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
const
|
|
68
|
+
const errorName = (error) =>
|
|
69
|
+
typeof error?.name === "string" ? error.name.slice(0, 100) : "Error";
|
|
70
|
+
|
|
71
|
+
// Only execute is wrapped. The SDK reads a card's source to tell a view card
|
|
72
|
+
// from an input card, and a wrapper would hide it: the agent would then wait
|
|
73
|
+
// on every view card. recordRender logs cards from the SDK's own events.
|
|
74
|
+
const wrapExecute = (actionName, fn) =>
|
|
69
75
|
function observedActionPhase(...args) {
|
|
70
76
|
const startedAt = now();
|
|
77
|
+
const phase = "execute";
|
|
71
78
|
record({
|
|
72
79
|
source: "local-handler",
|
|
73
80
|
actionName,
|
|
74
81
|
phase,
|
|
75
82
|
status: "started",
|
|
76
|
-
|
|
77
|
-
? { parameterKeys: parameterKeys(args[0]) }
|
|
78
|
-
: {}),
|
|
83
|
+
parameterKeys: parameterKeys(args[0]),
|
|
79
84
|
});
|
|
80
85
|
|
|
81
86
|
const succeeded = (value) => {
|
|
@@ -95,10 +100,7 @@ export function buildActionObserverScript({
|
|
|
95
100
|
phase,
|
|
96
101
|
status: "failed",
|
|
97
102
|
durationMs: Math.max(0, Math.round(now() - startedAt)),
|
|
98
|
-
errorName:
|
|
99
|
-
typeof error?.name === "string"
|
|
100
|
-
? error.name.slice(0, 100)
|
|
101
|
-
: "Error",
|
|
103
|
+
errorName: errorName(error),
|
|
102
104
|
});
|
|
103
105
|
throw error;
|
|
104
106
|
};
|
|
@@ -132,18 +134,16 @@ export function buildActionObserverScript({
|
|
|
132
134
|
continue;
|
|
133
135
|
}
|
|
134
136
|
const actionDescriptors = Object.getOwnPropertyDescriptors(definition);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
};
|
|
146
|
-
}
|
|
137
|
+
const executeDescriptor = actionDescriptors.execute;
|
|
138
|
+
if (
|
|
139
|
+
executeDescriptor &&
|
|
140
|
+
"value" in executeDescriptor &&
|
|
141
|
+
typeof executeDescriptor.value === "function"
|
|
142
|
+
) {
|
|
143
|
+
actionDescriptors.execute = {
|
|
144
|
+
...executeDescriptor,
|
|
145
|
+
value: wrapExecute(name, executeDescriptor.value),
|
|
146
|
+
};
|
|
147
147
|
}
|
|
148
148
|
if (!descriptor || !("value" in descriptor)) continue;
|
|
149
149
|
const observedDefinition = Object.create(
|
|
@@ -207,6 +207,37 @@ export function buildActionObserverScript({
|
|
|
207
207
|
};
|
|
208
208
|
ensureActionCapture();
|
|
209
209
|
|
|
210
|
+
// The SDK emits "rendered" just before it calls the card, and "failed" if that
|
|
211
|
+
// call throws, in the same task. Still pending on the next task means it drew.
|
|
212
|
+
const pendingRenders = new Map();
|
|
213
|
+
const recordRender = (event) => {
|
|
214
|
+
const actionName = event?.actionKey;
|
|
215
|
+
if (!state.expectedActionNames.includes(actionName)) return;
|
|
216
|
+
const phase = "render";
|
|
217
|
+
const source = "local-handler";
|
|
218
|
+
const durationMs = () =>
|
|
219
|
+
Math.max(0, Math.round(now() - pendingRenders.get(actionName)));
|
|
220
|
+
if (event.status === "rendered") {
|
|
221
|
+
pendingRenders.set(actionName, now());
|
|
222
|
+
record({ source, actionName, phase, status: "started" });
|
|
223
|
+
setTimeout(() => {
|
|
224
|
+
if (!pendingRenders.has(actionName)) return;
|
|
225
|
+
record({ source, actionName, phase, status: "succeeded", durationMs: durationMs() });
|
|
226
|
+
pendingRenders.delete(actionName);
|
|
227
|
+
}, 0);
|
|
228
|
+
} else if (event.status === "failed" && pendingRenders.has(actionName)) {
|
|
229
|
+
record({
|
|
230
|
+
source,
|
|
231
|
+
actionName,
|
|
232
|
+
phase,
|
|
233
|
+
status: "failed",
|
|
234
|
+
durationMs: durationMs(),
|
|
235
|
+
errorName: errorName(event.payload),
|
|
236
|
+
});
|
|
237
|
+
pendingRenders.delete(actionName);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
210
241
|
const observedAgents = new WeakSet();
|
|
211
242
|
const subscribe = () => {
|
|
212
243
|
ensureActionCapture();
|
|
@@ -237,6 +268,7 @@ export function buildActionObserverScript({
|
|
|
237
268
|
? event.status.slice(0, 100)
|
|
238
269
|
: "unknown",
|
|
239
270
|
});
|
|
271
|
+
recordRender(event);
|
|
240
272
|
};
|
|
241
273
|
agent.on?.("action.callback", recordSdkAction);
|
|
242
274
|
agent.on?.("remoteActions.loaded", ensureActionCapture);
|
package/src/lint/ast.mjs
CHANGED
|
@@ -71,7 +71,7 @@ function initializerOf(node) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Call `visit(fn, name)` for execute / render / callback function-likes.
|
|
74
|
+
* Call `visit(fn, name)` for execute / render / renderComponent / callback function-likes.
|
|
75
75
|
*/
|
|
76
76
|
export function forEachNamedFunction(sourceFile, names, visit) {
|
|
77
77
|
const wanted = names instanceof Set ? names : new Set(names);
|
|
@@ -102,6 +102,101 @@ export function callbackParamName(fn) {
|
|
|
102
102
|
return "callback";
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
const REPORT_KEYS = ["callback", "cancel", "readyState"];
|
|
106
|
+
|
|
107
|
+
/** Every default inside a nested pattern. */
|
|
108
|
+
function collectBindingDefaults(pattern, defaults) {
|
|
109
|
+
for (const element of pattern.elements) {
|
|
110
|
+
if (!ts.isBindingElement(element)) continue;
|
|
111
|
+
if (element.initializer) defaults.push(element.initializer);
|
|
112
|
+
if (!ts.isIdentifier(element.name)) collectBindingDefaults(element.name, defaults);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readComponentPattern(pattern, read) {
|
|
117
|
+
for (const element of pattern.elements) {
|
|
118
|
+
const keyName = element.propertyName;
|
|
119
|
+
if (element.dotDotDotToken || (keyName && !ts.isIdentifier(keyName))) {
|
|
120
|
+
read.unknown = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const key = (keyName || element.name).getText();
|
|
124
|
+
const reports = REPORT_KEYS.includes(key);
|
|
125
|
+
if (element.initializer) read.defaults.push(element.initializer);
|
|
126
|
+
if (ts.isIdentifier(element.name)) {
|
|
127
|
+
if (reports) read.locals.set(element.name.text, key);
|
|
128
|
+
} else if (reports) {
|
|
129
|
+
read.unknown = true;
|
|
130
|
+
} else {
|
|
131
|
+
collectBindingDefaults(element.name, read.defaults);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How a `renderComponent` reads its one object, the way the SDK reads it.
|
|
138
|
+
* `locals` maps the name each report function goes by to its key:
|
|
139
|
+
* `{ callback: done }` gives done -> callback, from the parameter or from
|
|
140
|
+
* `const { callback } = ctx`. `context` is the parameter's name when it is not
|
|
141
|
+
* destructured. `defaults` are the pattern's defaults, which run like the body.
|
|
142
|
+
* `unknown` is what the SDK cannot read either: a `...rest` at the top of the
|
|
143
|
+
* pattern, a report key taken apart, or a key that is not a plain name. Nested
|
|
144
|
+
* patterns under any other key are read.
|
|
145
|
+
*/
|
|
146
|
+
export function readComponentParam(fn) {
|
|
147
|
+
const params = (fn.parameters || []).filter((param) => !(ts.isIdentifier(param.name) && param.name.text === "this"));
|
|
148
|
+
const first = params[0];
|
|
149
|
+
const read = { locals: new Map(), context: null, defaults: [], unknown: false };
|
|
150
|
+
if (!first) return read;
|
|
151
|
+
if (first.dotDotDotToken) return { ...read, unknown: true };
|
|
152
|
+
if (first.initializer) read.defaults.push(first.initializer);
|
|
153
|
+
if (ts.isIdentifier(first.name)) {
|
|
154
|
+
read.context = first.name.text;
|
|
155
|
+
const walk = (node) => {
|
|
156
|
+
if (
|
|
157
|
+
ts.isVariableDeclaration(node) &&
|
|
158
|
+
ts.isObjectBindingPattern(node.name) &&
|
|
159
|
+
node.initializer &&
|
|
160
|
+
ts.isIdentifier(node.initializer) &&
|
|
161
|
+
node.initializer.text === read.context
|
|
162
|
+
) {
|
|
163
|
+
readComponentPattern(node.name, read);
|
|
164
|
+
}
|
|
165
|
+
ts.forEachChild(node, walk);
|
|
166
|
+
};
|
|
167
|
+
if (fn.body) walk(fn.body);
|
|
168
|
+
return read;
|
|
169
|
+
}
|
|
170
|
+
if (!ts.isObjectBindingPattern(first.name)) return { ...read, unknown: true };
|
|
171
|
+
readComponentPattern(first.name, read);
|
|
172
|
+
return read;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Call `visit(call, key)` for each call a `renderComponent` makes to a report
|
|
177
|
+
* function: `callback(x)` from the pattern, or `ctx.callback(x)`.
|
|
178
|
+
*/
|
|
179
|
+
export function forEachComponentReport(fn, visit) {
|
|
180
|
+
const { locals, context } = readComponentParam(fn);
|
|
181
|
+
function walk(node) {
|
|
182
|
+
if (ts.isCallExpression(node)) {
|
|
183
|
+
const callee = node.expression;
|
|
184
|
+
if (ts.isIdentifier(callee) && locals.has(callee.text)) visit(node, locals.get(callee.text));
|
|
185
|
+
if (
|
|
186
|
+
context &&
|
|
187
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
188
|
+
ts.isIdentifier(callee.expression) &&
|
|
189
|
+
callee.expression.text === context &&
|
|
190
|
+
REPORT_KEYS.includes(callee.name.text)
|
|
191
|
+
) {
|
|
192
|
+
visit(node, callee.name.text);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
ts.forEachChild(node, walk);
|
|
196
|
+
}
|
|
197
|
+
if (fn.body) walk(fn.body);
|
|
198
|
+
}
|
|
199
|
+
|
|
105
200
|
export function collectConstBindings(scopeNode) {
|
|
106
201
|
const map = new Map();
|
|
107
202
|
|
package/src/lint/rules.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
collectModuleBindings,
|
|
6
6
|
finding,
|
|
7
7
|
forEachCallNamed,
|
|
8
|
+
forEachComponentReport,
|
|
8
9
|
forEachNamedFunction,
|
|
9
10
|
forEachReturn,
|
|
10
11
|
forEachStringLiteral,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
resolveNode,
|
|
19
20
|
shouldSkipActionFile,
|
|
20
21
|
stringText,
|
|
22
|
+
readComponentParam,
|
|
21
23
|
} from "./ast.mjs";
|
|
22
24
|
import { ts } from "./ts.mjs";
|
|
23
25
|
|
|
@@ -113,9 +115,17 @@ function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, or
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
|
|
116
|
-
forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn, name) => {
|
|
118
|
+
forEachNamedFunction(sourceFile, ["execute", "render", "renderComponent", "callback"], (fn, name) => {
|
|
117
119
|
const bindings = mergeBindings(fn, sourceFile);
|
|
118
120
|
|
|
121
|
+
if (name === "renderComponent") {
|
|
122
|
+
forEachComponentReport(fn, (call, key) => {
|
|
123
|
+
const arg = call.arguments[0];
|
|
124
|
+
if (!arg || key === "cancel") return;
|
|
125
|
+
inspectReturnedObject(resolveNode(arg, bindings), bindings, sourceFile, relative, findings, key);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
if (name === "execute" || name === "callback") {
|
|
120
130
|
forEachReturn(fn, (expression) => {
|
|
121
131
|
inspectReturnedObject(
|
|
@@ -235,7 +245,7 @@ export function ruleNoEmoji(sourceFile, relative, findings, { wholeFile = false
|
|
|
235
245
|
return;
|
|
236
246
|
}
|
|
237
247
|
|
|
238
|
-
forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn) => {
|
|
248
|
+
forEachNamedFunction(sourceFile, ["execute", "render", "renderComponent", "callback"], (fn) => {
|
|
239
249
|
const bindings = mergeBindings(fn, sourceFile);
|
|
240
250
|
forEachStringLiteral(
|
|
241
251
|
fn,
|
|
@@ -479,7 +489,7 @@ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, pro
|
|
|
479
489
|
// `this` parameter is erased, so it takes no position.
|
|
480
490
|
const CARD_PARAMS = ["callback", "cancel", "readyState"];
|
|
481
491
|
|
|
482
|
-
function mentions(fn, test) {
|
|
492
|
+
function mentions(fn, test, { also = [], bindings = true } = {}) {
|
|
483
493
|
let found = false;
|
|
484
494
|
function walk(node) {
|
|
485
495
|
if (found) return;
|
|
@@ -495,6 +505,7 @@ function mentions(fn, test) {
|
|
|
495
505
|
ts.isSetAccessorDeclaration(parent)) &&
|
|
496
506
|
parent.name === node;
|
|
497
507
|
const isKey = parent && ts.isBindingElement(parent) && parent.propertyName === node;
|
|
508
|
+
const isBinding = !bindings && parent && ts.isBindingElement(parent) && parent.name === node;
|
|
498
509
|
let inType = false;
|
|
499
510
|
for (let up = parent; up && up !== fn; up = up.parent) {
|
|
500
511
|
if (ts.isTypeNode(up) && !ts.isExpressionWithTypeArguments(up)) {
|
|
@@ -502,14 +513,14 @@ function mentions(fn, test) {
|
|
|
502
513
|
break;
|
|
503
514
|
}
|
|
504
515
|
}
|
|
505
|
-
if (!isName && !isKey && !inType) {
|
|
516
|
+
if (!isName && !isKey && !isBinding && !inType) {
|
|
506
517
|
found = true;
|
|
507
518
|
return;
|
|
508
519
|
}
|
|
509
520
|
}
|
|
510
521
|
ts.forEachChild(node, walk);
|
|
511
522
|
}
|
|
512
|
-
|
|
523
|
+
for (const root of [fn.body, ...also]) if (root) walk(root);
|
|
513
524
|
return found;
|
|
514
525
|
}
|
|
515
526
|
|
|
@@ -525,6 +536,44 @@ function readRender(fn) {
|
|
|
525
536
|
return { waits, declaredUnused: !waits && names.length > 0 ? names : [] };
|
|
526
537
|
}
|
|
527
538
|
|
|
539
|
+
// A renderComponent takes one object, and only the keys it uses tell the two
|
|
540
|
+
// kinds of card apart. What the SDK can't read (`...rest`, `arguments`, the
|
|
541
|
+
// object passed on or aliased) is settled by `waitsForUser`, which defaults to
|
|
542
|
+
// waiting: with no flag, a view card written that way never releases the agent.
|
|
543
|
+
function readComponent(fn, members) {
|
|
544
|
+
const { locals, context, defaults, unknown: unreadable } = readComponentParam(fn);
|
|
545
|
+
let unknown = unreadable || mentions(fn, (name) => name === "arguments", { also: defaults });
|
|
546
|
+
let waits = false;
|
|
547
|
+
if (context) {
|
|
548
|
+
const walk = (node) => {
|
|
549
|
+
if (ts.isIdentifier(node) && node.text === context) {
|
|
550
|
+
const parent = node.parent;
|
|
551
|
+
const destructured =
|
|
552
|
+
ts.isVariableDeclaration(parent) && parent.initializer === node && ts.isObjectBindingPattern(parent.name);
|
|
553
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
|
|
554
|
+
if (CARD_PARAMS.includes(parent.name.text)) waits = true;
|
|
555
|
+
} else if (!destructured && !(ts.isPropertyAccessExpression(parent) && parent.name === node)) {
|
|
556
|
+
unknown = true;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
ts.forEachChild(node, walk);
|
|
560
|
+
};
|
|
561
|
+
if (fn.body) walk(fn.body);
|
|
562
|
+
}
|
|
563
|
+
const names = [...locals.keys()];
|
|
564
|
+
if (names.some((name) => mentions(fn, (text) => text === name, { also: defaults, bindings: false }))) waits = true;
|
|
565
|
+
if (waits) return { waits: true, declaredUnused: [] };
|
|
566
|
+
if (unknown) {
|
|
567
|
+
const flag = members.find((member) => propertyName(member) === "waitsForUser");
|
|
568
|
+
return {
|
|
569
|
+
waits: !(flag && ts.isPropertyAssignment(flag) && flag.initializer.kind === ts.SyntaxKind.FalseKeyword),
|
|
570
|
+
declaredUnused: [],
|
|
571
|
+
unsettled: !flag,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return { waits: false, declaredUnused: names.map((name) => locals.get(name)) };
|
|
575
|
+
}
|
|
576
|
+
|
|
528
577
|
// The function `render` names: inline, a method, or - for `render` and
|
|
529
578
|
// `render: renderCard` - a `function` or `const` of that name in the same file.
|
|
530
579
|
function declaredFunction(sourceFile, name) {
|
|
@@ -567,14 +616,19 @@ export function ruleCardContract(sourceFile, relative, findings) {
|
|
|
567
616
|
function walk(node) {
|
|
568
617
|
if (ts.isObjectLiteralExpression(node)) {
|
|
569
618
|
const members = node.properties;
|
|
570
|
-
const
|
|
571
|
-
(
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
619
|
+
const named = (name) =>
|
|
620
|
+
members.find(
|
|
621
|
+
(member) =>
|
|
622
|
+
propertyName(member) === name &&
|
|
623
|
+
(ts.isPropertyAssignment(member) || ts.isMethodDeclaration(member) || ts.isShorthandPropertyAssignment(member)),
|
|
624
|
+
);
|
|
625
|
+
// If an action has both, the SDK draws renderComponent.
|
|
626
|
+
const component = named("renderComponent");
|
|
627
|
+
const render = component || named("render");
|
|
628
|
+
const drawn = component ? "renderComponent" : "render";
|
|
575
629
|
const fn = render ? renderFunction(sourceFile, render) : null;
|
|
576
630
|
if (fn) {
|
|
577
|
-
const { waits, declaredUnused } = readRender(fn);
|
|
631
|
+
const { waits, declaredUnused, unsettled } = component ? readComponent(fn, members) : readRender(fn);
|
|
578
632
|
const flag = members.find((member) => propertyName(member) === "awaitUserInput");
|
|
579
633
|
const flagTrue = flag && ts.isPropertyAssignment(flag) && flag.initializer.kind === ts.SyntaxKind.TrueKeyword;
|
|
580
634
|
if (flagTrue && !waits) {
|
|
@@ -586,7 +640,7 @@ export function ruleCardContract(sourceFile, relative, findings) {
|
|
|
586
640
|
node: flag.name,
|
|
587
641
|
file: relative,
|
|
588
642
|
message:
|
|
589
|
-
|
|
643
|
+
`A view card (${drawn} uses none of callback, cancel or readyState) sets awaitUserInput: true. The chat panel ignores it, but in headless mode the agent then waits for a callback that never comes.`,
|
|
590
644
|
fix: "Remove awaitUserInput. A view card is reported to the agent as shown, with what execute returned.",
|
|
591
645
|
}),
|
|
592
646
|
);
|
|
@@ -600,7 +654,7 @@ export function ruleCardContract(sourceFile, relative, findings) {
|
|
|
600
654
|
node: flag.name,
|
|
601
655
|
file: relative,
|
|
602
656
|
message:
|
|
603
|
-
|
|
657
|
+
`A view card does not need awaitUserInput: ${drawn} uses none of callback, cancel or readyState, so the agent is told the card was shown, with what execute returned.`,
|
|
604
658
|
fix: "Remove awaitUserInput.",
|
|
605
659
|
}),
|
|
606
660
|
);
|
|
@@ -614,11 +668,25 @@ export function ruleCardContract(sourceFile, relative, findings) {
|
|
|
614
668
|
node: flag.name,
|
|
615
669
|
file: relative,
|
|
616
670
|
message:
|
|
617
|
-
|
|
671
|
+
`An input card does not need awaitUserInput: ${drawn} uses callback, cancel or readyState, and that is what makes the agent wait.`,
|
|
618
672
|
fix: 'Remove awaitUserInput. Then check that every path calls callback or cancel - choosing, "none of these", closing the card, failing. When the conversation should go on while the user decides, call readyState(value) once the card is drawn.',
|
|
619
673
|
}),
|
|
620
674
|
);
|
|
621
675
|
}
|
|
676
|
+
if (unsettled && !flag) {
|
|
677
|
+
findings.push(
|
|
678
|
+
finding({
|
|
679
|
+
rule: "card-contract",
|
|
680
|
+
severity: "warning",
|
|
681
|
+
sourceFile,
|
|
682
|
+
node: render.name,
|
|
683
|
+
file: relative,
|
|
684
|
+
message:
|
|
685
|
+
"The SDK can't read this renderComponent (the object passed on or aliased, ...rest, arguments), so the agent waits until it calls readyState, callback or cancel. A view card that calls none of them never gets a reply, until the user presses stop.",
|
|
686
|
+
fix: "Destructure the keys in the parameter: renderComponent({ data, host, header, readyState }). A view card calls readyState once it is drawn; an input card calls callback or cancel.",
|
|
687
|
+
}),
|
|
688
|
+
);
|
|
689
|
+
}
|
|
622
690
|
if (declaredUnused.length > 0) {
|
|
623
691
|
const list =
|
|
624
692
|
declaredUnused.length === 1
|
|
@@ -632,8 +700,8 @@ export function ruleCardContract(sourceFile, relative, findings) {
|
|
|
632
700
|
sourceFile,
|
|
633
701
|
node: render.name,
|
|
634
702
|
file: relative,
|
|
635
|
-
message:
|
|
636
|
-
fix: `For a view card, remove ${list} from
|
|
703
|
+
message: `${drawn} declares ${list} but ${unused} - the SDK treats the card as a view card: the agent is told it was shown, with what execute returned.`,
|
|
704
|
+
fix: `For a view card, remove ${list} from ${drawn}. For an input card, call callback or cancel on every path, or readyState(value) once the card is drawn.`,
|
|
637
705
|
}),
|
|
638
706
|
);
|
|
639
707
|
}
|
|
@@ -857,6 +925,115 @@ export function bundleTurnsOnTestMode(js) {
|
|
|
857
925
|
return /setTestMode/.test(js.replace(/\??\.setTestMode(\?\.)?\(\s*(!1|false)\s*\)/g, ""));
|
|
858
926
|
}
|
|
859
927
|
|
|
928
|
+
/*
|
|
929
|
+
no-native-lookalike: an action that copies something every agent already
|
|
930
|
+
has (CLAUDE.md, "Built into every agent"). A warning, not an error: a name
|
|
931
|
+
is only a hint, and an action can have a reason the built-in doesn't cover.
|
|
932
|
+
Two signals: the action's key (agent/actions/<key>.ts or <key>/index.ts), and
|
|
933
|
+
code that reads the whole page or the microphone.
|
|
934
|
+
*/
|
|
935
|
+
const NATIVE_LOOKALIKE_NAMES = [
|
|
936
|
+
{
|
|
937
|
+
native: "navigation",
|
|
938
|
+
pattern: /(^|_)(navigat[a-z]*|go_to|goto|open_(page|screen|view|url)|redirect[a-z]*)(_|$)|(^|_)urls?$|(^|_)build_[a-z_]*url/,
|
|
939
|
+
use: "a navigation route for each page (create_navigation_route); the agent opens it with the built-in navigate",
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
native: "share current tab / share screen",
|
|
943
|
+
pattern: /(^|_)(current|active|visible)_(page|screen|view|tab)(_|$)|(^|_)(page|screen|view|tab)_(content|context|html|text|snapshot|state)(_|$)|(^|_)(read_page|screenshot|capture_(page|screen))(_|$)/,
|
|
944
|
+
use: "Share current tab (agent settings → Customize → Data Access & Handoff), or shareState() for the app's own state",
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
native: "knowledge search",
|
|
948
|
+
pattern: /(^|_)search_(docs|documentation|knowledge|help|articles|faq)(_|$)/,
|
|
949
|
+
use: "a knowledge source or an internal article",
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
native: "voice",
|
|
953
|
+
pattern: /(^|_)(speech|voice|dictat[a-z]*|transcri[a-z]*|text_to_speech|tts|stt)(_|$)/,
|
|
954
|
+
use: "voice chat and dictation in agent settings",
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
native: "ask_user_question",
|
|
958
|
+
pattern: /^(ask_user(_input)?|ask_question|ask_the_user|clarify[a-z_]*|choose_option[s]?)$/,
|
|
959
|
+
use: "the built-in ask_user_question (to restyle or replace it, see CLAUDE.md)",
|
|
960
|
+
},
|
|
961
|
+
];
|
|
962
|
+
|
|
963
|
+
const PAGE_TEXT_PROPS = new Set(["innerText", "innerHTML", "outerHTML", "textContent"]);
|
|
964
|
+
const VOICE_NAMES = new Set(["getUserMedia", "SpeechRecognition", "webkitSpeechRecognition", "speechSynthesis", "SpeechSynthesisUtterance"]);
|
|
965
|
+
|
|
966
|
+
export function actionKeyOf(filePath) {
|
|
967
|
+
const parts = filePath.split(/[\\/]/);
|
|
968
|
+
const i = parts.lastIndexOf("actions");
|
|
969
|
+
if (i < 0) return null;
|
|
970
|
+
const rest = parts.slice(i + 1);
|
|
971
|
+
let key = null;
|
|
972
|
+
if (rest.length === 1) key = rest[0].replace(/\.(ts|tsx|js|mjs)$/, "");
|
|
973
|
+
else if (rest.length === 2 && /^index\.(ts|tsx|js|mjs)$/.test(rest[1])) key = rest[0];
|
|
974
|
+
if (!key || key === "index") return null;
|
|
975
|
+
return key.replace(/-/g, "_").toLowerCase();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function ruleNoNativeLookalike(sourceFile, relative, findings, filePath) {
|
|
979
|
+
const key = actionKeyOf(filePath || relative);
|
|
980
|
+
if (key && key !== "ask_user_question") {
|
|
981
|
+
for (const item of NATIVE_LOOKALIKE_NAMES) {
|
|
982
|
+
if (!item.pattern.test(key)) continue;
|
|
983
|
+
findings.push(
|
|
984
|
+
finding({
|
|
985
|
+
rule: "no-native-lookalike",
|
|
986
|
+
severity: "warning",
|
|
987
|
+
sourceFile,
|
|
988
|
+
node: sourceFile,
|
|
989
|
+
file: relative,
|
|
990
|
+
message: `"${key}" looks like the built-in ${item.native}, which every agent already has.`,
|
|
991
|
+
fix: `Use ${item.use}. If this action does something the built-in can't, keep it and tell the human why.`,
|
|
992
|
+
}),
|
|
993
|
+
);
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const reported = new Set();
|
|
999
|
+
const report = (node, native, use) => {
|
|
1000
|
+
if (reported.has(native)) return;
|
|
1001
|
+
reported.add(native);
|
|
1002
|
+
findings.push(
|
|
1003
|
+
finding({
|
|
1004
|
+
rule: "no-native-lookalike",
|
|
1005
|
+
severity: "warning",
|
|
1006
|
+
sourceFile,
|
|
1007
|
+
node,
|
|
1008
|
+
file: relative,
|
|
1009
|
+
message: native === "voice"
|
|
1010
|
+
? "Microphone or speech code: voice chat and dictation are built into every agent."
|
|
1011
|
+
: "Reads the whole page's text or HTML: sharing the current tab is built into every agent.",
|
|
1012
|
+
fix: `Use ${use}.`,
|
|
1013
|
+
}),
|
|
1014
|
+
);
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
function walk(node) {
|
|
1018
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
1019
|
+
const name = node.name.text;
|
|
1020
|
+
if (PAGE_TEXT_PROPS.has(name)) {
|
|
1021
|
+
const target = node.expression.getText(sourceFile).replace(/\s+/g, "");
|
|
1022
|
+
if (/^(window\.)?document\.(body|documentElement)$/.test(target)) {
|
|
1023
|
+
report(node, "page", NATIVE_LOOKALIKE_NAMES[1].use);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (VOICE_NAMES.has(name)) report(node, "voice", NATIVE_LOOKALIKE_NAMES[3].use);
|
|
1027
|
+
} else if (ts.isIdentifier(node) && VOICE_NAMES.has(node.text) && !ts.isPropertyAccessExpression(node.parent)) {
|
|
1028
|
+
report(node, "voice", NATIVE_LOOKALIKE_NAMES[3].use);
|
|
1029
|
+
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "html2canvas") {
|
|
1030
|
+
report(node, "page", NATIVE_LOOKALIKE_NAMES[1].use);
|
|
1031
|
+
}
|
|
1032
|
+
ts.forEachChild(node, walk);
|
|
1033
|
+
}
|
|
1034
|
+
walk(sourceFile);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
860
1037
|
export function lintActionFile(filePath, relative, findings) {
|
|
861
1038
|
if (shouldSkipActionFile(filePath)) return;
|
|
862
1039
|
const sourceFile = parseFile(filePath);
|
|
@@ -864,6 +1041,7 @@ export function lintActionFile(filePath, relative, findings) {
|
|
|
864
1041
|
ruleNoRunTaskPrompt(sourceFile, relative, findings);
|
|
865
1042
|
ruleNoEmoji(sourceFile, relative, findings);
|
|
866
1043
|
ruleCardContract(sourceFile, relative, findings);
|
|
1044
|
+
ruleNoNativeLookalike(sourceFile, relative, findings, filePath);
|
|
867
1045
|
}
|
|
868
1046
|
|
|
869
1047
|
export function lintViewsFile(filePath, relative, findings) {
|
package/src/lint/safety.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
|
-
import { finding, forEachNamedFunction, forEachReturn, isFunctionLike, parseFile, resolveModulePath, stringText } from "./ast.mjs";
|
|
16
|
+
import { finding, forEachComponentReport, forEachNamedFunction, forEachReturn, isFunctionLike, parseFile, resolveModulePath, stringText } from "./ast.mjs";
|
|
17
17
|
import { ts } from "./ts.mjs";
|
|
18
18
|
import { hostMatchesAny, siteOf } from "../observe-core.mjs";
|
|
19
19
|
import { addressNamesLabel, customerLabel, suggestedApiBase } from "../customer-subdomain.mjs";
|
|
@@ -518,7 +518,7 @@ export function ruleTokenStaysHome(sourceFile, relative, findings, facts, scopes
|
|
|
518
518
|
findings.push(finding({ rule: "token-stays-home", severity: "error", sourceFile, node, file: relative, message, fix }));
|
|
519
519
|
|
|
520
520
|
// Returned to the agent: what execute and a card's callback return, and what
|
|
521
|
-
// render hands to callback or readyState.
|
|
521
|
+
// render or renderComponent hands to callback or readyState.
|
|
522
522
|
forEachNamedFunction(sourceFile, ["execute", "callback"], (fn, name) => {
|
|
523
523
|
forEachReturn(fn, (expression) => {
|
|
524
524
|
if (flow.carries(expression)) {
|
|
@@ -539,6 +539,14 @@ export function ruleTokenStaysHome(sourceFile, relative, findings, facts, scopes
|
|
|
539
539
|
};
|
|
540
540
|
if (fn.body) walk(fn.body);
|
|
541
541
|
});
|
|
542
|
+
forEachNamedFunction(sourceFile, ["renderComponent"], (fn) => {
|
|
543
|
+
forEachComponentReport(fn, (call, key) => {
|
|
544
|
+
if (key === "cancel") return;
|
|
545
|
+
if (call.arguments.some((arg) => flow.carries(arg))) {
|
|
546
|
+
report(call, `The card hands the user's sign-in token to ${key}, which reaches the agent and the model.`, TOKEN_FIXES.agent);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
542
550
|
|
|
543
551
|
const walk = (node) => {
|
|
544
552
|
if (ts.isCallExpression(node)) {
|