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
54 changes: 40 additions & 14 deletions docs/knowledge-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,22 +133,48 @@ security event log as `LEARNING_CHANGED`. Settings belong to the workspace
membership, so the same person can have different settings in two workspaces.
Cloud and self-hosted behave the same.

### Skills are instructions, not extra tools

> **Important:** applied skills do **not** add tool calls to your MCP surface.
> All applied skills for a server (its own server-scoped skills plus the
> connector-scoped ones for its connectors) are concatenated into a single
> `## Workspace skills` block inside the server's `instructions`. The agent reads
> them as guidance — the tool list is unchanged.
### How skills reach the AI client

A server's applied skills (its own server-scoped skills plus the
connector-scoped ones of the connectors the caller can use) reach the client in
two ways:

- **In the instructions** sent on connect, as a `## Workspace skills` block.
Each MCP server chooses how (MCP server page → *Workspace skills on connect*):
- **Automatic** (default): the full text while it fits in about 6,000
characters, otherwise an index of ids, titles and *when to use*.
- **Always the full text.**
- **Always a list** (the index).
- **On demand**, with two built-in tools that appear on `/mcp/<serverId>` when
the caller can see at least one applied skill:
- `skills_list(query?)` lists or searches the skills;
- `skill_get(skill)` returns one skill in full, by id or exact title.

Both read the current version at call time and only return skills the caller
can see on that server.

Each skill is also offered as an **MCP prompt**, so clients with a prompt
picker (slash commands) can start one by hand.

A skill has a short **instruction** (up to 2,000 characters) and an optional
**procedure** (markdown, up to 20,000 characters) for the full steps of a
workflow. The procedure is never pasted into the instructions: the full-text
mode mentions it with its id, and `skill_get` returns it. The Agent Skills
Finder's `skills_save_to_workspace` can file a procedure too.

The shared `/mcp` endpoint keeps its fixed toolset: its workspace guide
(`anythingmcp_get_workspace_guide`) returns the skills as text, procedures
included.

**Versions and usage.** Editing a skill's title, *when to use*, instruction or
procedure keeps the previous content as a version (see *History* on the skill)
and increments its version number. Each `skill_get` or prompt use is counted, so
the Skills page shows how often a skill was opened and when. Consolidate with
AI leaves skills that have a procedure untouched.

Because composition is dynamic, editing or deleting a skill takes effect on the
next request — there's no blob to clean up.

> **Scaling note.** Applied skills are concatenated verbatim into the server's
> instruction block, so with many active skills that block grows and consumes
> context on every session. Keep the applied set tight: scope skills to the
> right server/connector, dismiss duplicates, and use **Consolidate with AI** to
> merge overlapping active skills into fewer rules.
next request (a stateful session keeps the instructions it started with; the
tools always read the current version).

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- Workspace skills v2: long procedures served on demand, versions, usage,
-- and a per-server choice of how skills reach the model on initialize.
ALTER TABLE "kg_skill_suggestions" ADD COLUMN "body" TEXT;
ALTER TABLE "kg_skill_suggestions" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "kg_skill_suggestions" ADD COLUMN "use_count" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "kg_skill_suggestions" ADD COLUMN "last_used_at" TIMESTAMP(3);

ALTER TABLE "mcp_server_configs" ADD COLUMN "skills_delivery" TEXT NOT NULL DEFAULT 'auto';

