From 32f2f9e52b3f2917b2ac8a784072ff5f498ca01b Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 8 Nov 2026 23:14:21 -0800 Subject: [PATCH] Tell a Bot where each app stands the way its page does On a used personal install with Gmail and 1Password turned on and neither connected, a Bot said Gmail was ready to read and send mail, that Notion was connected for the deployment and an administrator had to grant it, and that Parallel could not search. The apps line said "Gmail (needs connecting)", but Gmail's tools were still listed a line later as systems the Bot reaches "as the person asking, with their own access", and the Bot believed that. - An app that isn't ready for this person is no longer listed as a system the Bot reaches; its standing is said once, in the apps line, from appAccessForBot, the same answer the Bot's page draws. - "This deployment also connects to" lists only apps whose tools were listed, never an app the apps line already names, and is not said in the personal edition, where every listed tool goes to every Bot. - Tools an endpoint wasn't offered on a turn are counted as held and offered on a turn that needs them, instead of reading as a missing grant. - The personal edition's instructions, the no-search lines and the show_app_access description name no administrator. --- CHANGELOG.md | 4 + server/src/copilot.ts | 24 +++- server/src/index.ts | 6 +- server/src/plugins/app-access-tool.ts | 5 +- server/src/plugins/tools.ts | 128 +++++++++++++++--- server/tests/copilot.test.ts | 180 +++++++++++++++++++++++++- shared/bot-prompt.ts | 16 ++- 7 files changed, 333 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86424f0a73..ce9f2be387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,10 @@ deployment: tell it to look an app's tools up rather than say it has no access. Bots are also told to say what they did in plain words ("I checked your Gmail profile") and never to name a tool, such as `GMAIL_GET_PROFILE`. +- **A Bot says where each app stands the way its page does.** An app that's on but that you haven't + connected is "needs connecting", never ready. An app nobody connected isn't called connected, + each app gets one reason, and a tool a Bot holds but wasn't offered on that turn isn't called + missing. In the personal edition a Bot never mentions an admin. - **Bots answer in Markdown by default.** Ordinary questions, explanations, summaries and short lists come back as a reply. A notice card is kept for an alert that needs you to act, or for when you ask for one, and a chart, table or card is drawn when it makes the answer easier to use. Bots diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 2c9a31cab3..4ea120d6ae 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -96,6 +96,7 @@ import type { AgentFetch, StallGuard } from "./channels/stall-guard"; import type { DeploymentConfig } from "./config"; import { observeModelConnection } from "./desktop-connection-failure"; import { desktopTelemetryProperties } from "./desktop-telemetry"; +import { currentEdition } from "./edition"; import { observeIntelligenceAuthentication } from "./intelligence-client"; import { capturePassOrigin } from "./intelligence-gateway-proxy"; import { @@ -778,7 +779,12 @@ export function builtInAgentConfiguration( ? [grantedToolGuidance(tools, connectedVendors, shown, apps)] : []), ...(computerGuidance - ? [computerGuidance({ search: holdsWebSearch(tools) })] + ? [ + computerGuidance({ + search: holdsWebSearch(tools), + personal: currentEdition() === "personal", + }), + ] : []), ...(learnedSkills?.catalog ? [learnedSkills.catalog] : []), /* @@ -2144,15 +2150,21 @@ function remoteAgentWithStandingRole( * prompt — a page about the browser that mentions connectors nowhere. That is the Bot that browsed * to drive.google.com holding four Drive tools. * - * Built from the tools this run was offered rather than from everything granted, so a narrowed - * run is never told it holds a system it cannot reach on this turn. + * Built from the tools this run was offered, so a narrowed run is never told it can call a tool it + * was not sent. What it holds and was not offered is counted beside its system as offered on a turn + * that needs it: left out, a Bot offered Parallel's feedback tool and not its search told the + * person it could not search and that an administrator could grant it. */ - const holdingsMessageFor = (offered: GrantedTool[]) => { + const holdingsMessageFor = (offered: GrantedTool[], held: boolean) => { + const sent = new Set(offered.map((tool) => tool.name)); + // A run the runtime makes for itself holds nothing, now or later. See `isAskedRun`. + const notOffered = held ? tools.filter((tool) => !sent.has(tool.name)) : []; const holdings = grantedToolGuidance( - offered, + [...offered, ...notOffered], connectedVendors, undefined, apps, + new Set(notOffered.map((tool) => tool.name)), ); return holdings ? { @@ -2168,7 +2180,7 @@ function remoteAgentWithStandingRole( input: RunAgentInput, next: AbstractAgent, ) => { - const holdingsMessage = holdingsMessageFor(tools); + const holdingsMessage = holdingsMessageFor(tools, holds(input)); /* * Not signed for a run the runtime makes for itself: the statement is what the Bot hands back to * call a tool here, and that run holds none. See `isAskedRun`. diff --git a/server/src/index.ts b/server/src/index.ts index 2e2ce9c792..6cc94fd12e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -272,6 +272,7 @@ import { import { ensureSingleUserIdentity } from "./plugins/single-user-identity"; import { createPluginStore } from "./plugins/store"; import { + connectedSystems, grantedSkills, grantedTools, REFUSAL_MARKER, @@ -1930,11 +1931,14 @@ const signRunForActor = * Google's sign-in page and asked a person to sign in to an account the deployment had already * connected. Naming them lets it say which one it has not been granted instead. * + * Only the ones whose tools were listed: an app whose sign-in never finished is not connected, and + * was being named to every Bot as one an administrator could grant. See `connectedSystems`. + * * Read per request rather than held, because a connector added a minute ago has to count. * Let failures reach buildAgents, which reports the missing guidance once and keeps the run usable. */ const loadVendors = async () => - (await pluginStore.listServers()).map((server) => server.id); + connectedSystems(await pluginStore.listServers()); /* * How a run's tools are narrowed to the ones it is about. diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts index f53c825e7d..8f435b6730 100644 --- a/server/src/plugins/app-access-tool.ts +++ b/server/src/plugins/app-access-tool.ts @@ -60,8 +60,11 @@ export function appAccessTools(options: { { name: SHOW_APP_ACCESS_TOOL, ref: `app-access/${SHOW_APP_ACCESS_TOOL}`, + // No administrator in the personal edition: the card offers the step itself. description: - "Show the person a card saying whether they can use one work app through you, and the one thing that would let them: connecting their own account, or asking an administrator to switch the app on, grant it to you, or connect its shared account. Call it whenever a question needs an app you hold no tools for, instead of only saying it is missing.", + currentEdition() === "personal" + ? "Show the person a card saying whether they can use one work app through you, and the one thing that would let them: connecting it, or setting it up. Call it whenever a question needs an app you hold no tools for or one that needs connecting, instead of only saying it is missing." + : "Show the person a card saying whether they can use one work app through you, and the one thing that would let them: connecting their own account, or asking an administrator to switch the app on, grant it to you, or connect its shared account. Call it whenever a question needs an app you hold no tools for, instead of only saying it is missing.", parameters, async execute(args) { const parsed = parameters.safeParse(args); diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 9e7c1abef1..1b85b13ff1 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -9,6 +9,7 @@ import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { SEARCH_TOOLS, systemOf } from "../agents/tool-search"; import { ApprovalRefusedError } from "../approvals/types"; import type { AuditInitiator } from "../audit"; +import { currentEdition } from "../edition"; import { markUntrusted } from "../untrusted-content"; import { withAccountArgument } from "./account-argument"; import type { AppAccess, AppAccessState } from "./app-access"; @@ -121,6 +122,14 @@ export function grantedToolGuidance( * Absent where nobody asked, which says nothing about apps; empty says the Bot has none. */ apps?: readonly BotApp[], + /** + * Tools this Bot holds that this run was not offered, for an endpoint sent only what a run's + * message needs (`selectTools`, `fitToolLimit`). Counted per system like `waiting`, and never + * listed as callable: there is no search to find them on that path, only a later turn that needs + * them. Without these a Bot offered Parallel's feedback tool and not its search said it could not + * search and that an administrator could grant it. + */ + later: ReadonlySet = new Set(), ): string { /* * THE CARD TOOL IS NOT A SYSTEM. It is offered to every Bot so a missing app can be put in front of @@ -140,10 +149,29 @@ export function grantedToolGuidance( // A system is named by its app's own name where it is known: "Gmail", not `composio-gmail`. const nameOf = new Map(apps?.map((app) => [app.key, app.displayName])); const named = (system: string) => nameOf.get(system) ?? system; + const personal = currentEdition() === "personal"; + + /* + * AN APP THE PERSON CANNOT USE YET IS NOT A SYSTEM THEY REACH. Its tools are still held, and its + * standing is said once, beside its name, in the apps line. + * + * FOUND IN 1.0.0-rc.4. A Bot holding Gmail, which the person had never connected, was told + * "Gmail (needs connecting)" and then, a line later, "You can reach these systems directly, as the + * person asking, with their own access: - Gmail: GMAIL_SEND_EMAIL, ...". It believed the second + * and told the person Gmail was ready to read and send their mail. + */ + const notReady = new Set( + apps?.filter((app) => app.state !== "ready").map((app) => app.key), + ); + const heldSystems = new Set(reaching.map((tool) => systemOf(tool.name))); + const callable = reaching.filter( + (tool) => !notReady.has(systemOf(tool.name)), + ); const bySystem = new Map(); const waitingBySystem = new Map(); - for (const tool of reaching) { + const laterBySystem = new Map(); + for (const tool of callable) { // `mcp__server__tool`, which is the shape the model is offered. const system = systemOf(tool.name); const parts = tool.name.replace(/^mcp__/, "").split("__"); @@ -151,29 +179,45 @@ export function grantedToolGuidance( if (!bySystem.has(system)) bySystem.set(system, []); if (waiting.has(tool.name)) waitingBySystem.set(system, (waitingBySystem.get(system) ?? 0) + 1); + else if (later.has(tool.name)) + laterBySystem.set(system, (laterBySystem.get(system) ?? 0) + 1); else bySystem.get(system)?.push(rest); } - const held = [...bySystem.keys()]; + /* + * ONE REASON PER APP. An app the apps line names already says where it stands; listed here too, + * a Notion that needed connecting was also "connected elsewhere, not granted to me". + */ const missing = connectedButNotHeld.filter( - (system) => !held.includes(system), + (system) => !heldSystems.has(system) && !nameOf.has(system), ); - const research = researchProviders(reaching); + // Only what this run can call: a provider waiting for a later turn is not one it can use now. + const research = researchProviders( + callable.filter((tool) => !later.has(tool.name)), + ); return [ ...appsGuidance(apps, waiting.size > 0, showsCards), - ...(apps !== undefined && reaching.length > 0 ? [""] : []), - ...(reaching.length > 0 + ...(apps !== undefined && callable.length > 0 ? [""] : []), + ...(callable.length > 0 ? [ "You can reach these systems directly, as the person asking, with their own access:", ] : []), ...[...bySystem.entries()].map(([system, names]) => { const more = waitingBySystem.get(system) ?? 0; - if (more === 0) return `- ${named(system)}: ${names.join(", ")}`; - const found = `${more} ${names.length > 0 ? "more " : ""}tools you find with ${SEARCH_TOOLS}`; - return `- ${named(system)}: ${[...names, found].join(", ")}`; + const after = laterBySystem.get(system) ?? 0; + const extra = names.length > 0 ? "more " : ""; + return `- ${named(system)}: ${[ + ...names, + ...(more > 0 + ? [`${more} ${extra}tools you find with ${SEARCH_TOOLS}`] + : []), + ...(after > 0 + ? [`${after} ${extra}tools offered on a turn that needs them`] + : []), + ].join(", ")}`; }), ...(waiting.size > 0 ? [ @@ -183,16 +227,23 @@ export function grantedToolGuidance( "tool missing.", ] : []), - ...(reaching.length > 0 + ...(callable.length > 0 ? [ "Use them for anything about those systems. Do NOT browse to one of their websites instead: your", "browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall", "that connecting an account has already solved.", - "If one of these systems is involved and no tool above covers the part you need, that is a", - "missing grant and not something to work around. Say so plainly, name the capability you would", - "need, and say an administrator can grant it on that connector. Do not reach for the browser, do", - "not ask the person to sign in, and do not ask them to fetch it for you: they already have the", - "access, and the thing that is missing is yours, not theirs.", + ...(laterBySystem.size > 0 + ? [ + "A tool offered on a turn that needs it is one you hold: never say you lack it or that it", + "has to be granted.", + ] + : []), + `If one of these systems is involved and no tool you hold covers the part you need${waiting.size > 0 ? ` (search with ${SEARCH_TOOLS} first)` : ""},`, + personal + ? "say so plainly and name the capability you would need." + : "that is a missing grant. Say so plainly, name the capability you would need, and say an administrator can grant it on that connector.", + "Do not reach for the browser, do not ask the person to sign in, and do not ask them to fetch it", + "for you.", ] : []), ...researchGuidance(research), @@ -205,9 +256,16 @@ export function grantedToolGuidance( * person asking, and the container's browser is signed in as nobody, so browsing there abandons * the per-person path and lands on a login wall by construction. */ - ...(missing.length > 0 + /* + * NOT IN THE PERSONAL EDITION, where every tool an app lists goes to every Bot: an app a Bot + * holds nothing for there is one that has listed nothing, because the person has not connected + * it. Said to be "connected" with an administrator to ask, a Bot sent the one person to an + * administrator there is none of, about an app nobody had connected. The card line below names + * it instead, with the step the card itself offers. + */ + ...(missing.length > 0 && !personal ? [ - ...(reaching.length > 0 ? [""] : []), + ...(callable.length > 0 ? [""] : []), `This deployment also connects to: ${missing.map(named).join(", ")}. You hold none of their tools.`, ...(showsCards ? [ @@ -232,17 +290,38 @@ export function grantedToolGuidance( */ ...(showsCards ? [ - ...(reaching.length > 0 || missing.length > 0 || apps?.length + ...(callable.length > 0 || + (missing.length > 0 && !personal) || + apps?.length ? [""] : []), `If the person needs a work app that is not named here at all${notNamedExamples(apps)}, call`, `${SHOW_APP_ACCESS_TOOL} with its name before saying you cannot reach it: the card tells them whether it`, - "can be switched on and lets them ask an administrator. Do not browse to its website instead.", + personal + ? "is connected and offers the step that connects it. Do not browse to its website instead." + : "can be switched on and lets them ask an administrator. Do not browse to its website instead.", ] : []), ].join("\n"); } +/** + * The apps this deployment really connects to, by server id, for `grantedToolGuidance`'s + * `connectedButNotHeld`: the ones whose tools were listed. + * + * AN APP WITH NOTHING LISTED IS NOT CONNECTED. Its sign-in never finished (a first-party app with + * only its OAuth client, and a connect error where its tools would be), so there is nothing a grant + * could give. Named as one "this deployment also connects to", with an administrator to ask, it + * read to a Bot as connected for everybody and missing only for itself. + */ +export function connectedSystems( + servers: readonly { id: string; tools: readonly unknown[] }[], +): string[] { + return servers + .filter((server) => server.tools.length > 0) + .map((server) => server.id); +} + /** One app a Bot holds, as it stands for one person. */ export type BotApp = Pick< AppAccess, @@ -262,6 +341,12 @@ const APP_STANDING: Record = { owner_grantable: "not given to you", }; +/** The personal edition has no administrator: the one person connects a shared account too. */ +const PERSONAL_STANDING: Partial> = { + shared_not_connected: "needs connecting", + shared_not_approved: "not approved for you", +}; + /** * Which apps this Bot can use for this person, said by name before anything else about its tools. * @@ -282,10 +367,13 @@ function appsGuidance( if (apps === undefined) return []; if (apps.length === 0) return ["You have no apps turned on for the person you are working with."]; + const personal = currentEdition() === "personal"; const standing = (app: BotApp) => app.state === "ready" && app.account === "deployment" ? "ready" - : APP_STANDING[app.state]; + : personal + ? (PERSONAL_STANDING[app.state] ?? APP_STANDING[app.state]) + : APP_STANDING[app.state]; const unconnected = apps.some( (app) => app.state === "not_connected" || app.state === "reconnect", ); diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index f6161e036f..c03e8f6c7b 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -35,7 +35,8 @@ import { selfAddressGuidance, standingRoleMessage, } from "../src/copilot"; -import { grantedToolGuidance } from "../src/plugins/tools"; +import { setEdition } from "../src/edition"; +import { connectedSystems, grantedToolGuidance } from "../src/plugins/tools"; import { loadTenantPackage } from "../src/tenant-package"; import { UNTRUSTED_GUIDANCE } from "../src/untrusted-content"; import { testEnvironment } from "./support/environment"; @@ -1375,6 +1376,183 @@ describe("which apps a Bot is told it can use", () => { }); }); +/** + * Where each app stands is told from the same truth the Bot's page draws, with one reason per app + * and words true for the edition. + * + * FOUND IN 1.0.0-rc.4, on a personal install with Gmail and 1Password turned on and neither + * connected, and Notion turned on with only its OAuth client and a connect error. Asked which apps + * it could use, the Bot said Gmail was ready to read and send mail (its tools were listed as ones it + * reached "as the person asking"), that Notion was connected for the deployment and an + * administrator had to grant it, and that Parallel could not search, which an administrator could + * grant, while Parallel's search tools waited behind search_tools. + */ +describe("where each app stands, as the Bot's page says it", () => { + const card = { + name: "show_app_access", + ref: "app-access/show_app_access", + description: "Show an app card.", + parameters: z.object({}), + execute: async () => "", + }; + const tool = (server: string, name: string) => ({ + name: `mcp__${server}__${name}`, + ref: `${server}/${name}`, + description: "An app action.", + parameters: z.object({}), + execute: async () => "", + }); + const gmailTools = ["GMAIL_SEND_EMAIL", "GMAIL_FETCH_EMAILS"].map((name) => + tool("composio-gmail", name), + ); + // Parallel's shape: feedback in front, its search and reader waiting behind tool search. + const parallelTools = ["submit_feedback", "web_search", "web_fetch"].map( + (name) => tool("parallel", name), + ); + const deferred = new Set([ + "mcp__parallel__web_search", + "mcp__parallel__web_fetch", + ]); + // The rows as `listServers` returns them on that install. + const servers = [ + { id: "composio-gmail", tools: gmailTools }, + { id: "parallel", tools: parallelTools }, + { id: "1password", tools: [] }, + // First-party, only its OAuth client, a connect error and nothing listed. + { id: "notion", tools: [] }, + ]; + const apps = (gmail: "ready" | "not_connected") => [ + { + key: "1password", + displayName: "1Password", + account: "personal" as const, + state: "not_connected" as const, + }, + { + key: "composio-gmail", + displayName: "Gmail", + account: "personal" as const, + state: gmail, + }, + { + key: "parallel", + displayName: "Parallel", + account: "deployment" as const, + state: "ready" as const, + }, + ]; + const instructionsFor = ( + edition: "team" | "personal", + gmail: "ready" | "not_connected", + ) => { + setEdition(edition); + try { + return String( + ( + builtInAgentConfiguration( + { + id: "personal-assistant", + name: "Personal Assistant", + type: "built_in", + systemPrompt: "Help the person.", + }, + { provider: "openai", defaultModel: "gpt-5.6-terra" }, + "openai-secret", + [card, ...gmailTools, ...parallelTools] as never[], + computerGuidance, + connectedSystems(servers), + null, + undefined, + undefined, + deferred, + new Date("2026-11-08T12:00:00Z"), + undefined, + undefined, + apps(gmail), + ) as { prompt?: unknown } + ).prompt, + ).replace(/\s+/g, " "); + } finally { + setEdition("team"); + } + }; + + test("an app turned on with no connected account needs connecting, and is not listed as reachable", () => { + const prompt = instructionsFor("personal", "not_connected"); + expect(prompt).toContain( + "1Password (needs connecting), Gmail (needs connecting), Parallel (ready)", + ); + expect(prompt).not.toContain("- Gmail:"); + expect(prompt).not.toContain("GMAIL_SEND_EMAIL"); + }); + + test("an app the person connected is connected and reachable", () => { + const prompt = instructionsFor("personal", "ready"); + expect(prompt).toContain("Gmail (connected)"); + expect(prompt).toContain("- Gmail: GMAIL_SEND_EMAIL, GMAIL_FETCH_EMAILS"); + }); + + test("an app that never connected is not described as connected, and no administrator is named", () => { + const prompt = instructionsFor("personal", "not_connected"); + expect(prompt).not.toContain("Notion"); + expect(prompt).not.toContain("also connects to"); + expect(prompt.toLowerCase()).not.toContain("administrator"); + // The team edition does not name it as connected either: nothing of it was ever listed. + expect(instructionsFor("team", "not_connected")).not.toContain("notion"); + }); + + test("tools waiting behind search are held, never a missing grant", () => { + const prompt = instructionsFor("personal", "not_connected"); + expect(prompt).toContain( + "- Parallel: submit_feedback, 2 more tools you find with search_tools", + ); + expect(prompt).not.toContain("missing grant"); + expect(prompt).toContain("web_search finds sources"); + }); + + test("tools an endpoint was not offered this turn are held, never a missing grant", () => { + // The remote path's shape: offered only Parallel's feedback tool on this turn. + setEdition("personal"); + try { + const guidance = grantedToolGuidance( + [card, ...parallelTools] as never[], + connectedSystems(servers), + undefined, + apps("not_connected"), + deferred, + ).replace(/\s+/g, " "); + expect(guidance).toContain( + "- Parallel: submit_feedback, 2 more tools offered on a turn that needs them", + ); + expect(guidance).toContain( + "never say you lack it or that it has to be granted", + ); + expect(guidance.toLowerCase()).not.toContain("administrator"); + expect(guidance).not.toContain("missing grant"); + } finally { + setEdition("team"); + } + }); + + test("one reason per app: an app the apps line names is not also one it holds nothing for", () => { + const guidance = grantedToolGuidance( + [card] as never[], + ["notion"], + undefined, + [ + { + key: "notion", + displayName: "Notion", + account: "personal", + state: "not_connected", + }, + ], + ); + expect(guidance).toContain("Notion (needs connecting)"); + expect(guidance).not.toContain("You hold none of their tools"); + }); +}); + /** * Whether the browser leaves searching to a search tool, decided by what this run holds. * diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index 1fd7cb1f53..e38beb1d4c 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -70,6 +70,14 @@ const WITHOUT_SEARCH_LINES = [ "names but cannot search.", ]; +/** The same, for the personal edition: one person, no administrator, and the card offers the step. */ +const WITHOUT_SEARCH_PERSONAL_LINES = [ + ...WITHOUT_SEARCH_LINES.slice(0, 6), + `If you hold ${SHOW_APP_ACCESS_TOOL}, call it once in the conversation with the name of one search`, + `app (${RESEARCH_APP_NAMES.join(", ")}): the card offers the step that turns it on.`, + ...WITHOUT_SEARCH_LINES.slice(-2), +]; + /** * The order of operations that makes the computer tools usable. * @@ -150,6 +158,8 @@ function asParagraphs(lines: readonly string[]): string { export type ComputerHolding = { /** Whether this run was offered a tool that finds sources, from any research provider. */ search: boolean; + /** The personal edition, which has no administrator to send anybody to. */ + personal?: boolean; }; /** @@ -164,7 +174,11 @@ export function computerGuidance(holding: ComputerHolding): string { return asParagraphs([ ...OPENING_A_PAGE_LINES, "", - ...(holding.search ? WITH_SEARCH_LINES : WITHOUT_SEARCH_LINES), + ...(holding.search + ? WITH_SEARCH_LINES + : holding.personal + ? WITHOUT_SEARCH_PERSONAL_LINES + : WITHOUT_SEARCH_LINES), "", ...ACTING_LINES, ]);