赋予 Discourse AI 代理技能

在行业中,一种非常常见的模式被广泛应用于各种代理(Agent)中,那就是 Agent Skills(代理技能)。

技能解决了代理的上下文问题。

代理技能允许渐进式披露,而不是将所有指令和想法塞进一个巨大的系统提示(system prompt)中。

代理知道它能做什么,并调用非常具体的工具来查找如何执行。

这在 Discourse 中提供了显著优势,因为我们可以将技能目录保留在主题(topics)中,并获得良好的编辑器、历史记录和权限管理。

通过在 Discourse AI 中使用 自定义工具,我们可以利用它们提供的两个非常强大的功能来近似实现这一系统。

  • 自定义工具允许修改系统提示
  • 自定义工具可以读取特定类别中的主题和帖子

我们的目标

为了在 Discourse 上近似实现技能,我们希望:

  1. 每个技能对应一个主题
  2. 在系统提示中自动生成可用的技能目录

因此,例如,我们希望主题具有如下结构(原始格式):

---
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 设置为包含您技能的实际类别别名(slug)。

接下来,我们使用 customSystemMessage 注入系统提示

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

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

  var lines = [
    "Skills provide specialized instructions and workflows for specific tasks.",
    "Use the load_skill tool to load a skill when a task matches its description.",
    "<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 "No skill name was provided.";
  }

  requestedName = requestedName.trim();

  if (!SKILL_NAME_PATTERN.test(requestedName)) {
    return "Skill names must use lowercase letters, numbers, and hyphens.";
  }

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

  if (matches.length === 0) {
    return "No available skill has the name " + requestedName + ".";
  }

  if (matches.length > 1) {
    return "More than one skill has the name " + requestedName + ".";
  }

  var skill = matches[0];

  return [
    '<skill_content name="' + xmlEscape(skill.name) + '">',
    "# Skill: " + skill.name,
    "",
    skill.content.trim(),
    "",
    "Source topic for this skill: " + skill.location,
    "</skill_content>",
  ].join("\n");
}

完整的可工作代理 + 自定义工具 JSON 可在以下位置获取,您可以将其导入您的实例并根据需要进行修改:

category-skills-agent.json (5.9 KB)

查看实际效果

局限性

当前的演示仍然存在 N+1 调用问题,即在每次用户交互时加载每个带有技能的帖子。在大规模场景下,这可能不可取。

可以修改设计,以便通过工作流自动生成技能目录帖子,或者扩展 filterTopics 以允许进行前缀查询调用。

此外,代理技能支持资源和脚本,资源可以通过允许“读取”工具并从技能链接到特定主题来近似实现。Shell 执行显然超出了 Discourse 的范围。

希望这对您有所帮助

1 个赞