DiscourseにAIエージェントのスキルを追加する

業界全体で広く採用されている非常に一般的なパターンに、Agent Skills(エージェントスキル)があります。

スキルは、エージェントのコンテキスト問題を解決します。

すべての指示やアイデアを巨大なシステムプロンプトに詰め込む代わりに、エージェントスキルは段階的な情報開示(progressive disclosure)を可能にします。

エージェントは自分が何ができるかを知っており、具体的な方法を見つけるために非常に特定されたツールを呼び出します。

これはDiscourse上で大きな利点をもたらします。なぜなら、スキルのカタログをトピックとして保持しておき、優れたエディタ、履歴、権限管理を利用できるからです。

Discourse AIのカスタムツールを使用することで、彼らが提供する2つの強力な機能により、このシステムを近似できます。

  • カスタムツールはシステムプロンプトの修正を可能にします
  • カスタムツールは特定のカテゴリ内のトピックや投稿を読み取ることができます。

目標

Discourse上でスキルを近似するために、以下を実現したいと考えています。

  1. スキルごとに1つのトピック
  2. システムプロンプト内での利用可能なスキルディレクトリの自動生成

例えば、以下のような形状のトピック(raw形式)を想定しています。

---
name: doc-coauthoring
description: ドキュメントの共同作成のための構造化ワークフローをユーザーに案内します。ユーザーが提案書、技術仕様書、意思決定記録、または同様のドキュメントを作成したい場合に使用します。
---

# ドキュメント共同作成ワークフロー

1. ユーザーからコンテキストを収集します。
2. アウトラインに合意します。
3. 各セクションをドラフトし、洗練させます。
4. 新しい読者の視点からドキュメントをテストします。

これをシステムプロンプト内で以下のように変換します。

<available_skills>
  <skill>
    <name>doc-coauthoring</name>
    <description>ドキュメントの共同作成のための構造化ワークフローをユーザーに案内します...</description>
    <location>https://example.com/t/skill-doc-coauthoring/123</location>
  </skill>
  ... ここにさらにスキルが追加 ... 
</available_skills>

この特定の形式を選択した理由は、多くの言語モデルがすでにこの特定の形状を検索するようにファインチューニングされており、リコール(想起率)を向上させる可能性があるためです。実際のスキルの本文が欠落していることに注意してください。

その後、モデルがユーザーがドキュメント共同作成のワークフローを試みていると検知すると、実際の指示を取得するためにカスタムツール load_skill を呼び出します。

これにより、エージェントに使用させたいスキルを含む専用のDiscourseカテゴリを定義し、コミュニティメンバーがそれらを反復改善できるようにできます。

仕組みは?

load_skill の全ロジックは、カスタムツールを使用してJavaScriptで記述されます。

discourse.filterTopics を呼び出して、特定のカテゴリ内のトピックを検索できます。
discourse.getPost を呼び出して、投稿の本文を取得できます。
customSystemMessage はカスタムシステムプロンプトを注入するために使用されます。

これらのビルディングブロックを使用します。

まず、新しいカスタムツールを定義します。

  • 名前: load_skill
  • パラメータ:
    • “name”: “available_skillsからのスキル名”

次に、スキル解析を教えます。

var SKILLS_CATEGORY_SLUG = "agent-skills";
var SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

function skillTopics() {
  var result = discourse.filterTopics({
    q: "category:" + SKILLS_CATEGORY_SLUG + " order:created",
    limit: 200,
  });

  return result && Array.isArray(result.topics) ? result.topics : [];
}

function topicOp(topic) {
  if (!topic || !topic.first_post_id) {
    return null;
  }

  return discourse.getPost(topic.first_post_id);
}

function frontmatter(raw) {
  if (typeof raw !== "string") {
    return null;
  }

  var match = raw.match(
    /^---[ \t]*\n([\s\S]*?)\n---(?:[ \t]*\n|$)/
  );

  return match ? match[1] : null;
}

