Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ts/packages/agentRpc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 51 additions & 11 deletions ts/packages/agentRpc/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ export async function createAgentRpcClient(

const actionContextMap = createObjectMap<ActionContext<ShimContext>>();
let optionsRpc: ReturnType<typeof createOptionsRpc> | undefined;
const optionsReferences = new Map<unknown, number>();
const contextOptions = new Map<number, unknown>();
function getOptionsCallBack(initOptions?: any) {
const functions = getOptionsFunctions(initOptions);
if (functions === undefined) {
Expand All @@ -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<T>(
actionContext: ActionContext<ShimContext>,
fn: (contextParams: ActionContextParams) => T,
Expand Down Expand Up @@ -674,11 +693,30 @@ export async function createAgentRpcClient(
}

const agent: Required<AppAgent> = {
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,
Expand Down Expand Up @@ -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;
};
Expand Down
98 changes: 98 additions & 0 deletions ts/packages/agentRpc/test/actionContext.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Options>) =>
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<unknown>;
const read = (sessionContext: SessionContext<unknown>) =>
client.executeAction!(
{ schemaName: "test", actionName: "read" },
{ sessionContext } as ActionContext<unknown>,
);
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;
Expand Down
98 changes: 94 additions & 4 deletions ts/packages/agents/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,106 @@ 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.

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,
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion ts/packages/agents/browser/src/agent/memoryHubGraph.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading
Loading