Skip to content

Commit 086bc07

Browse files
authored
Merge pull request #13 from MaryamZi/support-agent-skills
Support Agent Skills with the Ballerina Interpreter
2 parents 1493056 + f97c1a3 commit 086bc07

File tree

26 files changed

+1096
-72
lines changed

26 files changed

+1096
-72
lines changed

ballerina-interpreter/Dependencies.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
[ballerina]
77
dependencies-toml-version = "2"
8-
distribution-version = "2201.12.7"
8+
distribution-version = "2201.12.10"
99

1010
[[package]]
1111
org = "ballerina"
@@ -90,7 +90,7 @@ dependencies = [
9090
[[package]]
9191
org = "ballerina"
9292
name = "data.xmldata"
93-
version = "1.5.2"
93+
version = "1.6.1"
9494
dependencies = [
9595
{org = "ballerina", name = "jballerina.java"},
9696
{org = "ballerina", name = "lang.object"}
@@ -117,6 +117,9 @@ dependencies = [
117117
{org = "ballerina", name = "os"},
118118
{org = "ballerina", name = "time"}
119119
]
120+
modules = [
121+
{org = "ballerina", packageName = "file", moduleName = "file"}
122+
]
120123

121124
[[package]]
122125
org = "ballerina"
@@ -468,7 +471,7 @@ modules = [
468471
[[package]]
469472
org = "ballerinax"
470473
name = "ai.anthropic"
471-
version = "1.3.0"
474+
version = "1.3.1"
472475
dependencies = [
473476
{org = "ballerina", name = "ai"},
474477
{org = "ballerina", name = "constraint"},
@@ -530,6 +533,7 @@ version = "0.1.0"
530533
dependencies = [
531534
{org = "ballerina", name = "ai"},
532535
{org = "ballerina", name = "data.yaml"},
536+
{org = "ballerina", name = "file"},
533537
{org = "ballerina", name = "http"},
534538
{org = "ballerina", name = "io"},
535539
{org = "ballerina", name = "jballerina.java"},

ballerina-interpreter/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,17 @@ docker run -v /path/to/agent.afm.md:/app/agent.afm.md \
4848
-e afmFilePath=/app/agent.afm.md \
4949
-p 8085:8085 \
5050
afm-ballerina-interpreter
51+
52+
# Run with skills (mount the skills directory so the agent can discover them)
53+
docker run -v /path/to/agent.afm.md:/app/agent.afm.md \
54+
-v /path/to/skills:/app/skills \
55+
-e afmFilePath=/app/agent.afm.md \
56+
-p 8085:8085 \
57+
afm-ballerina-interpreter
5158
```
5259

60+
When using local skills, the `path` in the AFM file should be relative to the AFM file's location. For example, if the AFM file is at `/app/agent.afm.md` and skills are mounted at `/app/skills`, use `path: "./skills"` in the AFM file.
61+
5362
## Testing
5463

5564
```bash
@@ -68,6 +77,7 @@ ballerina-interpreter/
6877
├── interface_web_chat.bal # Web chat HTTP API
6978
├── interface_web_ui.bal # Web chat UI
7079
├── interface_webhook.bal # Webhook/WebSub handler
80+
├── skills.bal # Agent Skills discovery & toolkit
7181
├── modules/
7282
│ └── everit.validator/ # JSON Schema validation
7383
├── tests/ # Test files

ballerina-interpreter/agent.bal

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import ballerina/http;
2323
import ballerinax/ai.anthropic;
2424
import ballerinax/ai.openai;
2525

26-
function createAgent(AFMRecord afmRecord) returns ai:Agent|error {
26+
function createAgent(AFMRecord afmRecord, string afmFileDir) returns ai:Agent|error {
2727
AFMRecord {metadata, role, instructions} = afmRecord;
2828

29-
ai:McpToolKit[] mcpToolkits = [];
29+
ai:McpToolKit[] mcpToolKits = [];
3030
MCPServer[]? mcpServers = metadata?.tools?.mcp;
3131
if mcpServers is MCPServer[] {
3232
foreach MCPServer mcpConn in mcpServers {
@@ -36,22 +36,35 @@ function createAgent(AFMRecord afmRecord) returns ai:Agent|error {
3636
}
3737

3838
string[]? filteredTools = getFilteredTools(mcpConn.tool_filter);
39-
mcpToolkits.push(check new ai:McpToolKit(
39+
mcpToolKits.push(check new ai:McpToolKit(
4040
transport.url,
4141
permittedTools = filteredTools,
4242
auth = check mapToHttpClientAuth(transport.authentication)
4343
));
4444
}
4545
}
4646

47+
[string, SkillsToolKit]? catalog = check extractSkillCatalog(metadata, afmFileDir);
48+
49+
string effectiveInstructions;
50+
(ai:BaseToolKit)[] toolKits;
51+
52+
if catalog is () {
53+
effectiveInstructions = instructions;
54+
toolKits = mcpToolKits;
55+
} else {
56+
effectiveInstructions = string `${instructions}\n\n${catalog[0]}`;
57+
toolKits = [...mcpToolKits, catalog[1]];
58+
}
59+
4760
ai:ModelProvider model = check getModel(metadata?.model);
48-
61+
4962
ai:AgentConfiguration agentConfig = {
5063
systemPrompt: {
51-
role,
52-
instructions
64+
role,
65+
instructions: effectiveInstructions
5366
},
54-
tools: mcpToolkits,
67+
tools: toolKits,
5568
model
5669
};
5770

@@ -85,9 +98,9 @@ function getModel(Model? model) returns ai:ModelProvider|error {
8598
return error("This implementation requires the 'provider' of the model to be specified");
8699
}
87100

88-
provider = provider.toLowerAscii();
101+
string providerLower = provider.toLowerAscii();
89102

90-
if provider == "wso2" {
103+
if providerLower == "wso2" {
91104
return new ai:Wso2ModelProvider(
92105
model.url ?: "https://dev-tools.wso2.com/ballerina-copilot/v2.0",
93106
check getToken(model.authentication)
@@ -99,7 +112,7 @@ function getModel(Model? model) returns ai:ModelProvider|error {
99112
return error("This implementation requires the 'name' of the model to be specified");
100113
}
101114

102-
match provider {
115+
match providerLower {
103116
"openai" => {
104117
return new openai:ModelProvider(
105118
check getApiKey(model.authentication),
@@ -115,12 +128,13 @@ function getModel(Model? model) returns ai:ModelProvider|error {
115128
);
116129
}
117130
}
118-
return error(string `Model provider: ${<string>provider} not yet supported`);
131+
return error(string `Model provider: ${provider} not yet supported`);
119132
}
120133

121134
const DEFAULT_SESSION_ID = "sessionId";
122135

123-
function runAgent(ai:Agent agent, json payload, map<json>? inputSchema = (), map<json>? outputSchema = (), string sessionId = DEFAULT_SESSION_ID)
136+
function runAgent(ai:Agent agent, json payload, map<json>? inputSchema = (),
137+
map<json>? outputSchema = (), string sessionId = DEFAULT_SESSION_ID)
124138
returns json|InputError|AgentError {
125139
error? validateJsonSchemaResult = validateJsonSchema(inputSchema, payload);
126140
if validateJsonSchemaResult is error {
@@ -275,7 +289,7 @@ isolated function validateJsonSchema(map<json>? jsonSchemaVal, json sampleJson)
275289
validator:JSONObject jsonObject = validator:newJSONObject7(sampleJson.toJsonString());
276290
error? validationResult = trap schema.validate(jsonObject);
277291
if validationResult is error {
278-
return error("JSON validation failed: " + validationResult.message());
292+
return error(string `JSON validation failed: ${validationResult.message()}`);
279293
}
280294
return ();
281295
}

ballerina-interpreter/main.bal

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// under the License.
1616

1717
import ballerina/ai;
18+
import ballerina/file;
1819
import ballerina/http;
1920
import ballerina/io;
2021
import ballerina/log;
@@ -41,22 +42,22 @@ public function main(string? filePath = ()) returns error? {
4142
fileToUse = filePath;
4243
}
4344

44-
4545
string content = check io:fileReadString(fileToUse);
46+
string afmFileDir = check file:parentPath(check file:getAbsolutePath(fileToUse));
4647

4748
AFMRecord afm = check parseAfm(content);
48-
check runAgentFromAFM(afm, port);
49+
check runAgentFromAFM(afm, port, afmFileDir);
4950
}
5051

51-
function runAgentFromAFM(AFMRecord afm, int port) returns error? {
52-
AgentMetadata metadata = afm.metadata;
52+
function runAgentFromAFM(AFMRecord afm, int port, string afmFileDir) returns error? {
53+
AgentMetadata? metadata = afm?.metadata;
5354

54-
Interface[] agentInterfaces = metadata.interfaces ?: [<ConsoleChatInterface>{}];
55+
Interface[] agentInterfaces = metadata?.interfaces ?: [<ConsoleChatInterface>{}];
5556

5657
var [consoleChatInterface, webChatInterface, webhookInterface] =
5758
check validateAndExtractInterfaces(agentInterfaces);
5859

59-
ai:Agent agent = check createAgent(afm);
60+
ai:Agent agent = check createAgent(afm, afmFileDir);
6061

6162
// Start all service-based interfaces first (non-blocking)
6263
http:Listener? httpListener = ();

ballerina-interpreter/parser.bal

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,40 +19,25 @@ import ballerina/os;
1919

2020
function parseAfm(string content) returns AFMRecord|error {
2121
string resolvedContent = check resolveVariables(content);
22-
23-
string[] lines = splitLines(resolvedContent);
24-
int length = lines.length();
25-
22+
2623
AgentMetadata? metadata = ();
27-
int bodyStart = 0;
28-
29-
// Extract and parse YAML frontmatter
30-
if length > 0 && lines[0].trim() == FRONTMATTER_DELIMITER {
31-
int i = 1;
32-
while i < length && lines[i].trim() != FRONTMATTER_DELIMITER {
33-
i += 1;
34-
}
35-
36-
if i < length {
37-
string[] fmLines = [];
38-
foreach int j in 1 ..< i {
39-
fmLines.push(lines[j]);
40-
}
41-
string yamlContent = string:'join("\n", ...fmLines);
42-
map<json> intermediate = check yaml:parseString(yamlContent);
43-
metadata = check intermediate.fromJsonWithType();
44-
bodyStart = i + 1;
45-
}
24+
string body;
25+
if resolvedContent.startsWith(FRONTMATTER_DELIMITER) {
26+
map<json> frontmatterMap;
27+
[frontmatterMap, body] = check extractFrontMatter(resolvedContent);
28+
metadata = check frontmatterMap.fromJsonWithType();
29+
} else {
30+
body = resolvedContent;
4631
}
47-
32+
4833
// Extract Role and Instructions sections
34+
string[] bodyLines = splitLines(body);
4935
string role = "";
5036
string instructions = "";
5137
boolean inRole = false;
5238
boolean inInstructions = false;
53-
54-
foreach int k in bodyStart ..< length {
55-
string line = lines[k];
39+
40+
foreach string line in bodyLines {
5641
string trimmed = line.trim();
5742

5843
if trimmed.startsWith("# ") {
@@ -70,7 +55,7 @@ function parseAfm(string content) returns AFMRecord|error {
7055
}
7156

7257
AFMRecord afmRecord = {
73-
metadata: check metadata.ensureType(),
58+
metadata,
7459
role: role.trim(),
7560
instructions: instructions.trim()
7661
};
@@ -161,7 +146,12 @@ function validateHttpVariables(AFMRecord afmRecord) returns error? {
161146
return error("http: variables are only supported in webhook prompt fields, found in instructions section");
162147
}
163148

164-
AgentMetadata {authors, provider, model, interfaces, tools, max_iterations: _, ...rest} = afmRecord.metadata;
149+
AgentMetadata? metadata = afmRecord?.metadata;
150+
if metadata is () {
151+
return;
152+
}
153+
154+
AgentMetadata {authors, provider, model, interfaces, tools, skills, max_iterations: _, ...rest} = metadata;
165155

166156
string[] erroredKeys = [];
167157

@@ -285,6 +275,14 @@ function validateHttpVariables(AFMRecord afmRecord) returns error? {
285275
}
286276
}
287277

278+
if skills is SkillSource[] {
279+
foreach SkillSource skillSource in skills {
280+
if containsHttpVariable(skillSource.path) {
281+
erroredKeys.push("skills.path");
282+
}
283+
}
284+
}
285+
288286
if erroredKeys.length() > 0 {
289287
return error(string `http: variables are only supported in webhook prompt fields, found in metadata fields: ${string:'join(", ", ...erroredKeys)}`);
290288
}
@@ -398,19 +396,43 @@ function toolFilterContainsHttpVariable(ToolFilter? filter) returns boolean {
398396
return false;
399397
}
400398

399+
// Extracts YAML frontmatter and the remaining body from a document delimited by `---`.
400+
// Returns the parsed YAML as a map and the body text after the closing delimiter.
401+
function extractFrontMatter(string content) returns [map<json>, string]|error {
402+
string[] lines = splitLines(content);
403+
int length = lines.length();
404+
405+
if length == 0 || lines[0].trim() != FRONTMATTER_DELIMITER {
406+
return error("Document must start with YAML frontmatter (---)");
407+
}
408+
409+
int i = 1;
410+
while i < length && lines[i].trim() != FRONTMATTER_DELIMITER {
411+
i += 1;
412+
}
413+
414+
if i >= length {
415+
return error("Frontmatter is not closed (missing ---)");
416+
}
417+
418+
string yamlContent = string:'join("\n", ...lines.slice(1, i));
419+
map<json> frontmatter = check yaml:parseString(yamlContent);
420+
string body = string:'join("\n", ...lines.slice(i + 1));
421+
return [frontmatter, body];
422+
}
423+
401424
function splitLines(string content) returns string[] {
402425
string[] result = [];
403-
string remaining = content;
426+
int length = content.length();
427+
int 'start = 0;
404428

405-
while true {
406-
int? idx = remaining.indexOf("\n");
429+
while 'start < length {
430+
int? idx = content.indexOf("\n", 'start);
407431
if idx is int {
408-
result.push(remaining.substring(0, idx));
409-
remaining = remaining.substring(idx + 1);
432+
result.push(content.substring('start, idx));
433+
'start = idx + 1;
410434
} else {
411-
if remaining.length() > 0 {
412-
result.push(remaining);
413-
}
435+
result.push(content.substring('start));
414436
break;
415437
}
416438
}

0 commit comments

Comments
 (0)