Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions ballerina-interpreter/Dependencies.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

[ballerina]
dependencies-toml-version = "2"
distribution-version = "2201.12.7"
distribution-version = "2201.12.10"

[[package]]
org = "ballerina"
Expand Down Expand Up @@ -90,7 +90,7 @@ dependencies = [
[[package]]
org = "ballerina"
name = "data.xmldata"
version = "1.5.2"
version = "1.6.1"
dependencies = [
{org = "ballerina", name = "jballerina.java"},
{org = "ballerina", name = "lang.object"}
Expand All @@ -117,6 +117,9 @@ dependencies = [
{org = "ballerina", name = "os"},
{org = "ballerina", name = "time"}
]
modules = [
{org = "ballerina", packageName = "file", moduleName = "file"}
]

[[package]]
org = "ballerina"
Expand Down Expand Up @@ -468,7 +471,7 @@ modules = [
[[package]]
org = "ballerinax"
name = "ai.anthropic"
version = "1.3.0"
version = "1.3.1"
dependencies = [
{org = "ballerina", name = "ai"},
{org = "ballerina", name = "constraint"},
Expand Down Expand Up @@ -530,6 +533,7 @@ version = "0.1.0"
dependencies = [
{org = "ballerina", name = "ai"},
{org = "ballerina", name = "data.yaml"},
{org = "ballerina", name = "file"},
{org = "ballerina", name = "http"},
{org = "ballerina", name = "io"},
{org = "ballerina", name = "jballerina.java"},
Expand Down
10 changes: 10 additions & 0 deletions ballerina-interpreter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,17 @@ docker run -v /path/to/agent.afm.md:/app/agent.afm.md \
-e afmFilePath=/app/agent.afm.md \
-p 8085:8085 \
afm-ballerina-interpreter

# Run with skills (mount the skills directory so the agent can discover them)
docker run -v /path/to/agent.afm.md:/app/agent.afm.md \
-v /path/to/skills:/app/skills \
-e afmFilePath=/app/agent.afm.md \
-p 8085:8085 \
afm-ballerina-interpreter
```

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.

## Testing

```bash
Expand All @@ -68,6 +77,7 @@ ballerina-interpreter/
├── interface_web_chat.bal # Web chat HTTP API
├── interface_web_ui.bal # Web chat UI
├── interface_webhook.bal # Webhook/WebSub handler
├── skills.bal # Agent Skills discovery & toolkit
├── modules/
│ └── everit.validator/ # JSON Schema validation
├── tests/ # Test files
Expand Down
40 changes: 27 additions & 13 deletions ballerina-interpreter/agent.bal
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ import ballerina/http;
import ballerinax/ai.anthropic;
import ballerinax/ai.openai;

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

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

string[]? filteredTools = getFilteredTools(mcpConn.tool_filter);
mcpToolkits.push(check new ai:McpToolKit(
mcpToolKits.push(check new ai:McpToolKit(
transport.url,
permittedTools = filteredTools,
auth = check mapToHttpClientAuth(transport.authentication)
));
}
}

[string, SkillsToolKit]? catalog = check extractSkillCatalog(metadata, afmFileDir);

string effectiveInstructions;
(ai:BaseToolKit)[] toolKits;

if catalog is () {
effectiveInstructions = instructions;
toolKits = mcpToolKits;
} else {
effectiveInstructions = string `${instructions}\n\n${catalog[0]}`;
toolKits = [...mcpToolKits, catalog[1]];
}

ai:ModelProvider model = check getModel(metadata?.model);

ai:AgentConfiguration agentConfig = {
systemPrompt: {
role,
instructions
role,
instructions: effectiveInstructions
},
tools: mcpToolkits,
tools: toolKits,
model
};

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

provider = provider.toLowerAscii();
string providerLower = provider.toLowerAscii();

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

match provider {
match providerLower {
"openai" => {
return new openai:ModelProvider(
check getApiKey(model.authentication),
Expand All @@ -115,12 +128,13 @@ function getModel(Model? model) returns ai:ModelProvider|error {
);
}
}
return error(string `Model provider: ${<string>provider} not yet supported`);
return error(string `Model provider: ${provider} not yet supported`);
}

const DEFAULT_SESSION_ID = "sessionId";

function runAgent(ai:Agent agent, json payload, map<json>? inputSchema = (), map<json>? outputSchema = (), string sessionId = DEFAULT_SESSION_ID)
function runAgent(ai:Agent agent, json payload, map<json>? inputSchema = (),
map<json>? outputSchema = (), string sessionId = DEFAULT_SESSION_ID)
returns json|InputError|AgentError {
error? validateJsonSchemaResult = validateJsonSchema(inputSchema, payload);
if validateJsonSchemaResult is error {
Expand Down Expand Up @@ -275,7 +289,7 @@ isolated function validateJsonSchema(map<json>? jsonSchemaVal, json sampleJson)
validator:JSONObject jsonObject = validator:newJSONObject7(sampleJson.toJsonString());
error? validationResult = trap schema.validate(jsonObject);
if validationResult is error {
return error("JSON validation failed: " + validationResult.message());
return error(string `JSON validation failed: ${validationResult.message()}`);
}
return ();
}
Expand Down
13 changes: 7 additions & 6 deletions ballerina-interpreter/main.bal
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// under the License.

import ballerina/ai;
import ballerina/file;
import ballerina/http;
import ballerina/io;
import ballerina/log;
Expand All @@ -41,22 +42,22 @@ public function main(string? filePath = ()) returns error? {
fileToUse = filePath;
}


string content = check io:fileReadString(fileToUse);
string afmFileDir = check file:parentPath(check file:getAbsolutePath(fileToUse));

AFMRecord afm = check parseAfm(content);
check runAgentFromAFM(afm, port);
check runAgentFromAFM(afm, port, afmFileDir);
}

function runAgentFromAFM(AFMRecord afm, int port) returns error? {
AgentMetadata metadata = afm.metadata;
function runAgentFromAFM(AFMRecord afm, int port, string afmFileDir) returns error? {
AgentMetadata? metadata = afm?.metadata;

Interface[] agentInterfaces = metadata.interfaces ?: [<ConsoleChatInterface>{}];
Interface[] agentInterfaces = metadata?.interfaces ?: [<ConsoleChatInterface>{}];

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

ai:Agent agent = check createAgent(afm);
ai:Agent agent = check createAgent(afm, afmFileDir);

// Start all service-based interfaces first (non-blocking)
http:Listener? httpListener = ();
Expand Down
96 changes: 59 additions & 37 deletions ballerina-interpreter/parser.bal
Original file line number Diff line number Diff line change
Expand Up @@ -19,40 +19,25 @@ import ballerina/os;

function parseAfm(string content) returns AFMRecord|error {
string resolvedContent = check resolveVariables(content);

string[] lines = splitLines(resolvedContent);
int length = lines.length();


AgentMetadata? metadata = ();
int bodyStart = 0;

// Extract and parse YAML frontmatter
if length > 0 && lines[0].trim() == FRONTMATTER_DELIMITER {
int i = 1;
while i < length && lines[i].trim() != FRONTMATTER_DELIMITER {
i += 1;
}

if i < length {
string[] fmLines = [];
foreach int j in 1 ..< i {
fmLines.push(lines[j]);
}
string yamlContent = string:'join("\n", ...fmLines);
map<json> intermediate = check yaml:parseString(yamlContent);
metadata = check intermediate.fromJsonWithType();
bodyStart = i + 1;
}
string body;
if resolvedContent.startsWith(FRONTMATTER_DELIMITER) {
map<json> frontmatterMap;
[frontmatterMap, body] = check extractFrontMatter(resolvedContent);
metadata = check frontmatterMap.fromJsonWithType();
} else {
body = resolvedContent;
}

// Extract Role and Instructions sections
string[] bodyLines = splitLines(body);
string role = "";
string instructions = "";
boolean inRole = false;
boolean inInstructions = false;

foreach int k in bodyStart ..< length {
string line = lines[k];

foreach string line in bodyLines {
string trimmed = line.trim();

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

AFMRecord afmRecord = {
metadata: check metadata.ensureType(),
metadata,
role: role.trim(),
instructions: instructions.trim()
};
Expand Down Expand Up @@ -161,7 +146,12 @@ function validateHttpVariables(AFMRecord afmRecord) returns error? {
return error("http: variables are only supported in webhook prompt fields, found in instructions section");
}

AgentMetadata {authors, provider, model, interfaces, tools, max_iterations: _, ...rest} = afmRecord.metadata;
AgentMetadata? metadata = afmRecord?.metadata;
if metadata is () {
return;
}

AgentMetadata {authors, provider, model, interfaces, tools, skills, max_iterations: _, ...rest} = metadata;

string[] erroredKeys = [];

Expand Down Expand Up @@ -285,6 +275,14 @@ function validateHttpVariables(AFMRecord afmRecord) returns error? {
}
}

if skills is SkillSource[] {
foreach SkillSource skillSource in skills {
if containsHttpVariable(skillSource.path) {
erroredKeys.push("skills.path");
}
}
}

if erroredKeys.length() > 0 {
return error(string `http: variables are only supported in webhook prompt fields, found in metadata fields: ${string:'join(", ", ...erroredKeys)}`);
}
Expand Down Expand Up @@ -398,19 +396,43 @@ function toolFilterContainsHttpVariable(ToolFilter? filter) returns boolean {
return false;
}

// Extracts YAML frontmatter and the remaining body from a document delimited by `---`.
// Returns the parsed YAML as a map and the body text after the closing delimiter.
function extractFrontMatter(string content) returns [map<json>, string]|error {
string[] lines = splitLines(content);
int length = lines.length();

if length == 0 || lines[0].trim() != FRONTMATTER_DELIMITER {
return error("Document must start with YAML frontmatter (---)");
}

int i = 1;
while i < length && lines[i].trim() != FRONTMATTER_DELIMITER {
i += 1;
}

if i >= length {
return error("Frontmatter is not closed (missing ---)");
}

string yamlContent = string:'join("\n", ...lines.slice(1, i));
map<json> frontmatter = check yaml:parseString(yamlContent);
string body = string:'join("\n", ...lines.slice(i + 1));
return [frontmatter, body];
}

function splitLines(string content) returns string[] {
string[] result = [];
string remaining = content;
int length = content.length();
int 'start = 0;

while true {
int? idx = remaining.indexOf("\n");
while 'start < length {
int? idx = content.indexOf("\n", 'start);
if idx is int {
result.push(remaining.substring(0, idx));
remaining = remaining.substring(idx + 1);
result.push(content.substring('start, idx));
'start = idx + 1;
} else {
if remaining.length() > 0 {
result.push(remaining);
}
result.push(content.substring('start));
break;
}
}
Expand Down
Loading