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
45 changes: 45 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,51 @@ docker compose down

For remote hosting, configure an HTTPS reverse proxy and the matching `APP_ORIGIN`. See [Security](../SECURITY.md) for the template's deployment boundary.

## Automatic Learning

OpenDots connects [CopilotKit Automatic Learning](https://docs.copilotkit.ai/learning)
to individual Dots. It uses the existing server-side `INTELLIGENCE_API_KEY` and
optional `INTELLIGENCE_API_URL`; no additional model key or frontend key is needed.

1. Open **Learning** in the same Intelligence project and create a container for
one focused workflow, such as `research-workflow`. IDs use 1–64 lowercase
letters, numbers, and single hyphens.
2. In OpenDots, edit the Dot and enter that ID under **Automatic Learning**.
Saving the ID configures routing; it does not create or verify the remote container.
3. Start new conversations and complete related workflows. Each conversation keeps
the container assigned when it was created. Existing conversations, including
ones created before this integration, are not enrolled retroactively. Changing
or clearing the Dot's ID affects only new conversations. This applies to page
chat, scheduled and voice compute in those conversations, and new Slack threads.
4. In Intelligence, inspect the evidence, run Learning manually or use its schedule,
and review and publish proposed skills. The default automatic threshold is 15
eligible threads; use the readiness count shown in your deployment.
5. Enable **Skill delivery** on the Intelligence container, then enable **Use
published skills** in the Dot's settings. Start a new turn in an enrolled
conversation. The native BuiltInAgent integration loads the latest published
catalog and exposes `copilotkit_load_skill` and `copilotkit_read_skill_file`.
The model decides which relevant skills to load. Check the run's tool calls to
verify actual use; saving settings alone does not establish connectivity.

Skill delivery always uses the conversation's original container, even after the
Dot is pointed at another container. The Dot's delivery checkbox applies to all its
conversations. Unchecking it stops delivery on subsequent turns; changing Learning
settings also stops active work. Conversations without a container do not request
skills. Existing research, memory, page, and computer permissions still apply.

Ingestion and delivery are separate. Clearing the container does not unenroll older
conversations; pause Learning in Intelligence to stop its analysis. Turning off
delivery does not stop evidence collection. A delivery denial or an unavailable
initial skill snapshot fails the turn rather than silently continuing without the
configured skills. Restore delivery or uncheck **Use published skills** to continue
without them. Skills require review and publication in Intelligence; OpenDots does
not automatically approve them.

The integration uses an explicit container list, so ambient
`CPK_INTELLIGENCE_LEARNING_CONTAINER_ID` and `CPK_INTELLIGENCE_SKILLS_REVISION`
variables do not override a conversation's configuration. Self-hosted Intelligence
must support the [skill delivery endpoints](https://docs.copilotkit.ai/intelligence/learned-skills).

## Development checks

```sh
Expand Down
58 changes: 58 additions & 0 deletions src/client/WorkspaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export function WorkspaceDialog({
dialog.type === 'dot' ? (dialog.dot?.spaceId ?? dialog.spaceId) : '',
);
const [interval, setInterval] = useState('86400');
const [learningContainer, setLearningContainer] = useState(
dialog.type === 'dot' ? (dialog.dot?.learningContainerId ?? '') : '',
);
const [skillDelivery, setSkillDelivery] = useState(
dialog.type === 'dot' ? (dialog.dot?.skillDeliveryEnabled ?? false) : false,
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const container = useRef<HTMLElement>(null);
Expand Down Expand Up @@ -134,6 +140,8 @@ export function WorkspaceDialog({
instructions: text,
researchAllowed: research,
memoryAllowed: memory,
learningContainerId: learningContainer.trim() || null,
skillDeliveryEnabled: skillDelivery,
};
}
if (dialog.type === 'settings') {
Expand Down Expand Up @@ -279,6 +287,56 @@ export function WorkspaceDialog({
</label>
</>
)}
{dialog.type === 'dot' && (
<fieldset className="space-access-fields">
<legend>Automatic Learning</legend>
<label className="field-label" htmlFor="learning-container">
Learning container ID
</label>
<input
id="learning-container"
value={learningContainer}
maxLength={64}
pattern="[a-z0-9]+(-[a-z0-9]+)*"
placeholder="research-workflow"
aria-describedby="learning-help"
onChange={(event) => {
setLearningContainer(event.target.value);
if (!event.target.value.trim()) setSkillDelivery(false);
}}
/>
<p className="muted" id="learning-help">
Create this container in your Intelligence project first. New
conversations will contribute evidence to it. Leave blank to
keep new conversations out of Learning. Existing conversations
retain their original assignment.
</p>
<label className="permission-row">
<input
type="checkbox"
checked={skillDelivery}
disabled={!learningContainer.trim()}
onChange={(event) => setSkillDelivery(event.target.checked)}
/>
<span>
<strong>Use published skills</strong>
<small>
Load reviewed skills from each conversation’s assigned
container. Enable delivery in Intelligence too. Turning this
off stops skill loading; it does not stop evidence
collection.
</small>
</span>
</label>
<a
href="https://docs.copilotkit.ai/learning"
target="_blank"
rel="noreferrer"
>
Set up Learning and review skills ↗
</a>
</fieldset>
)}
{dialog.type === 'schedule' && (
<>
<label className="field-label" htmlFor="schedule-interval">
Expand Down
20 changes: 18 additions & 2 deletions src/server/dot-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export class DotAgent extends AbstractAgent {
dot.id,
'Slack conversation',
);
this.workspace.requireThread(input.threadId, dot.id);
const conversation = this.workspace.requireThread(
input.threadId,
dot.id,
);
if (
!this.config.intelligenceKey ||
!this.config.apiKey ||
Expand All @@ -79,6 +82,8 @@ export class DotAgent extends AbstractAgent {
settings.researchAllowed !== initialSettings.researchAllowed ||
settings.memoryAllowed !== initialSettings.memoryAllowed ||
current.memoryAllowed !== dot.memoryAllowed ||
current.learningContainerId !== dot.learningContainerId ||
current.skillDeliveryEnabled !== dot.skillDeliveryEnabled ||
current.researchAllowed !== dot.researchAllowed ||
current.spaceId !== dot.spaceId ||
JSON.stringify(current.spaceIds) !== JSON.stringify(dot.spaceIds)
Expand Down Expand Up @@ -173,7 +178,18 @@ export class DotAgent extends AbstractAgent {
}).chat(this.config.model);
this.inner = new BuiltInAgent({
model,
maxSteps: 5,
learnedSkills:
dot.skillDeliveryEnabled && conversation.learningContainerId
? {
containers: [{ id: conversation.learningContainerId }],
apiKey: this.config.intelligenceKey,
apiUrl: this.config.intelligenceApiUrl,
}
: undefined,
maxSteps:
dot.skillDeliveryEnabled && conversation.learningContainerId
? 10
: 5,
maxOutputTokens: 2200,
maxRetries: 1,
tools: [
Expand Down
35 changes: 35 additions & 0 deletions src/server/learning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { CopilotKitIntelligence } from '@copilotkit/runtime/v2';
import type { WorkspaceStore } from './workspace.js';

type Selector = NonNullable<
ConstructorParameters<
typeof CopilotKitIntelligence
>[0]['getLearningContainerId']
>;

/** Called before execution, including before a new channel thread reaches DotAgent. */
export function learningSelector(
workspace: WorkspaceStore,
channelDotId?: string,
): Selector {
return ({ surface, user, agentId, input }) => {
if (user?.id !== workspace.ownerId)
throw new Error('Conversation learning requires the workspace owner.');
if (surface === 'channel') {
if (agentId !== channelDotId)
throw new Error(
'Conversation learning requires the configured Slack Dot.',
);
if (
!workspace
.conversations()
.some((thread) => thread.id === input.threadId)
)
workspace.bindThread(input.threadId, agentId, 'Slack conversation');
}
return (
workspace.requireThread(input.threadId, agentId).learningContainerId ??
null
);
};
}
5 changes: 5 additions & 0 deletions src/server/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { DotAgent } from './dot-agent.js';
import { runThreadTurn } from './headless.js';
import { setupStatus, type PlatformConfig } from './platform-config.js';
import { validateRuntimeScope } from './runtime-scope.js';
import { learningSelector } from './learning.js';
export class Platform {
private channelStartupFailed = false;
readonly pages: PageService;
Expand All @@ -40,6 +41,10 @@ export class Platform {
apiKey: config.intelligenceKey,
apiUrl: config.intelligenceApiUrl,
wsUrl: config.intelligenceWsUrl,
getLearningContainerId: learningSelector(
workspace,
config.slackDotId ?? workspace.dots()[0]?.id,
),
});
const channels = [];
if (config.slackChannel && config.slackTeam && config.slackUsers.length) {
Expand Down
44 changes: 44 additions & 0 deletions src/server/workspace-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import { Hono } from 'hono';
import { z } from 'zod';
import { Platform } from './platform.js';
import { VoiceService } from './voice.js';
import {
learningContainerIdSchema,
validateLearningSettings,
} from '../shared/learning.js';
const dotSchema = z
.object({
name: z.string().trim().min(1).max(40),
instructions: z.string().trim().min(3).max(2000),
researchAllowed: z.boolean(),
memoryAllowed: z.boolean(),
learningContainerId: learningContainerIdSchema.optional(),
skillDeliveryEnabled: z.boolean().optional(),
spaceIds: z.array(z.string().min(1)).min(1).max(100).optional(),
spaceId: z.string().min(1).optional(),
})
Expand Down Expand Up @@ -55,6 +61,22 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
},
400,
);
try {
validateLearningSettings(
data.data.learningContainerId ?? null,
data.data.skillDeliveryEnabled ?? false,
);
} catch (error) {
return c.json(
{
error:
error instanceof Error
? error.message
: 'Invalid Learning settings.',
},
400,
);
}
return c.json(
platform.workspace.createDot(
data.data.spaceId,
Expand All @@ -63,6 +85,8 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
data.data.researchAllowed,
data.data.memoryAllowed,
data.data.spaceIds,
data.data.learningContainerId,
data.data.skillDeliveryEnabled,
),
201,
);
Expand All @@ -71,6 +95,26 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
const data = dotSchema.safeParse(await c.req.json());
if (!data.success)
return c.json({ error: 'Invalid specialist settings.' }, 400);
const current = platform.workspace.dot(c.req.param('id'));
if (!current) return c.json({ error: 'Dot not found.' }, 404);
try {
validateLearningSettings(
data.data.learningContainerId === undefined
? (current.learningContainerId ?? null)
: data.data.learningContainerId,
data.data.skillDeliveryEnabled ?? current.skillDeliveryEnabled ?? false,
);
} catch (error) {
return c.json(
{
error:
error instanceof Error
? error.message
: 'Invalid Learning settings.',
},
400,
);
}
return c.json(platform.workspace.updateDot(c.req.param('id'), data.data));
});
app.post('/conversations', async (c) => {
Expand Down
Loading
Loading