function scalarField(yaml, field) {
  var match = yaml.match(
    new RegExp("^" + field + ":[ \\t]*(.+)[ \\t]*$", "m")
  );

  return match ? match[1].trim() : null;
}

function xmlEscape(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/\"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

function skillRecord(topic) {
  var post = topicOp(topic);
  var yaml = post && frontmatter(post.raw);

  if (!yaml) {
    return null;
  }

  var name = scalarField(yaml, "name");
  var description = scalarField(yaml, "description");

  if (
    !name ||
    !description ||
    !SKILL_NAME_PATTERN.test(name) ||
    name.length > 64 ||
    description.length > 1024
  ) {
    return null;
  }

  return {
    name: name,
    description: description,
    location: discourse.baseUrl + topic.url,
    content: post.raw,
  };
}

function availableSkills() {
  var records = [];
  var topics = skillTopics();

  for (var i = 0; i < topics.length; i++) {
    var record = skillRecord(topics[i]);
    if (record) {
      records.push(record);
    }
  }

  records.sort(function (a, b) {
    return a.name.localeCompare(b.name);
  });

  return records;
}

SKILLS_CATEGORY_SLUG を、スキルを含むカテゴリの実際のスラグに設定してください。

次に、customSystemMessageを使用してシステムプロンプトを注入します。

function customSystemMessage() {
  var skills = availableSkills();

  if (skills.length === 0) {
    return null;
  }

  var lines = [
    "スキルは、特定のタスク向けの専門的な指示とワークフローを提供します。",
    "タスクがその説明に一致する場合は、load_skillツールを使用してスキルをロードしてください。",
    "<available_skills>",
  ];

  skills.forEach(function (skill) {
    lines.push("  <skill>");
    lines.push("    <name>" + xmlEscape(skill.name) + "</name>");
    lines.push(
      "    <description>" +
        xmlEscape(skill.description) +
        "</description>"
    );
    lines.push(
      "    <location>" + xmlEscape(skill.location) + "</location>"
    );
    lines.push("  </skill>");
  });

  lines.push("</available_skills>");
  return lines.join("\n");
}

最後に、スラグからスキルのコンテンツへの変換を行うスキルローダーを作成します。

function invoke(parameters) {
  var requestedName = parameters && parameters.name;

  if (typeof requestedName !== "string") {
    return "スキル名が指定されていません。";
  }

  requestedName = requestedName.trim();

  if (!SKILL_NAME_PATTERN.test(requestedName)) {
    return "スキル名には小文字、数字、ハイフンのみを使用してください。";
  }

  var matches = availableSkills().filter(function (skill) {
    return skill.name === requestedName;
  });

  if (matches.length === 0) {
    return "名前の " + requestedName + " を持つ利用可能なスキルはありません。";
  }

  if (matches.length > 1) {
    return "名前の " + requestedName + " を持つスキルが複数あります。";
  }

  var skill = matches[0];

  return [
    '<skill_content name="' + xmlEscape(skill.name) + '">',
    "# スキル: " + skill.name,
    "",
    skill.content.trim(),
    "",
    "このスキルのソーストピック: " + skill.location,
    "</skill_content>",
  ].join("\n");
}

完全な動作するエージェントとカスタムツールのJSONはここで入手できます。インスタンスにインポートし、必要に応じて修正できます。

category-skills-agent.json (5.9 KB)

実際の動作

制限事項

現在のデモでは、N+1呼び出しの問題があり、各ユーザーターンでスキルを持つすべての投稿をロードしています。大規模な環境では推奨されない可能性があります。

ワークフローを使用してスキルカタログの投稿を自動的に生成するように設計を修正するか、filterTopics を拡張してプレフィックスを指定した呼び出しを許可するようにすることもできます。

また、エージェントスキルはリソースとスクリプトをサポートしており、リソースは「read」ツールを許可し、スキルから特定のトピックへのリンクを貼ることで近似できます。シェル実行は明らかにDiscourseの範囲外です。

これが役立つことを願っています

「いいね!」 1