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
11 changes: 6 additions & 5 deletions cockpit/chat/a2ui/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,10 +774,11 @@ async def generate_title(state: MessagesState, config) -> dict:
intent into 3-5 words and persist to LangGraph thread metadata so the
sidenav shows something meaningful instead of a UUID slice.

Idempotent — skips when metadata.thread_title already exists. Errors
are swallowed (title is a UX nicety, never a blocker). Runs after the
Idempotent — skips when metadata.title already exists. Errors are
swallowed (title is a UX nicety, never a blocker). Runs after the
user-visible terminal node so it never blocks the response. See spec
2026-05-19-llm-generated-labels-design.md.
2026-05-19-llm-generated-labels-design.md (originally `thread_title`,
converged to `title` for parity with the canonical demo + adapter).
"""
thread_id = (config.get("configurable") or {}).get("thread_id")
if not thread_id:
Expand All @@ -790,7 +791,7 @@ async def generate_title(state: MessagesState, config) -> dict:
try:
client = get_client(url=sdk_url)
thread = await client.threads.get(thread_id)
if (thread.get("metadata") or {}).get("thread_title"):
if (thread.get("metadata") or {}).get("title"):
return {}
first_user = next(
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
Expand All @@ -808,7 +809,7 @@ async def generate_title(state: MessagesState, config) -> dict:
])
title = (response.content or "").strip().strip('"').strip("'")[:80]
if title:
await client.threads.update(thread_id, metadata={"thread_title": title})
await client.threads.update(thread_id, metadata={"title": title})
except Exception as err: # noqa: BLE001 — title is a UX nicety; never block
_logger.warning("Thread title generation failed: %s", err)
return {}
Expand Down
9 changes: 3 additions & 6 deletions cockpit/chat/threads/angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,11 @@ export const appConfig: ApplicationConfig = {
providers: [
provideAgent({ apiUrl: environment.langGraphApiUrl }),
provideChat({}),
// c-threads' Python graph writes the LLM-generated title to
// metadata.thread_title (per spec 2026-05-19-llm-generated-labels-design).
// The adapter expects metadata.title; the cap's generate_title
// graph node writes there. No per-cap key override needed.
{
provide: LANGGRAPH_THREADS_CONFIG,
useValue: {
apiUrl: environment.langGraphApiUrl,
titleMetadataKey: 'thread_title',
},
useValue: { apiUrl: environment.langGraphApiUrl },
},
],
};
11 changes: 6 additions & 5 deletions cockpit/chat/threads/angular/src/app/threads.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import { environment } from '../environments/environment';
* ThreadsComponent demonstrates multi-thread conversation management
* backed by the real LangGraph SDK. Consumes the shared
* LangGraphThreadsAdapter from `@ngaf/langgraph` — same service the
* canonical demo uses — configured for the `metadata.thread_title`
* key that this cap's `generate_title` graph node writes (spec
* 2026-05-19-llm-generated-labels-design). See app.config.ts for the
* LANGGRAPH_THREADS_CONFIG provider.
* canonical demo uses. Reads `metadata.title` written by this cap's
* `generate_title` graph node (spec
* 2026-05-19-llm-generated-labels-design.md, converged on `title`
* after the original `thread_title` choice). See app.config.ts for
* the LANGGRAPH_THREADS_CONFIG provider.
*/
@Component({
selector: 'app-threads',
Expand Down Expand Up @@ -86,7 +87,7 @@ export class ThreadsComponent {
void this.threadsSvc.refresh();

// Re-fetch when an agent run completes. The graph's generate_title
// node writes metadata.thread_title on the first turn; refreshing
// node writes metadata.title on the first turn; refreshing
// on the running→idle transition surfaces it in the sidenav
// without a manual reload.
refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh());
Expand Down
18 changes: 12 additions & 6 deletions cockpit/chat/threads/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
Conversational agent with inline thread-title generation. Each new
thread gets an LLM-generated 3-5 word title written to LangGraph
thread metadata on the first turn (idempotent — subsequent turns skip
the write). The chat-threads frontend reads `metadata.thread_title`
from `client.threads.search()` and displays it in the sidenav.
the write). The chat-threads frontend reads `metadata.title` from
`client.threads.search()` via LangGraphThreadsAdapter and displays
it in the sidenav.

Pattern D from spec 2026-05-19-llm-generated-labels-design.md: the
generate_title node lives inline in this file (not extracted to a
Expand Down Expand Up @@ -37,9 +38,14 @@ async def generate_title(state: MessagesState, config) -> dict:
intent into 3-5 words and persist to LangGraph thread metadata so the
sidenav shows something meaningful instead of a UUID slice.

Idempotent — skips when metadata.thread_title already exists. Errors
are swallowed (title is a UX nicety, never a blocker). Runs after the
Idempotent — skips when metadata.title already exists. Errors are
swallowed (title is a UX nicety, never a blocker). Runs after the
user-visible turn so it never blocks the response.

Writes to `metadata.title` (matches the legacy examples/chat
convention and the LangGraphThreadsAdapter default). Spec
2026-05-19 originally chose `thread_title`; we converged on
`title` to drop the per-cap `titleMetadataKey` config knob.
"""
thread_id = (config.get("configurable") or {}).get("thread_id")
if not thread_id:
Expand All @@ -53,7 +59,7 @@ async def generate_title(state: MessagesState, config) -> dict:
try:
client = get_client(url=sdk_url)
thread = await client.threads.get(thread_id)
if (thread.get("metadata") or {}).get("thread_title"):
if (thread.get("metadata") or {}).get("title"):
return {}
first_user = next(
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
Expand All @@ -71,7 +77,7 @@ async def generate_title(state: MessagesState, config) -> dict:
])
title = (response.content or "").strip().strip('"').strip("'")[:80]
if title:
await client.threads.update(thread_id, metadata={"thread_title": title})
await client.threads.update(thread_id, metadata={"title": title})
except Exception as e: # noqa: BLE001 — title is a UX nicety; never block
# Don't break the run, but DO log. A bare pass has hidden a prod
# bug in the sibling examples/chat graph where the title write was
Expand Down
18 changes: 18 additions & 0 deletions docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,21 @@ export function a2uiActionLabel(content: string): string | null {
- `_builder.add_edge("respond", "generate_title")` — both are node names registered earlier in the file. Consistent.

**Anti-pattern check:** zero hardcoded label tables. Zero topology magic. Zero cross-cap python imports. Each cap stays self-contained.

---

## Addendum 2026-05-21 — converged on `metadata.title`

This spec proposed `metadata.thread_title` for the new cockpit-cap title nodes (c-threads, c-a2ui). After landing #481, #488, #491, #492, #493 the per-cap key created friction:

- `LangGraphThreadsAdapter` carried a `titleMetadataKey` config knob to bridge the two conventions
- Each consumer had to remember which spelling its backend used
- The canonical demo (`examples/chat/python`) writes `metadata.title` and predates this spec

Resolved by converging on `metadata.title` across all consumers:

- `cockpit/chat/threads/python` + `cockpit/chat/a2ui/python` graphs now write `metadata.title`
- `LangGraphThreadsAdapter` reads `metadata.title` unconditionally; the `titleMetadataKey` config knob is gone
- Pre-existing prod threads written with the old `thread_title` spelling would lose their title; the existing prod backlog was cleared separately (see /tmp/delete-prod-threads.sh)

Pattern D (inline node per cap, no shared helper) stays intact — only the metadata key name changes.
9 changes: 2 additions & 7 deletions examples/chat/angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,10 @@ export const appConfig: ApplicationConfig = {
provideZonelessChangeDetection(),
provideRouter(routes, withComponentInputBinding()),
provideNgafTelemetry(environment.telemetry),
// Configure the shared LangGraphThreadsAdapter. The canonical
// demo's Python graph writes the title to `metadata.title` (the
// legacy spelling — c-threads writes `metadata.thread_title`).
// Configure the shared LangGraphThreadsAdapter.
{
provide: LANGGRAPH_THREADS_CONFIG,
useValue: {
apiUrl: environment.langGraphApiUrl,
titleMetadataKey: 'title',
},
useValue: { apiUrl: environment.langGraphApiUrl },
},
],
};
25 changes: 8 additions & 17 deletions libs/langgraph/src/lib/threads/threads-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ function mockClient(searchReturn: unknown[] = []): {
};
}

