feat(openclaw): OpenClaw skill injection pipeline — iAgent → bridge → SKILL.md
## Changes
### openclaw-bridge: POST /skill-inject
- New endpoint writes SKILL.md to ~/.openclaw/skills/{name}/ inside the container volume
- OpenClaw gateway file watcher picks it up within 250ms (no restart needed)
- Optionally calls sessions.delete RPC after write so the next user message starts
a fresh session that loads the new skill directory immediately (zero-downtime)
- Path traversal guard on skill name (rejects names with / .. \)
- OPENCLAW_HOME env var configurable (default: /home/node/.openclaw)
### agent-service: POST /api/v1/agent/instances/:id/skills
- New endpoint in AgentInstanceController proxies skill injection requests to the
instance's bridge (http://{serverHost}:{hostPort}/skill-inject)
- Guards: instance must be 'running', serverHost/hostPort must be set, content ≤ 100KB
- iAgent calls this internally (localhost:3002) via Python urllib — no Kong auth needed
- sessionKey format for DingTalk users: "agent:main:dt-{dingTalkUserId}"
### agent-service: remove dead SkillManagerService
- Deleted skill-manager.service.ts (file-system .md loader, never called by anything)
- Removed from agent.module.ts provider list
- The live skill path is ClaudeAgentSdkEngine.loadTenantSkills() which reads directly
from the DB (it0_t_{tenantId}.skills) at task-execution time
### agent-service: clean up SystemPromptBuilder
- Removed unused skills?: string[] from SystemPromptContext (was never populated)
- Added clarifying comment: SDK engine handles skill injection, not this builder
## DB
- Inserted iAgent meta-skill "为小龙虾安装技能" into it0_t_default.skills
(id: 79ac23ed-78c2-4d5f-8652-a99cf5185b61)
- Content instructs iAgent to: query user instances → generate SKILL.md → call
POST /api/v1/agent/instances/:id/skills via Python urllib heredoc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fa2212c7bb
commit
96e336dd18
|
|
@ -4,12 +4,32 @@
|
||||||
* - Forwards task submissions to OpenClaw via WebSocket RPC
|
* - Forwards task submissions to OpenClaw via WebSocket RPC
|
||||||
* - Returns status, session list, and metrics
|
* - Returns status, session list, and metrics
|
||||||
* - Reports heartbeat to IT0 agent-service
|
* - Reports heartbeat to IT0 agent-service
|
||||||
|
* - Injects SKILL.md files into the container volume (POST /skill-inject)
|
||||||
|
*
|
||||||
|
* Skill injection flow:
|
||||||
|
* iAgent (agent-service) receives user request → calls POST /api/v1/agent/instances/:id/skills
|
||||||
|
* → agent-service forwards to this bridge's POST /skill-inject
|
||||||
|
* → bridge writes SKILL.md to ~/.openclaw/skills/{name}/
|
||||||
|
* → OpenClaw gateway file watcher picks it up within 250ms
|
||||||
|
* → bridge deletes the current OpenClaw session (sessions.delete RPC) so the
|
||||||
|
* next user message opens a fresh session that loads the new skill directory
|
||||||
|
*
|
||||||
|
* Skill directory layout (inside the container volume):
|
||||||
|
* $OPENCLAW_HOME/skills/
|
||||||
|
* └── {skill-slug}/
|
||||||
|
* └── SKILL.md ← YAML frontmatter (name, description) + markdown body
|
||||||
*/
|
*/
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
import { OpenClawClient } from './openclaw-client';
|
import { OpenClawClient } from './openclaw-client';
|
||||||
|
|
||||||
|
// Skills live in the mounted OpenClaw home dir
|
||||||
|
const OPENCLAW_HOME = process.env.OPENCLAW_HOME ?? '/home/node/.openclaw';
|
||||||
|
const SKILLS_DIR = path.join(OPENCLAW_HOME, 'skills');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.BRIDGE_PORT ?? '3000', 10);
|
const PORT = parseInt(process.env.BRIDGE_PORT ?? '3000', 10);
|
||||||
const OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789';
|
const OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789';
|
||||||
const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? '';
|
const OPENCLAW_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? '';
|
||||||
|
|
@ -150,6 +170,59 @@ app.post('/task-async', async (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Inject a skill into this OpenClaw instance.
|
||||||
|
//
|
||||||
|
// Writes SKILL.md to ~/.openclaw/skills/{name}/ so the gateway's file watcher
|
||||||
|
// picks it up within 250ms. Then optionally deletes the caller's current session
|
||||||
|
// so the next message starts a fresh session — new sessions pick up newly added
|
||||||
|
// skill directories immediately, giving the user a seamless experience.
|
||||||
|
//
|
||||||
|
// Request body:
|
||||||
|
// name — skill directory name (slug, e.g. "it0-weather")
|
||||||
|
// content — full SKILL.md content (markdown with YAML frontmatter)
|
||||||
|
// sessionKey — (optional) current session key to delete after injection
|
||||||
|
//
|
||||||
|
// Response: { ok, injected, sessionCleared }
|
||||||
|
app.post('/skill-inject', async (req, res) => {
|
||||||
|
const { name, content, sessionKey } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string' || !content || typeof content !== 'string') {
|
||||||
|
res.status(400).json({ error: 'name and content are required strings' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prevent path traversal
|
||||||
|
if (name.includes('/') || name.includes('..') || name.includes('\\')) {
|
||||||
|
res.status(400).json({ error: 'invalid skill name' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillDir = path.join(SKILLS_DIR, name);
|
||||||
|
try {
|
||||||
|
await fs.mkdir(skillDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf8');
|
||||||
|
console.log(`[bridge] Skill injected: ${name} → ${skillDir}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[bridge] Failed to write skill ${name}:`, err.message);
|
||||||
|
res.status(500).json({ error: `Failed to write skill: ${err.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the caller's current session so the next message opens a fresh session
|
||||||
|
// that picks up the newly added skill directory.
|
||||||
|
let sessionCleared = false;
|
||||||
|
if (sessionKey && typeof sessionKey === 'string' && ocClient.isConnected()) {
|
||||||
|
try {
|
||||||
|
await ocClient.rpc('sessions.delete', { sessionKey }, 5_000);
|
||||||
|
sessionCleared = true;
|
||||||
|
console.log(`[bridge] Session cleared after skill inject: ${sessionKey}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
// Non-fatal — skill is injected, session will naturally expire or be replaced
|
||||||
|
console.warn(`[bridge] Could not delete session ${sessionKey}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, injected: true, sessionCleared });
|
||||||
|
});
|
||||||
|
|
||||||
// List sessions
|
// List sessions
|
||||||
app.get('/sessions', async (_req, res) => {
|
app.get('/sessions', async (_req, res) => {
|
||||||
if (!ocClient.isConnected()) {
|
if (!ocClient.isConnected()) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { ClaudeApiEngine } from './infrastructure/engines/claude-api/claude-api-
|
||||||
import { ClaudeAgentSdkEngine } from './infrastructure/engines/claude-agent-sdk';
|
import { ClaudeAgentSdkEngine } from './infrastructure/engines/claude-agent-sdk';
|
||||||
import { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor';
|
import { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor';
|
||||||
import { CommandGuardService } from './infrastructure/guards/command-guard.service';
|
import { CommandGuardService } from './infrastructure/guards/command-guard.service';
|
||||||
import { SkillManagerService } from './domain/services/skill-manager.service';
|
|
||||||
import { StandingOrderExtractorService } from './domain/services/standing-order-extractor';
|
import { StandingOrderExtractorService } from './domain/services/standing-order-extractor';
|
||||||
import { AllowedToolsResolverService } from './domain/services/allowed-tools-resolver.service';
|
import { AllowedToolsResolverService } from './domain/services/allowed-tools-resolver.service';
|
||||||
import { SessionRepository } from './infrastructure/repositories/session.repository';
|
import { SessionRepository } from './infrastructure/repositories/session.repository';
|
||||||
|
|
@ -80,7 +79,6 @@ import { SystemPromptBuilder } from './infrastructure/engines/claude-code-cli/sy
|
||||||
ClaudeAgentSdkEngine,
|
ClaudeAgentSdkEngine,
|
||||||
ToolExecutor,
|
ToolExecutor,
|
||||||
CommandGuardService,
|
CommandGuardService,
|
||||||
SkillManagerService,
|
|
||||||
StandingOrderExtractorService,
|
StandingOrderExtractorService,
|
||||||
AllowedToolsResolverService,
|
AllowedToolsResolverService,
|
||||||
ConversationContextService,
|
ConversationContextService,
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export interface Skill {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
promptTemplate: string;
|
|
||||||
allowedTools?: string[];
|
|
||||||
maxRiskLevel?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SkillManagerService {
|
|
||||||
private readonly logger = new Logger(SkillManagerService.name);
|
|
||||||
private skills = new Map<string, Skill>();
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
this.loadSkills();
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadSkills(): void {
|
|
||||||
const skillsDir = this.configService.get<string>(
|
|
||||||
'SKILLS_DIR',
|
|
||||||
path.join(process.cwd(), 'skills'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(skillsDir)) {
|
|
||||||
this.logger.warn(`Skills directory not found: ${skillsDir}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(skillsDir).filter((f) => f.endsWith('.md'));
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, file), 'utf-8');
|
|
||||||
const skill = this.parseSkillFile(content);
|
|
||||||
if (skill) {
|
|
||||||
this.skills.set(skill.name, skill);
|
|
||||||
this.logger.log(`Loaded skill: ${skill.name}`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(`Failed to load skill from ${file}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Loaded ${this.skills.size} skill(s)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseSkillFile(content: string): Skill | null {
|
|
||||||
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
||||||
if (!frontMatterMatch) return null;
|
|
||||||
|
|
||||||
const frontMatter = frontMatterMatch[1];
|
|
||||||
const promptTemplate = frontMatterMatch[2].trim();
|
|
||||||
|
|
||||||
const name = this.extractField(frontMatter, 'name');
|
|
||||||
const description = this.extractField(frontMatter, 'description');
|
|
||||||
|
|
||||||
if (!name || !description) return null;
|
|
||||||
|
|
||||||
const allowedToolsRaw = this.extractField(frontMatter, 'allowedTools');
|
|
||||||
const allowedTools = allowedToolsRaw
|
|
||||||
? allowedToolsRaw.split(',').map((t) => t.trim())
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const maxRiskLevelRaw = this.extractField(frontMatter, 'maxRiskLevel');
|
|
||||||
const maxRiskLevel = maxRiskLevelRaw ? parseInt(maxRiskLevelRaw, 10) : undefined;
|
|
||||||
|
|
||||||
return { name, description, promptTemplate, allowedTools, maxRiskLevel };
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractField(frontMatter: string, field: string): string | undefined {
|
|
||||||
const match = frontMatter.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
|
||||||
return match ? match[1].trim() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSkill(name: string): Skill | undefined {
|
|
||||||
return this.skills.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
listSkills(): Skill[] {
|
|
||||||
return Array.from(this.skills.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
buildSkillPrompt(skillName: string, context: Record<string, string>): string {
|
|
||||||
const skill = this.skills.get(skillName);
|
|
||||||
if (!skill) {
|
|
||||||
throw new Error(`Skill not found: ${skillName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prompt = skill.promptTemplate;
|
|
||||||
for (const [key, value] of Object.entries(context)) {
|
|
||||||
prompt = prompt.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
|
||||||
}
|
|
||||||
return prompt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ export interface SystemPromptContext {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
serverContext?: string;
|
serverContext?: string;
|
||||||
skills?: string[];
|
|
||||||
riskBoundary?: string;
|
riskBoundary?: string;
|
||||||
additionalInstructions?: string;
|
additionalInstructions?: string;
|
||||||
/** Voice session ID — when set, uses the internal wget-based OAuth trigger endpoint
|
/** Voice session ID — when set, uses the internal wget-based OAuth trigger endpoint
|
||||||
|
|
@ -17,9 +16,16 @@ export interface SystemPromptContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds system prompts for Claude CLI sessions.
|
* Builds system prompts for Claude CLI and voice sessions.
|
||||||
* Generates and optionally saves system prompt files that provide
|
* Generates and optionally saves system prompt files that provide
|
||||||
* context about the tenant, server environment, and operational boundaries.
|
* context about the tenant, server environment, and operational boundaries.
|
||||||
|
*
|
||||||
|
* Note: tenant skill content (from the `skills` DB table) is NOT injected here.
|
||||||
|
* The ClaudeAgentSdkEngine handles skill injection directly by querying
|
||||||
|
* `it0_t_{tenantId}.skills` and appending them to the system prompt at
|
||||||
|
* task-execution time (see claude-agent-sdk-engine.ts → loadTenantSkills /
|
||||||
|
* buildSystemPromptWithSkills). This builder is only used for CLI and voice
|
||||||
|
* sessions where skills are not currently loaded.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemPromptBuilder {
|
export class SystemPromptBuilder {
|
||||||
|
|
@ -95,13 +101,6 @@ export class SystemPromptBuilder {
|
||||||
parts.push(`\nServer Environment:\n${context.serverContext}`);
|
parts.push(`\nServer Environment:\n${context.serverContext}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available skills
|
|
||||||
if (context.skills && context.skills.length > 0) {
|
|
||||||
parts.push(
|
|
||||||
`\nAvailable Skills: ${context.skills.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Risk boundary
|
// Risk boundary
|
||||||
if (context.riskBoundary) {
|
if (context.riskBoundary) {
|
||||||
parts.push(`\nRisk Boundary:\n${context.riskBoundary}`);
|
parts.push(`\nRisk Boundary:\n${context.riskBoundary}`);
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,61 @@ export class AgentInstanceController {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject a skill into a running OpenClaw instance.
|
||||||
|
*
|
||||||
|
* iAgent calls this (via internal wget) when a user asks to add a capability
|
||||||
|
* to their 小龙虾. The bridge writes SKILL.md to the container volume and
|
||||||
|
* resets the current OpenClaw session so the new skill takes effect immediately.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* name — skill slug, e.g. "it0-weather" (no slashes)
|
||||||
|
* content — full SKILL.md content (markdown with YAML frontmatter)
|
||||||
|
* sessionKey — (optional) active OpenClaw session key to reset after injection
|
||||||
|
* format: "agent:main:dt-<dingTalkUserId>" for DingTalk users
|
||||||
|
*/
|
||||||
|
@Post(':id/skills')
|
||||||
|
async injectSkill(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { name: string; content: string; sessionKey?: string },
|
||||||
|
) {
|
||||||
|
const inst = await this.instanceRepo.findById(id);
|
||||||
|
if (!inst) throw new NotFoundException(`Instance ${id} not found`);
|
||||||
|
if (inst.status !== 'running') {
|
||||||
|
throw new BadRequestException(`Instance ${id} is not running (status: ${inst.status})`);
|
||||||
|
}
|
||||||
|
if (!body.name || !body.content) {
|
||||||
|
throw new BadRequestException('name and content are required');
|
||||||
|
}
|
||||||
|
if (body.content.length > 100_000) {
|
||||||
|
throw new BadRequestException('content too large (max 100KB)');
|
||||||
|
}
|
||||||
|
if (!inst.serverHost || !inst.hostPort) {
|
||||||
|
throw new BadRequestException(`Instance ${id} has no bridge address (still deploying?)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridgeUrl = `http://${inst.serverHost}:${inst.hostPort}`;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
name: body.name,
|
||||||
|
content: body.content,
|
||||||
|
...(body.sessionKey ? { sessionKey: body.sessionKey } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resp = await fetch(`${bridgeUrl}/skill-inject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: payload,
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.text().catch(() => resp.statusText);
|
||||||
|
throw new BadRequestException(`Bridge rejected skill injection: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string) {
|
||||||
const inst = await this.instanceRepo.findById(id);
|
const inst = await this.instanceRepo.findById(id);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue