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: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ platform API version.

### Added

- The kind of an edge, for a sidecar whose `connect` can make more than one.
A sidecar lists the kinds in its capabilities (`diagnostic.edgeRoles`,
`[{ id, label, description? }]`); the create-edge handler then asks which
(a role given on the operation, or the only one listed, is taken without
asking) and sends it as `args.role`, and the delete handler sends the
edge's `cal:edgeRole` as `args.role` so the right one of several edges
between two nodes goes. The capabilities are asked once per sidecar command
for the session, not on every connect. An agent's `create-edges` call is
headless: it may name the kind in `args.role`, and is never asked; without
one the sidecar's default kind is made. A sidecar that lists none is
treated as before, on connect and on delete.
- `clientBehavior.edgeCaptions`: `false` draws an edge as its line alone, with
no carried label, queue badge, last-token button or index label on it; the
tooltip on hover still says what it carries. Default `true`, as before.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export class AgentDispatchCreateEdgesMcpToolHandler extends CreateEdgesMcpToolHa
"name via `args.source` / `args.target` as \"nodeName.portName\" (e.g. args.source " +
'"producer.out", args.target "consumer.in") — the name form is resolved server-side and needs ' +
'no query-elements/diagram-model dump. After create-nodes, the confirmation lists each new ' +
"node's port names, so the very next create-edges can use nodeName.portName directly. Set " +
"node's port names, so the very next create-edges can use nodeName.portName directly. When " +
"the diagram's edges come in kinds, name the kind in `args.role`; without it the diagram's " +
'default kind is made (never a question to the person). Set ' +
'`dryRun: true` to validate without creating. Use `element-types` for valid edge type IDs. ' +
'This modifies the source and is undoable via the editor.';