function configure(client: Client, titleKey = 'thread_title'): LangGraphThreadsAdapter {
function configure(client: Client): LangGraphThreadsAdapter {
TestBed.configureTestingModule({
providers: [
{ provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x', titleMetadataKey: titleKey } },
{ provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } },
{ provide: LANGGRAPH_CLIENT, useValue: client },
],
});
Expand All @@ -40,12 +40,12 @@ function configure(client: Client, titleKey = 'thread_title'): LangGraphThreadsA
describe('LangGraphThreadsAdapter', () => {
beforeEach(() => TestBed.resetTestingModule());

it('maps SDK threads through the configured title metadata key', async () => {
it('maps SDK threads via metadata.title', async () => {
const { client } = mockClient([
{
thread_id: 't1',
updated_at: '2026-05-20T00:00:00Z',
metadata: { thread_title: 'Capital of Japan' },
metadata: { title: 'Capital of Japan' },
},
]);
const svc = configure(client);
Expand All @@ -55,15 +55,6 @@ describe('LangGraphThreadsAdapter', () => {
]);
});

it('honours an alternate title key (demo writes metadata.title)', async () => {
const { client } = mockClient([
{ thread_id: 't1', metadata: { title: 'Hello' } },
]);
const svc = configure(client, 'title');
await svc.refresh();
expect(svc.threads()[0].title).toBe('Hello');
});

it('falls back to "Untitled" when title metadata is missing', async () => {
const { client } = mockClient([{ thread_id: 't1', metadata: {} }]);
const svc = configure(client);
Expand Down Expand Up @@ -93,19 +84,19 @@ describe('LangGraphThreadsAdapter', () => {
expect(svc.threads().map(t => t.id)).toEqual(['p1', 'p2', 'unp']);
});

it('rename() writes the configured title key', async () => {
it('rename() writes metadata.title', async () => {
const m = mockClient();
const svc = configure(m.client, 'thread_title');
const svc = configure(m.client);
await svc.rename('t1', 'New title');
expect(m.update).toHaveBeenCalledWith('t1', { metadata: { thread_title: 'New title' } });
expect(m.update).toHaveBeenCalledWith('t1', { metadata: { title: 'New title' } });
});

it('getThread() returns a mapped Thread when the SDK resolves', async () => {
const m = mockClient();
m.get.mockResolvedValue({
thread_id: 'tx',
updated_at: '2026-05-20T00:00:00Z',
metadata: { thread_title: 'hello' },
metadata: { title: 'hello' },
});
const svc = configure(m.client);
const result = await svc.getThread('tx');
Expand Down
18 changes: 8 additions & 10 deletions libs/langgraph/src/lib/threads/threads-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,20 @@ import { createLangGraphClient } from '../client/create-langgraph-client';
* providers: [
* { provide: LANGGRAPH_THREADS_CONFIG, useValue: {
* apiUrl: environment.langGraphApiUrl,
* titleMetadataKey: 'thread_title',
* }},
* ],
* ```
*
* The adapter expects backends to write the thread title to
* `metadata.title`. Spec 2026-05-19-llm-generated-labels-design.md
* originally proposed `metadata.thread_title` for cockpit caps but
* we converged on `title` to match the canonical demo and avoid a
* per-cap configuration knob.
*/
export interface LangGraphThreadsConfig {
/** Base URL for the LangGraph Platform API. Accepts both absolute
* URLs and relative `/api`-style paths. */
apiUrl: string;
/** Metadata key the backend writes the thread title to. Two
* conventions exist in the wild:
* - `'title'` — legacy / canonical demo
* - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design
* Defaults to `'thread_title'`. */
titleMetadataKey?: string;
/** Fallback label for threads whose title hasn't been written yet
* (e.g. created but never sent). Defaults to `'Untitled'`. */
titleFallback?: string;
Expand Down Expand Up @@ -65,7 +64,6 @@ export class LangGraphThreadsAdapter {
private readonly client: Client = inject(LANGGRAPH_CLIENT, { optional: true })
?? createLangGraphClient(this.config.apiUrl);

private readonly titleKey: string = this.config.titleMetadataKey ?? 'thread_title';
private readonly fallback: string = this.config.titleFallback ?? 'Untitled';

private readonly _threads: WritableSignal<Thread[]> = signal<Thread[]>([]);
Expand Down Expand Up @@ -153,7 +151,7 @@ export class LangGraphThreadsAdapter {
}

async rename(threadId: string, newTitle: string): Promise<void> {
await this.client.threads.update(threadId, { metadata: { [this.titleKey]: newTitle } });
await this.client.threads.update(threadId, { metadata: { title: newTitle } });
await this.refresh();
}

Expand Down Expand Up @@ -206,7 +204,7 @@ export class LangGraphThreadsAdapter {

private toThread(t: SdkThread): Thread {
const meta = (t.metadata ?? {}) as Record<string, unknown>;
const rawTitle = meta[this.titleKey];
const rawTitle = meta['title'];
const archived = meta['archived'] === true;
const pinned = meta['pinned'] === true;
const projectId = typeof meta['projectId'] === 'string' && (meta['projectId'] as string).length > 0
Expand Down
Loading