diff --git a/ts/packages/agentRpc/README.md b/ts/packages/agentRpc/README.md index 192f6f2144..47d747bc61 100644 --- a/ts/packages/agentRpc/README.md +++ b/ts/packages/agentRpc/README.md @@ -2,6 +2,15 @@ This package contains code to support remoting TypeAgent SDK interfaces via an abstract RpcChannel interface. +## Initialization option callbacks + +An agent process can serve multiple conversation contexts. Initialization options +containing functions use a shared options RPC channel, with references retained +per initialized context (including initializations still in flight). Closing one +context releases only its options reference; the channel remains available to +other contexts. The last release closes the channel, and subsequent initialization +can create it again. Failed initialization also releases its reference. + ## Connection convention: prefer `rpc.rebind` over recreate **Decision (adopted):** rpc connection clients should be built as a **durable diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index fe5e056fa4..77ae9eb132 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -262,6 +262,8 @@ export async function createAgentRpcClient( const actionContextMap = createObjectMap>(); let optionsRpc: ReturnType | undefined; + const optionsReferences = new Map(); + const contextOptions = new Map(); function getOptionsCallBack(initOptions?: any) { const functions = getOptionsFunctions(initOptions); if (functions === undefined) { @@ -270,11 +272,28 @@ export async function createAgentRpcClient( if (optionsRpc === undefined) { optionsRpc = createOptionsRpc(channelProvider, name, options); } + optionsReferences.set( + initOptions, + (optionsReferences.get(initOptions) ?? 0) + 1, + ); return { id: optionsRpc.optionsMap.getId(initOptions), functions, }; } + function releaseOptions(initOptions: unknown) { + const remaining = (optionsReferences.get(initOptions) ?? 0) - 1; + if (remaining > 0) { + optionsReferences.set(initOptions, remaining); + return; + } + optionsReferences.delete(initOptions); + optionsRpc?.optionsMap.close(initOptions); + if (!optionsReferences.size && optionsRpc) { + channelProvider.deleteChannel(getOptionsChannelName(name, options)); + optionsRpc = undefined; + } + } function withActionContext( actionContext: ActionContext, fn: (contextParams: ActionContextParams) => T, @@ -674,11 +693,30 @@ export async function createAgentRpcClient( } const agent: Required = { - initializeAgentContext(settings?: AppAgentInitSettings) { - return rpc.invoke("initializeAgentContext", { - settings, - optionsCallBack: getOptionsCallBack(settings?.options), - }); + async initializeAgentContext(settings?: AppAgentInitSettings) { + const optionsCallBack = getOptionsCallBack(settings?.options); + try { + const context = await rpc.invoke("initializeAgentContext", { + settings, + optionsCallBack, + }); + if (optionsCallBack) { + if ( + typeof context !== "object" || + context === null || + !("contextId" in context) || + typeof context.contextId !== "number" + ) + throw new Error( + "Agent initialization did not return a context ID", + ); + contextOptions.set(context.contextId, settings?.options); + } + return context; + } catch (error) { + if (optionsCallBack) releaseOptions(settings?.options); + throw error; + } }, updateAgentContext( enable, @@ -915,12 +953,14 @@ export async function createAgentRpcClient( regIdsByContext.delete(contextId); } contextMap.close(context); - // Clean up the options RPC channel once this agent context is closed. - // Options are agent-scoped (created once per initializeAgentContext call) - // so they can be released when the context is torn down. - if (optionsRpc !== undefined) { - channelProvider.deleteChannel(getOptionsChannelName(name, options)); - optionsRpc = undefined; + const agentContextId = context.agentContext?.contextId; + if ( + agentContextId !== undefined && + contextOptions.has(agentContextId) + ) { + const initOptions = contextOptions.get(agentContextId); + contextOptions.delete(agentContextId); + releaseOptions(initOptions); } return result; }; diff --git a/ts/packages/agentRpc/test/actionContext.spec.ts b/ts/packages/agentRpc/test/actionContext.spec.ts index d8264d4f51..b4e50f5516 100644 --- a/ts/packages/agentRpc/test/actionContext.spec.ts +++ b/ts/packages/agentRpc/test/actionContext.spec.ts @@ -14,10 +14,108 @@ import { import { createAgentRpcServer } from "../src/server.js"; import { ChoiceManager, + createActionResultFromTextDisplay, createYesNoChoiceResult, } from "@typeagent/agent-sdk/helpers/action"; describe("agent action context RPC", () => { + test.each([false, true])( + "closing a context preserves surviving option callbacks (shared options: %s)", + async (shared) => { + let clientProvider: ChannelProviderAdapter; + let serverProvider: ChannelProviderAdapter; + clientProvider = createChannelProviderAdapter( + "options-client", + (message, callback) => { + queueMicrotask(() => + serverProvider.notifyMessage( + JSON.parse(JSON.stringify(message)), + ), + ); + callback?.(null); + }, + ); + serverProvider = createChannelProviderAdapter( + "options-server", + (message, callback) => { + queueMicrotask(() => + clientProvider.notifyMessage( + JSON.parse(JSON.stringify(message)), + ), + ); + callback?.(null); + }, + ); + const deletedChannels: string[] = []; + const deleteChannel = clientProvider.deleteChannel; + clientProvider.deleteChannel = (name) => { + deletedChannels.push(name); + deleteChannel(name); + }; + type Options = { read(): string; fail?: boolean }; + const agent: AppAgent = { + initializeAgentContext: async (settings) => { + const options = settings?.options as Options; + if (options.fail) throw new Error("Initialization failed"); + return options; + }, + closeAgentContext: async () => {}, + executeAction: async (_, context: ActionContext) => + createActionResultFromTextDisplay( + await context.sessionContext.agentContext.read(), + ), + }; + const server = createAgentRpcServer( + "retained-options", + agent, + serverProvider, + ); + const client = await createAgentRpcClient( + "retained-options", + clientProvider, + server.agentInterface, + ); + const first = { read: () => "first" }; + const second = shared ? first : { read: () => "second" }; + let sessionId = 0; + const initialize = async (options: Options) => + ({ + sessionContextId: `options-${++sessionId}`, + agentContext: await client.initializeAgentContext!({ + options, + }), + }) as SessionContext; + const read = (sessionContext: SessionContext) => + client.executeAction!( + { schemaName: "test", actionName: "read" }, + { sessionContext } as ActionContext, + ); + try { + const a = await initialize(first); + const b = await initialize(second); + await expect( + initialize({ read: () => "", fail: true }), + ).rejects.toThrow("Initialization failed"); + await client.closeAgentContext!(a); + expect(deletedChannels).toEqual([]); + await expect(read(b)).resolves.toMatchObject({ + displayContent: shared ? "first" : "second", + }); + await client.closeAgentContext!(b); + expect(deletedChannels).toContain("options:retained-options"); + const c = await initialize(first); + await expect(read(c)).resolves.toMatchObject({ + displayContent: "first", + }); + await client.closeAgentContext!(c); + } finally { + server.closeFn(); + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + } + }, + ); + test("invokes nested initialization option methods with their receiver", async () => { let clientProvider: ChannelProviderAdapter; let serverProvider: ChannelProviderAdapter; diff --git a/ts/packages/agents/browser/README.md b/ts/packages/agents/browser/README.md index 2c30958fa5..7e161abbf9 100644 --- a/ts/packages/agents/browser/README.md +++ b/ts/packages/agents/browser/README.md @@ -39,8 +39,15 @@ separately. Updating Puppeteer does not patch system Chrome. ### Scoped graphs and Memory Hub navigation Memory Hub Explore supports entity, topic and maintained hierarchy graphs with -document/event filters, bounded generation-aware paging, coverage diagnostics and -exact evidence drilldown. Graph preparation happens separately from reads; hosts +document/event filters, bounded graph previews and exact evidence drilldown. +The entity and topic pages show filters, graph controls and the canvas without +coverage disclosures, collection/count paging rows, browse lists or routine +maintenance status messages. Loading, empty states and request failures remain +visible. Refresh, Global and Fit are Font Awesome icon buttons with accessible +labels and tooltips, grouped inside the upper-left corner of the graph canvas. +The graph response omits coverage, counts, limits and inventory paging +metadata; topic generation is retained for snapshot-safe evidence drilldown. +Graph preparation happens separately from reads; hosts without the scoped capability report it explicitly. Retained originals and event bodies remain subject to current eligibility and suppression checks. @@ -48,9 +55,90 @@ Views use paginated cards and a separate detail reader, with build receipts in a flyout. Grouped activity, Inbox navigation and responsive detail panels reuse summary inventories rather than fetching every view's full evidence. These navigation features do not depend on checkpoint or integrated-answer APIs. +Inventory pages contain up to 25 summaries per collection, not 25 globally. +Inbox has counted tabs for Question decisions, Views, and Sources & operations. +Its All overview shows at most two compact rows per category, with Questions +first; full explanations and existing actions open in a details side panel. +Review uses a file-lines icon button, and each All-overview section's View all +action uses a list icon button. Both retain descriptive accessible names and +tooltips. Views uses a bell for Latest build, retaining its spinner while a build +is running. +Counts describe loaded attention items/groups, with `+` when more may be +available, rather than claiming a complete cross-collection total. Switching +tabs or opening an explanation does not fetch another evidence inventory. +Activity and Inbox use summary conflict state where available; full conflict +evidence is reserved for selected details or older-service compatibility. + +Explore, Inbox, Sources, Views, Questions and Activity share icon-and-underline +navigation styling. Active filters retain their pressed state; actual tab panels +retain their selected state. Tab strips scroll horizontally rather than wrapping +or widening the page on smaller screens. Views exposes its existing type filter +as tabs above search and build controls, preserving paginated inventory queries, +search text, keyboard focus and horizontal scroll position when it refreshes. +Supported Views tabs are ordered All, Knowledge Wiki, Project Brief, Timeline +and Troubleshooting Guide. Questions has its own left-navigation entry rather +than a duplicate Views filter tab. Collection-maintenance entry points use the +screwdriver-and-wrench icon. + +The Views gallery sends `excludeKinds: ["schemaView"]` to the view-summary query, +including on subsequent pages. The service excludes question views before +pagination and totals; the dedicated Questions workspace and +explicit schema-view reader links remain supported. + +Views and Questions place those tabs directly after the page title. Their lists, +search controls and bottom pagination live in Sources-style surface cards with +server-reported totals in the upper-right corner, not loaded-page counts. +Unavailable question inventories are marked unavailable or partial rather than +displayed as a complete zero. Knowledge preparation remains lazy and is placed +at the bottom of Questions. Explore Overview has no separate refresh button; +scope changes and the main refresh action retain their existing behavior. + +Wiki and Project Brief readers distinguish synopsis and coverage caveats from +their main sections in a blue-tinted right-hand infobox, alongside version, +completeness and revision +metadata. Article text wraps beside the infobox on wide screens; at 800 px and +below the panel stacks above the article. Source disclosures, section navigation +and edit actions remain available. Project Brief goals, owners, status, milestones, +decisions, risks and context stay in the main reading flow; other view types retain +their layout. +Reader Back and Edit controls are icon-only with accessible names and tooltips. +The overflow control opens the shared icon-and-text action menu, including +conditional conflict review and generated-update unlocks. Escape dismisses the +menu; closing an action dialog returns focus to the overflow trigger. + +Views shows a spinner and loading message inside the list card during type +changes, hiding stale results and paging until the new inventory settles. +Rapid type changes queue the latest filter rather than dropping it while another +read is in flight. Failed reads remain explicit and do not display a misleading +empty result. ### Schema-defined questions +The Questions landing page uses two columns of compact cards, stacking into one +column at 800 px and below. A card opens a dedicated question reader at a +corpus/view/slot deep link. Answer candidates and evidence stay in the main content, +while an option B sidecard groups coverage, review/admission state, view version +and evaluated-source metadata. The reader retains guarded edits, explicit errors, +retry, history and exact evidence. Back returns to the previous scoped inventory +with its filters and paging; direct links can load slots outside the current page +without scanning every collection. + +Question route transitions clear old controls before fetching. Reader mutation heads +are refreshed for the exact reader corpus/view independently of the preserved +inventory scope, including direct-link discovery and discovery after an edit. +Discovery refreshes reader metadata before another mutation. Pending operations block navigation, and background polling +does not reload an unsaved reader draft. + +Graph toolbars include an on-demand, keyboard-accessible item picker for the +displayed nodes and relationships. It reuses the bounded graph projection and +generation-qualified evidence flow without fetching a separate inventory or +restoring the removed graph coverage and browse disclosures. + +Inbox detail panels survive unchanged and unrelated count refreshes. Stable item +identities and state fingerprints close a panel only when its selected item +changes or disappears; category navigation and scope resets still close it. +Retained panels restore focus to the replacement Review button after a refresh. + The Memory hub's Questions workspace is a complete projection of versioned schema-view slots, including proposed and rejected slots. The inspector reviews candidates and exact source revisions, records schema-validated user assertions, @@ -72,8 +160,10 @@ Markdown links preserve exact revisions, slots and collection item keys when opened in the browser. Optional service capabilities and operation errors are reported explicitly, not interpreted as empty inventories. Question lists use bounded summary pages; selecting a question separately loads its full evidence. -Questions also mounts knowledge preparation status, discovery policy controls, -bounded failed-discovery retries and captured readiness checkpoints. Existing +Questions loads knowledge preparation status, discovery policy controls, +bounded failed-discovery retries and captured readiness checkpoints. This +progress is fetched only when the Knowledge preparation disclosure is expanded, +not when loading question lists or Inbox attention. Existing corpora remain off until enabled; new corpora default to source observed. Policy admission is not human confirmation, and source indexing alone never implies knowledge readiness. Integrated search answers retain original-source diff --git a/ts/packages/agents/browser/src/agent/memoryHubGraph.mts b/ts/packages/agents/browser/src/agent/memoryHubGraph.mts index 07fa39667e..1bfe4dcdcd 100644 --- a/ts/packages/agents/browser/src/agent/memoryHubGraph.mts +++ b/ts/packages/agents/browser/src/agent/memoryHubGraph.mts @@ -158,10 +158,21 @@ export function createMemoryHubGraphFunctions( Math.floor(maxEdges / selected.length), ), }; + const graph = await timed(query.call(service, input)); result.corpora.push({ corpusId: corpus.corpusId, corpusName: corpus.name, - graph: await timed(query.call(service, input)), + graph: { + generation: graph.generation, + nodes: graph.nodes, + edges: graph.edges, + ...(graph.coverage.topics?.generation + ? { + topicGeneration: + graph.coverage.topics.generation, + } + : {}), + }, }); } catch (error) { const message = diff --git a/ts/packages/agents/browser/src/views/client/library/memoryHub.css b/ts/packages/agents/browser/src/views/client/library/memoryHub.css index e3c7081386..94c6ace133 100644 --- a/ts/packages/agents/browser/src/views/client/library/memoryHub.css +++ b/ts/packages/agents/browser/src/views/client/library/memoryHub.css @@ -19,10 +19,27 @@ body[data-memory-hub] { } .hub-draft-views { - margin-top: 20px; - padding: 16px; - border: 1px solid var(--border); - border-radius: 8px; + min-width: 0; +} +.hub-draft-views > p:empty { + display: none; +} +.hub-draft-views .hub-tabs > button { + margin: 0 0 -1px; +} +.hub-list-card { + min-width: 0; +} +.hub-list-card > .section-heading { + margin-bottom: 12px; +} +.hub-list-card > .section-heading h2 { + margin: 0; +} +.hub-list-total { + color: var(--primary-dark); + font-size: 13px; + white-space: nowrap; } .hub-draft-views fieldset label { display: flex; @@ -70,8 +87,12 @@ body[data-memory-hub] { gap: 8px; margin: 12px 0; } -.hub-view-actions > details { - position: relative; +.hub-view-actions button { + margin: 0; +} +.hub-view-actions .hub-menu { + width: max-content; + max-width: min(320px, calc(100vw - 64px)); } .hub-view-document { max-width: 900px; @@ -104,6 +125,64 @@ body[data-memory-hub] { color: var(--muted); font-size: 13px; } +.hub-view-reading-flow { + display: flow-root; +} +.hub-view-infobox { + box-sizing: border-box; + float: right; + width: 310px; + max-width: 43%; + margin: 0 0 24px 28px; + padding: 20px; + border: 1px solid #cbdce8; + border-top: 4px solid #7599b6; + border-radius: 8px; + background: #edf5fa; + color: #3d5870; + font-size: 12px; + line-height: 1.75; +} +.hub-view-infobox > h3 { + margin: 0 0 6px; + color: #345b78; + font-size: 16px; +} +.hub-view-infobox-label { + margin: 0 0 14px; + color: #526e83; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.hub-view-infobox .hub-view-lead > :first-child { + margin-top: 0; +} +.hub-view-infobox .hub-view-lead > :last-child { + margin-bottom: 0; +} +.hub-view-infobox > .hub-view-note, +.hub-view-infobox > .hub-view-meta { + margin: 16px 0 0; + padding-top: 14px; + border-top: 1px solid #cddfea; + color: #526e83; + font-size: 11px; +} +.hub-view-infobox > .hub-view-meta { + display: block; +} +.hub-view-infobox .hub-chips { + margin-bottom: 8px; +} +@media (max-width: 800px) { + .hub-view-infobox { + float: none; + width: 100%; + max-width: none; + margin: 0 0 24px; + } +} .hub-view-section > h3 { margin: 20px 0 8px; font-size: 15px; @@ -153,22 +232,6 @@ body[data-memory-hub] { clip: rect(0 0 0 0); white-space: nowrap; } -.hub-view-more-actions > summary { - display: inline-grid; - place-items: center; - width: 32px; - height: 32px; - border-radius: 6px; - list-style: none; - cursor: pointer; -} -.hub-view-more-actions > summary::-webkit-details-marker { - display: none; -} -.hub-view-more-actions > summary:hover { - background: var(--surface-alt); -} -.hub-draft-views:has(.hub-view-document) > h3, .hub-draft-views:has(.hub-view-document) > p, .hub-draft-views:has(.hub-view-document) > .hub-view-build-receipt { display: none; @@ -192,7 +255,6 @@ body[data-memory-hub] { margin: 16px 0; } @media (max-width: 600px) { - .hub-draft-views, .hub-view-document { padding: 12px; } @@ -543,14 +605,20 @@ body[data-memory-hub] { /* Tabs: underline style for both buttons and links */ .hub-tabs { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 2px; + max-width: 100%; + min-width: 0; margin: 0 0 14px; border-bottom: 1px solid var(--border); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; } .hub-tabs > button, .hub-tabs > a { display: inline-flex; + flex: 0 0 auto; align-items: center; gap: 7px; min-height: 0; @@ -562,6 +630,7 @@ body[data-memory-hub] { background: transparent; color: var(--muted); text-decoration: none; + white-space: nowrap; } .hub-tabs > button:hover:not(:disabled), .hub-tabs > a:hover { @@ -569,12 +638,17 @@ body[data-memory-hub] { color: var(--text); } .hub-tabs > button[aria-selected="true"], +.hub-tabs > button[aria-selected="true"]:hover, +.hub-page .hub-tabs > button[aria-pressed="true"], .hub-tabs > a[aria-current="page"] { border-bottom-color: var(--primary); background: transparent; color: var(--primary-dark); font-weight: 650; } +.hub-tabs > :focus-visible { + outline-offset: -3px; +} .hub-explore-nav { margin-top: 0; } @@ -717,6 +791,62 @@ textarea:focus-visible { } /* Inbox */ +.hub-inbox-tabs { + position: sticky; + top: 0; + z-index: 2; + background: var(--background); +} +.hub-inbox-workspace [role="tabpanel"][hidden] { + display: none; +} +.hub-inbox-overview-group { + margin: 8px 0 16px; +} +.hub-inbox-overview-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.hub-inbox-overview-head h3 { + margin: 0; +} +.hub-inbox-item { + display: flex; + align-items: center; + gap: 12px; + margin: 6px 0; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} +.hub-inbox-item-text { + flex: 1; + min-width: 0; +} +.hub-inbox-item h4 { + margin: 0; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hub-inbox-item p { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; + margin: 3px 0 0; + font-size: 12px; + color: var(--muted); +} +.hub-inbox-item > button { + flex: 0 0 auto; +} .inbox-card { display: flex; align-items: center; @@ -1222,10 +1352,13 @@ textarea:focus-visible { max-height: none; } -/* Questions: list on the left, selected question beside it */ +/* Questions: card inventory and a separate detail reader */ .hub-qhead { flex-wrap: wrap; } +.hub-qhead [role="status"]:empty { + display: none; +} .hub-qfilters { display: flex; flex-wrap: wrap; @@ -1275,16 +1408,10 @@ textarea:focus-visible { .hub-notice i { margin-right: 8px; } -.hub-split { - display: grid; - grid-template-columns: minmax(280px, 2fr) minmax(0, 3fr); - gap: 16px; - align-items: start; -} .hub-qlist { - display: flex; - flex-direction: column; - gap: 6px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; min-width: 0; } .hub-qrow { @@ -1329,30 +1456,14 @@ textarea:focus-visible { white-space: nowrap; } .hub-qdetail { - position: sticky; - top: 8px; - max-height: calc(100vh - 24px); min-width: 0; - overflow: auto; - padding: 0 4px 16px; -} -.hub-qdetail:empty::before { - content: "Select a question to see its answers."; - color: var(--muted); -} -.hub-qdetail-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; } -.hub-qdetail-head h3 { - flex: 1 1 100%; - margin: 0; - font-size: 16px; +.hub-question-content { + display: flow-root; } -.hub-qdetail-head .hub-row-actions { - margin-left: auto; +.hub-question-reader .hub-view-actions { + max-width: 900px; + margin: 0 auto 8px; } .hub-qdetail details { margin: 10px 0; @@ -1384,14 +1495,10 @@ textarea:focus-visible { cursor: pointer; font-weight: 600; } -@media (max-width: 900px) { - .hub-split { +@media (max-width: 800px) { + .hub-qlist { grid-template-columns: minmax(0, 1fr); } - .hub-qdetail { - position: static; - max-height: none; - } } .hub-qdetail .card .hub-row-actions { display: flex; diff --git a/ts/packages/agents/browser/src/views/client/library/memoryHub.html b/ts/packages/agents/browser/src/views/client/library/memoryHub.html index f6c8cc7720..d3a8b09c9f 100644 --- a/ts/packages/agents/browser/src/views/client/library/memoryHub.html +++ b/ts/packages/agents/browser/src/views/client/library/memoryHub.html @@ -101,42 +101,44 @@

Memory

Inbox

-
-
- +
+
+
+ +
+
+
+ + + +
-
-
- - - -