CREATE TABLE "kg_skill_versions" (
"id" TEXT NOT NULL,
"skill_id" TEXT NOT NULL,
"organization_id" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"when_to_use" TEXT NOT NULL,
"instruction" TEXT NOT NULL,
"body" TEXT,
"edited_by_user_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "kg_skill_versions_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "kg_skill_versions_skill_id_version_key" ON "kg_skill_versions"("skill_id", "version");
CREATE INDEX "kg_skill_versions_organization_id_idx" ON "kg_skill_versions"("organization_id");

ALTER TABLE "kg_skill_versions" ADD CONSTRAINT "kg_skill_versions_skill_id_fkey" FOREIGN KEY ("skill_id") REFERENCES "kg_skill_suggestions"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- `auto` switches to an index once the skills text passes 6,000 characters.
-- Keep today's behaviour (full text) on every server that receives applied
-- skills today, its own or its connectors', so no existing server changes
-- until an admin picks a mode. New servers start on `auto`.
UPDATE "mcp_server_configs" AS s
SET "skills_delivery" = 'inline'
WHERE EXISTS (
SELECT 1
FROM "kg_skill_suggestions" AS k
WHERE k."status" = 'applied'
AND k."organization_id" = s."organization_id"
AND (
k."mcp_server_id" = s."id"
OR k."connector_id" IN (
SELECT c."connector_id" FROM "mcp_server_connectors" AS c WHERE c."mcp_server_id" = s."id"
)
)
);
32 changes: 32 additions & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,10 @@ model McpServerConfig {
version String @default("1.0.0")
isActive Boolean @default(true) @map("is_active")
instructions String? @db.Text // custom instructions prepended to composed connector instructions
/// How applied skills reach the model on initialize: `auto` (full text
/// while small, else an index pointing to skill_get), `inline` (always the
/// full text) or `index` (always the index).
skillsDelivery String @default("auto") @map("skills_delivery")

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand Down Expand Up @@ -1379,6 +1383,15 @@ model KgSkillSuggestion {
// consolidated) | finder (skills_save_to_workspace). Generation only
// replaces its own pending `llm` rows.
source String @default("manual")
// Optional long procedure (markdown). Never pasted into the instructions:
// served on demand by skill_get.
body String? @db.Text
// Incremented on every edit of title / whenToUse / instruction / body; the
// previous content is kept in kg_skill_versions.
version Int @default(1)
useCount Int @default(0) @map("use_count")
lastUsedAt DateTime? @map("last_used_at")
versions KgSkillVersion[]

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand All @@ -1387,6 +1400,25 @@ model KgSkillSuggestion {
@@map("kg_skill_suggestions")
}

// Earlier content of a workspace skill, written when an edit changes it.
model KgSkillVersion {
id String @id @default(cuid())
skillId String @map("skill_id")
skill KgSkillSuggestion @relation(fields: [skillId], references: [id], onDelete: Cascade)
organizationId String @map("organization_id")
version Int
title String
whenToUse String @db.Text @map("when_to_use")
instruction String @db.Text
body String? @db.Text
editedByUserId String? @map("edited_by_user_id")
createdAt DateTime @default(now()) @map("created_at")

@@unique([skillId, version])
@@index([organizationId])
@@map("kg_skill_versions")
}

// A submitted LLM batch job (cloud cron, cost-saver). Tracks the provider batch
// id so a later cron run can retrieve results and apply them. `context` maps each
// request's custom_id to what's needed to apply its result (org, type, ref map).
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/knowledge-graph/kg-resource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('KgService.describeForResource', () => {
const { svc } = make({
nodes: [node('n1', 'c1', 'Customer'), node('n2', 'c2', 'Order', { description: 'A sales order.' })],
edges: [{ sourceNodeId: 'n2', targetNodeId: 'n1', kind: 'references', matchKey: 'customer_id', note: null }],
skills: [{ title: 'Monthly revenue', whenToUse: 'When asked for revenue per month.' }],
skills: [{ id: 'sk-rev', title: 'Monthly revenue', whenToUse: 'When asked for revenue per month.' }],
});
const text = await svc.describeForResource(ORG, { connectorIds: ['c1', 'c2'], serverName: 'Sales' });

Expand All @@ -69,7 +69,7 @@ describe('KgService.describeForResource', () => {
expect(text).toContain('`c1_get_customer`');
expect(text).toContain('`customer_number`');
expect(text).toContain('- Order (ERP) refers to Customer (CRM) via `customer_id`');
expect(text).toContain('- **Monthly revenue**: When asked for revenue per month.');
expect(text).toContain('- **Monthly revenue** (`sk-rev`): When asked for revenue per month.');
});

it('stays bounded on a large graph and says what it left out', async () => {
Expand Down
145 changes: 145 additions & 0 deletions packages/backend/src/knowledge-graph/kg-skill.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,148 @@ describe('KgSkillService.activeSkillsText', () => {
);
});
});

describe('KgSkillService delivery modes', () => {
const ORG = 'org-A';
const row = (i: number, extra: Partial<any> = {}) => ({
id: `s${i}`,
title: `Skill ${i}`,
whenToUse: `when ${i}`,
instruction: `rule ${i}`,
body: null,
version: 1,
...extra,
});
const svcWith = (rows: any[]) =>
new KgSkillService({ kgSkillSuggestion: { findMany: jest.fn().mockResolvedValue(rows) } } as any, {} as any);

it('inline keeps the existing format for skills without a procedure', async () => {
const text = await svcWith([row(1)]).activeSkillsText(ORG, 'srv', [], 'inline');
expect(text).toBe('## Workspace skills\n- Skill 1 (when: when 1): rule 1');
});

it('inline references a procedure instead of pasting it', async () => {
const text = await svcWith([row(1, { body: 'STEP-BY-STEP' })]).activeSkillsText(ORG, 'srv', [], 'inline');
expect(text).toContain('skill_get "s1"');
expect(text).not.toContain('STEP-BY-STEP');
});

it('full pastes the procedure (shared guide)', async () => {
const text = await svcWith([row(1, { body: 'STEP-BY-STEP' })]).activeSkillsText(ORG, 'srv', [], 'full');
expect(text).toContain('STEP-BY-STEP');
});

it('auto stays inline while small and switches to the index when large', async () => {
const small = await svcWith([row(1)]).activeSkillsText(ORG, 'srv', [], 'auto');
expect(small).toContain('rule 1');

const big = Array.from({ length: 40 }, (_, i) => row(i, { instruction: 'x'.repeat(300) }));
const text = (await svcWith(big).activeSkillsText(ORG, 'srv', [], 'auto'))!;
expect(text).toContain('call `skill_get`');
expect(text).toContain('- `s0` Skill 0: when 0');
expect(text).not.toContain('x'.repeat(300));
});

it('index lists up to its budget and points to skills_list for the rest', async () => {
const many = Array.from({ length: 200 }, (_, i) => row(i, { whenToUse: 'w'.repeat(150) }));
const text = (await svcWith(many).activeSkillsText(ORG, 'srv', [], 'index'))!;
expect(text.length).toBeLessThan(8_200);
expect(text).toMatch(/…and \d+ more: call `skills_list`/);
});
});

describe('KgSkillService.getForCaller', () => {
it('reads only visible applied skills of the organization and counts the use', async () => {
const skill = { id: 's1', title: 'T', whenToUse: '', instruction: 'I', body: null, version: 2 };
const findFirst = jest.fn().mockResolvedValue(skill);
const update = jest.fn().mockResolvedValue({});
const svc = new KgSkillService({ kgSkillSuggestion: { findFirst, update } } as any, {} as any);

expect(await svc.getForCaller('org-A', 'srv-1', ['c-1'], 's1')).toBe(skill);
const where = findFirst.mock.calls[0][0].where;
expect(where.AND[0]).toEqual({
organizationId: 'org-A',
status: 'applied',
OR: [{ mcpServerId: { in: ['srv-1'] } }, { connectorId: { in: ['c-1'] } }],
});
expect(where.AND[1]).toEqual({ OR: [{ id: 's1' }, { title: { equals: 's1', mode: 'insensitive' } }] });
expect(update).toHaveBeenCalledWith({
where: { id: 's1' },
data: { useCount: { increment: 1 }, lastUsedAt: expect.any(Date) },
});
});

it('returns null without counting when the skill is not visible', async () => {
const update = jest.fn();
const svc = new KgSkillService(
{ kgSkillSuggestion: { findFirst: jest.fn().mockResolvedValue(null), update } } as any,
{} as any,
);
expect(await svc.getForCaller('org-A', 'srv-1', [], 'other-org-skill')).toBeNull();
expect(update).not.toHaveBeenCalled();
});
});

describe('KgSkillService.update versions', () => {
const current = {
id: 's1',
organizationId: 'org-A',
title: 'Old',
whenToUse: 'w',
instruction: 'old rule',
body: null,
version: 3,
};

function make() {
const tx = {
kgSkillVersion: { create: jest.fn() },
kgSkillSuggestion: { update: jest.fn().mockResolvedValue({ id: 's1', version: 4 }) },
};
const prisma = {
kgSkillSuggestion: {
findUnique: jest.fn().mockResolvedValue(current),
update: jest.fn().mockResolvedValue({ id: 's1' }),
},
$transaction: jest.fn(async (fn: any) => fn(tx)),
};
return { svc: new KgSkillService(prisma as any, {} as any), prisma, tx };
}

it('keeps the previous content as a version and increments it on a content change', async () => {
const { svc, tx } = make();
await svc.update('org-A', 's1', { instruction: 'new rule', body: 'steps' }, 'u-1');
expect(tx.kgSkillVersion.create).toHaveBeenCalledWith({
data: {
skillId: 's1',
organizationId: 'org-A',
version: 3,
title: 'Old',
whenToUse: 'w',
instruction: 'old rule',
body: null,
editedByUserId: 'u-1',
},
});
expect(tx.kgSkillSuggestion.update).toHaveBeenCalledWith({
where: { id: 's1' },
data: { instruction: 'new rule', body: 'steps', version: { increment: 1 } },
});
});

it('does not version a status-only change', async () => {
const { svc, prisma } = make();
await svc.update('org-A', 's1', { status: 'dismissed' });
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(prisma.kgSkillSuggestion.update).toHaveBeenCalledWith({
where: { id: 's1' },
data: { status: 'dismissed' },
});
});

it('refuses a skill of another organization', async () => {
const { svc, prisma } = make();
prisma.kgSkillSuggestion.findUnique.mockResolvedValue({ ...current, organizationId: 'org-B' });
await expect(svc.update('org-A', 's1', { title: 'x' })).rejects.toBeInstanceOf(NotFoundException);
});
});
Loading
Loading