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:
hailin 2026-03-09 02:16:47 -07:00
parent fa2212c7bb
commit 96e336dd18
5 changed files with 136 additions and 111 deletions

View File

@ -4,12 +4,32 @@
* - Forwards task submissions to OpenClaw via WebSocket RPC
* - Returns status, session list, and metrics
* - 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 express from 'express';
import * as crypto from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
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 OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789';
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
app.get('/sessions', async (_req, res) => {
if (!ocClient.isConnected()) {

View File

@ -16,7 +16,6 @@ import { ClaudeApiEngine } from './infrastructure/engines/claude-api/claude-api-
import { ClaudeAgentSdkEngine } from './infrastructure/engines/claude-agent-sdk';
import { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor';
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 { AllowedToolsResolverService } from './domain/services/allowed-tools-resolver.service';
import { SessionRepository } from './infrastructure/repositories/session.repository';
@ -80,7 +79,6 @@ import { SystemPromptBuilder } from './infrastructure/engines/claude-code-cli/sy
ClaudeAgentSdkEngine,
ToolExecutor,
CommandGuardService,
SkillManagerService,
StandingOrderExtractorService,
AllowedToolsResolverService,
ConversationContextService,

View File

@ -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;
}
}

View File

@ -8,7 +8,6 @@ export interface SystemPromptContext {
userId?: string;
userEmail?: string;
serverContext?: string;
skills?: string[];
riskBoundary?: string;
additionalInstructions?: string;
/** 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
* 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()
export class SystemPromptBuilder {
@ -95,13 +101,6 @@ export class SystemPromptBuilder {
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
if (context.riskBoundary) {
parts.push(`\nRisk Boundary:\n${context.riskBoundary}`);

View File

@ -140,6 +140,61 @@ export class AgentInstanceController {
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')
async remove(@Param('id') id: string) {
const inst = await this.instanceRepo.findById(id);