Expand All @@ -84,12 +86,14 @@ export class AgentDispatchCreateEdgesMcpToolHandler extends CreateEdgesMcpToolHa
if (source.ok && target.ok) {
// Drop the name specs from args (they are addressing, not model metadata) and fill
// the concrete ids the stock handler dispatches.
// An agent's edge is made headless: no one is at the screen to
// answer a question in the middle of its tool call.
const { source: _s, target: _t, ...restArgs } = (edge.args ?? {}) as Record<string, unknown>;
resolvedEdges.push({
...edge,
sourceElementId: source.elementId,
targetElementId: target.elementId,
args: Object.keys(restArgs).length > 0 ? restArgs : undefined
args: { ...restArgs, headless: true }
});
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/diagram-server/test/mcp-diagram-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,9 @@ describe('AgentDispatchCreateEdgesMcpToolHandler (name-addressable endpoints)',
const fwd = cap.forwarded();
expect(fwd.edges[0].sourceElementId).toBe('producer.out');
expect(fwd.edges[0].targetElementId).toBe('consumer.in');
// The name specs are addressing, not model metadata — stripped from args.
expect(fwd.edges[0].args).toBeUndefined();
// The name specs are addressing, not model metadata — stripped from args; an agent's
// edge is made headless, so no question reaches the person mid tool call.
expect(fwd.edges[0].args).toEqual({ headless: true });
} finally {
cap.restore();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared

import { ReversibleWorkspaceEditCommand } from '@dialogram/diagram-server/operations/reversible-workspace-edit-command';
import { SidecarInvoker } from './sidecar-invoker';
import { type SidecarEdgeRole } from '../../sidecar-capabilities';

/**
* Port reference resolved from GModel args (Python path).
Expand Down Expand Up @@ -80,9 +81,13 @@ export class CreateEdgeOperationHandler extends OperationHandler {
? { type: 'workflow', name: targetRef.portName }
: { type: 'actor', actor: targetRef.entityName, port: targetRef.portName };

const role = await this.pickEdgeRole(sourceUri, operation);
if (role === null) {
return undefined;
}
const ok = await this.sendSidecarOp(sourceUri, {
op: this.sidecar.sidecarOp('connect'),
args: { workflow: workflowName, from_port: fromPort, to_port: toPort }
args: { workflow: workflowName, from_port: fromPort, to_port: toPort, ...(role ? { role } : {}) }
});
if (!ok) {
return undefined;
Expand All @@ -92,6 +97,44 @@ export class CreateEdgeOperationHandler extends OperationHandler {
});
}

/**
* The kind of edge to make, when the sidecar's `connect` can make more
* than one (`getCapabilities.edgeRoles`).
*
* A role given on the operation wins. Otherwise the sidecar is asked what
* it offers: nothing, and `connect` goes without a role, as it always
* has; one, and that is it; several, and the person picks (`undefined`
* when the pick is dismissed, which is the `null` here: no connection).
* A headless caller, an agent with no one to ask, gets no pick either:
* it names the role itself, or the sidecar's default applies.
*/
protected async pickEdgeRole(sourceUri: string, operation: CreateEdgeOperation): Promise<string | undefined | null> {
const given = typeof operation.args?.['role'] === 'string' ? String(operation.args['role']).trim() : '';
if (given !== '') {
return given;
}
if (operation.args?.['headless'] === true) {
return undefined;
}
const roles = await this.edgeRoles(sourceUri);
if (!roles || roles.length === 0) {
return undefined;
}
if (roles.length === 1) {
return roles[0].id;
}
const picked = await vscode.window.showQuickPick(
roles.map(role => ({ label: role.id, description: role.label, detail: role.description, id: role.id })),
{ placeHolder: 'Which kind of edge?', matchOnDescription: true }
);
return picked ? picked.id : null;
}

/** The edge kinds the sidecar offers (asked once per session); nothing when it says nothing. */
protected async edgeRoles(sourceUri: string): Promise<SidecarEdgeRole[] | undefined> {
return this.sidecar.edgeRoles(sourceUri);
}

/**
* Resolve a port reference for Python diagram ports.
*
Expand Down
15 changes: 14 additions & 1 deletion packages/sidecar-toolkit/src/server/operations/delete-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,22 @@ export class DeleteElementOperationHandler extends OperationHandler {
if (!outPort || !inPort) continue;
const fromExpr = this.edgeEndpointExpr(fromName, outPort);
const toExpr = this.edgeEndpointExpr(toName, inPort);
// The kind of edge, when the producer said (`meta.role`)
// and the sidecar makes edges of several kinds: two nodes
// may then be joined by more than one, and the sidecar
// needs to know which one goes. A sidecar that lists no
// kinds gets no role, as before.
const role = (await this.sidecar.edgeRoles(sourceUri))
? elementArgs[WorkflowDiagramMetadata.EDGE_ROLE]
: undefined;
const ok = await this.sendSidecarOp(sourceUri, {
op: this.sidecar.sidecarOp('deleteEdge'),
args: { workflow: workflowName, from_expr: fromExpr, to_expr: toExpr }
args: {
workflow: workflowName,
from_expr: fromExpr,
to_expr: toExpr,
...(typeof role === 'string' && role.trim() !== '' ? { role: role.trim() } : {})
}
});
if (!ok) return undefined;
continue;
Expand Down
33 changes: 33 additions & 0 deletions packages/sidecar-toolkit/src/server/operations/sidecar-invoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as vscode from 'vscode';
import { URI } from 'vscode-uri';
import { SidecarRuntimeService, type CreateNodeStrings, type CreateNodeBehavior, CreateNodeVariant } from '../sidecar-runtime-config';
import { describeChildFailure, runChildProcess } from '../../run-child-process';
import { parseCapabilitiesDiagnostic, type SidecarEdgeRole } from '../../sidecar-capabilities';

export interface SidecarInvocationResult {
ok: boolean;
Expand All @@ -14,6 +15,9 @@ export interface SidecarInvocationResult {

@injectable()
export class SidecarInvoker {
/** The edge kinds each sidecar command offers, asked once per session. */
private readonly edgeRolesByCommand = new Map<string, Promise<SidecarEdgeRole[] | undefined>>();

constructor(
@inject(SidecarRuntimeService)
private readonly runtimeProfile: SidecarRuntimeService
Expand Down Expand Up @@ -78,6 +82,35 @@ export class SidecarInvoker {
});
}

/**
* The kinds of edge the sidecar's `connect` can make
* (`getCapabilities.edgeRoles`); nothing when it lists none. Asked once per
* sidecar command for the session, not on every connect: each ask is a
* process of its own, and what a sidecar offers does not change while it
* is running. A failed ask is not kept, so the next one tries again.
*/
async edgeRoles(sourceUri: string): Promise<SidecarEdgeRole[] | undefined> {
let key: string;
try {
key = this.sidecarCommand(vscode.Uri.parse(sourceUri));
} catch {
key = sourceUri;
}
let pending = this.edgeRolesByCommand.get(key);
if (!pending) {
pending = this.sendSidecarCapabilitiesDetailed(sourceUri).then(result => {
if (!result.ok || !result.response) {
this.edgeRolesByCommand.delete(key);
return undefined;
}
return parseCapabilitiesDiagnostic(result.response.diagnostic, '')?.edgeRoles ?? [];
});
this.edgeRolesByCommand.set(key, pending);
}
const roles = await pending;
return roles && roles.length > 0 ? roles : undefined;
}

async sendSidecarOp(sourceUri: string, payload: { op: string; args: Record<string, unknown> }): Promise<boolean> {
const result = await this.invokeSidecar(sourceUri, payload);
return result.ok;
Expand Down
35 changes: 34 additions & 1 deletion packages/sidecar-toolkit/src/sidecar-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,38 @@ export interface SidecarCapabilities {
supportedOps: Set<string>;
/** Declared feature flags (sourceTruthEdits, stableIds, checkConnection, …). */
features: Record<string, boolean>;
/**
* The kinds of edge the sidecar's `connect` can make, when a connection is
* more than one thing (`diagnostic.edgeRoles`, `[{ id, label, description? }]`).
* Absent or empty: a connection is one thing, and `connect` is sent without
* a role. Present: the create-edge handler asks which, and sends `args.role`.
*/
edgeRoles?: SidecarEdgeRole[];
}

/** One kind of edge a sidecar's `connect` can make. */
export interface SidecarEdgeRole {
id: string;
label: string;
description?: string;
}

/** `diagnostic.edgeRoles` as the sidecar wrote it, else nothing: an entry
* without a non-empty string id is dropped, a missing label is the id. */
export function parseEdgeRoles(raw: unknown): SidecarEdgeRole[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: SidecarEdgeRole[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const { id, label, description } = item as Record<string, unknown>;
if (typeof id !== 'string' || id.trim() === '') continue;
out.push({
id: id.trim(),
label: typeof label === 'string' && label.trim() !== '' ? label.trim() : id.trim(),
...(typeof description === 'string' && description.trim() !== '' ? { description: description.trim() } : {}),
});
}
return out.length > 0 ? out : undefined;
}

interface CapabilityQueryOptions {
Expand Down Expand Up @@ -68,7 +100,8 @@ export function parseCapabilitiesDiagnostic(diagnostic: any, prefix: string): Si
: typeof nested?.sidecarProtocolVersion === 'number'
? nested.sidecarProtocolVersion
: 1;
return { protocolVersion, supportedOps, features };
const edgeRoles = parseEdgeRoles(diagnostic.edgeRoles);
return { protocolVersion, supportedOps, features, ...(edgeRoles ? { edgeRoles } : {}) };
}

/**
Expand Down
Loading
Loading