diff --git a/README.md b/README.md index 72a3968..71f57ec 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,25 @@ -ACP (Agent Control Plane) is a cloud-native orchestrator for AI Agents built on Kubernetes. It supports [long-lived outer-loop agents](https://theouterloop.substack.com/p/openais-realtime-api-is-a-step-towards) that can process asynchronous execution of both LLM inference and long-running tool calls. It's designed for simplicity and gives strong durability and reliability guarantees for agents that make asynchronous tool calls like contacting humans or delegating work to other agents. +## Introduction + +Agent Control Plane (ACP) is a cloud-native orchestrator for AI Agents built on Kubernetes. It's designed to help you build production-grade AI systems that are reliable, scalable, and maintainable. + +After building and deploying AI agents in production environments, we've learned that the most successful implementations are those that combine well-engineered software with strategic LLM integration. ACP embodies these lessons by providing: + +- **Durable Execution**: Built-in support for long-running, asynchronous operations with automatic checkpointing and recovery +- **Human-in-the-Loop**: Seamless integration with human approval workflows and input channels +- **Observability**: Comprehensive tracing and monitoring of agent execution +- **Scalability**: Kubernetes-native architecture for reliable distributed agent execution +- **Flexibility**: Support for multiple LLM providers and tool integration patterns + +ACP is particularly useful when you need to: +- Build agents that incorporate long-running tool calls reliably +- Incorporate human approval into your agent workflows +- Scale agent execution across a distributed system +- Maintain clear visibility into agent behavior and execution state + +Whether you're building a new AI-powered application or enhancing an existing system, ACP provides the infrastructure needed to deploy production-ready AI agents. :warning: **Note** - ACP is in alpha. Use at your own risk. @@ -1109,6 +1127,7 @@ Events: Normal AllToolCallsCompleted 7s task-controller All tool calls completed, ready to send tool results to LLM Normal LLMFinalAnswer 6s task-controller LLM response received successfully ``` + ### Open Telemetry support You can use the `acp-example` folder to spin up a cluster with an otel stack, to view Task execution traces in grafana + tempo @@ -1217,3 +1236,56 @@ ACP is open-source and we welcome contributions in the form of issues, documenta ## License ACP is licensed under the Apache 2 License. + + + + + +Writing + + + +Howdy HN - I'm Dex from HumanLayer (YC F24), and I've been building AI agents for a while. After trying every framework out there and talking to many founders building with AI, I've noticed something interesting: most "AI Agents" that make it to production aren't actually that agentic. The best ones are mostly just well-engineered software with LLMs sprinkled in at key points. +So I set out to document what I've learned about building production-grade AI systems. Today I'm excited to share "12 Factor Agents" + +https://github.com/humanlayer/12-factor-agents + +It's a set of principles for building LLM-powered software that's reliable enough to put in the hands of production customers. + +I've seen many SaaS builders try to pivot towards AI by building greenfield new projects on agent frameworks, only to find that they couldn't get things past the 70-80% reliability bar with out-of-the-box tools. The ones that did succeed tended to take small, modular concepts from agent building, and incorporate them into their existing product, rather than starting from scratch. + +In the spirit of Heroku's 12 Factor Apps (https://12factor.net/), these principles focus on the engineering practices that make LLM applications more reliable, scalable, and maintainable. Even as models get exponentially more powerful, these core techniques will remain valuable. + +Some highlights: + +- Factor 1: Natural Language to Tool Calls + +- Factor 2: Own your prompts + +- Factor 3: Own your context window + +- Factor 4: Tools are just structured outputs + +- Factor 5: Unify execution and business state + +- Factor 6: Launch/Pause/Resume with simple APIs + +- Factor 7: Contact humans with tool calls + +- Factor 8: Own your control flow + +- Factor 9: Compact errors into context + +- Factor 10: Small, focused agents + +- Factor 11: Meet users where they are + +- Factor 12: Make your agent a stateless reducer + +The full guide goes into detail on each principle with examples and patterns to follow. I've seen these practices work well in production systems handling real user traffic. + +I'm sharing this as a starting point - the field is moving quickly and I expect these principles to evolve. I welcome your feedback and contributions to help figure out what "production grade" means for AI systems. + +Check out the full guide at https://github.com/humanlayer/12-factor-agents + +Special thanks to (github users) @iantbutler01, @tnm, @hellovai, @stantonk, @balanceiskey, @AdjectiveAllison, @pfbyjy, @a-churchill, as well as the SF MLOps community for early feedback on this guide. \ No newline at end of file diff --git a/acp/api/v1alpha1/agent_types.go b/acp/api/v1alpha1/agent_types.go index fc97b7f..ec0fbe7 100644 --- a/acp/api/v1alpha1/agent_types.go +++ b/acp/api/v1alpha1/agent_types.go @@ -4,6 +4,90 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// ExecuteConfig defines the configuration for freestyle execution +type ExecuteConfig struct { + // APIKeyFrom references the secret containing the API key + // +kubebuilder:validation:Required + + // +optional + FreestyleConfig *FreestyleConfig `json:"freestyle,omitempty"` + // E2BConfig *E2BConfig `json:"e2b,omitempty"` + // +optional + SecretSelectors []SecretSelector `json:"secretSelectors,omitempty"` + // optional - restrict the npm packages that can be used + // by default, all packages are allowed + // +optional + AllowedNPMPackages []string `json:"allowedNPMPackages,omitempty"` +} + +/** + + execute: + secretSelectors: + # todo is this an AND or an OR? + - name: code-execution-secret + - matchLabels: + environment: development + freestyle: + apiKeyFrom: + name: freestyle-api-key + key: apiKey + +*/ + +/** + +tool description: + name: code-execution + description: This tool is used to execute code + parameters: + type: object + properties: + code: + type: string + description: The code to execute + npmPackages: + type: array + items: + type: object + // todo can we do dynamic k:v or has to be array? + properties: + name: + type: string + description: The name of the npm package + version: + type: string + description: The version of the npm package + secrets: + type: array + items: + type: string + description: array of secrets to pass in as env vars, e.g. RESEND_API_KEY + +*/ + +type SecretSelector struct { + // Name of the secret + // +kubebuilder:validation:Required + Name string `json:"name"` + + // MatchLabels is a map of labels that the secret must have + // +optional + MatchLabels map[string]string `json:"matchLabels,omitempty"` +} + +type FreestyleConfig struct { + // APIKeyFrom references the secret containing the API key + // +kubebuilder:validation:Required + APIKeyFrom SecretKeyRef `json:"apiKeyFrom"` +} + +type E2BConfig struct { + // APIKeyFrom references the secret containing the API key + // +kubebuilder:validation:Required + APIKeyFrom SecretKeyRef `json:"apiKeyFrom"` +} + // AgentSpec defines the desired state of Agent type AgentSpec struct { // LLMRef references the LLM to use for this agent @@ -22,6 +106,10 @@ type AgentSpec struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 System string `json:"system"` + + // Execute defines how the agent should execute code + // +optional + Execute *ExecuteConfig `json:"execute,omitempty"` } // LocalObjectReference contains enough information to locate the referenced resource in the same namespace @@ -97,3 +185,7 @@ type AgentList struct { metav1.ListMeta `json:"metadata,omitempty"` Items []Agent `json:"items"` } + +func init() { + SchemeBuilder.Register(&Agent{}, &AgentList{}) +} diff --git a/acp/api/v1alpha1/toolcall_types.go b/acp/api/v1alpha1/toolcall_types.go index eab5eed..1f9d193 100644 --- a/acp/api/v1alpha1/toolcall_types.go +++ b/acp/api/v1alpha1/toolcall_types.go @@ -17,8 +17,9 @@ const ( type ToolType string const ( - ToolTypeMCP ToolType = "MCP" - ToolTypeHumanContact ToolType = "HumanContact" + ToolTypeMCP ToolType = "MCP" + ToolTypeHumanContact ToolType = "HumanContact" + ToolTypeExecuteToolType ToolType = "ExecuteScript" ) // ToolCallSpec defines the desired state of ToolCall diff --git a/acp/api/v1alpha1/zz_generated.deepcopy.go b/acp/api/v1alpha1/zz_generated.deepcopy.go index d489edd..6046ef4 100644 --- a/acp/api/v1alpha1/zz_generated.deepcopy.go +++ b/acp/api/v1alpha1/zz_generated.deepcopy.go @@ -113,6 +113,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = make([]LocalObjectReference, len(*in)) copy(*out, *in) } + if in.Execute != nil { + in, out := &in.Execute, &out.Execute + *out = new(ExecuteConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. @@ -347,6 +352,22 @@ func (in *EnvVarSource) DeepCopy() *EnvVarSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecuteConfig) DeepCopyInto(out *ExecuteConfig) { + *out = *in + out.APIKeyFrom = in.APIKeyFrom +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FreestyleConfig. +func (in *ExecuteConfig) DeepCopy() *ExecuteConfig { + if in == nil { + return nil + } + out := new(ExecuteConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GoogleConfig) DeepCopyInto(out *GoogleConfig) { *out = *in diff --git a/acp/config/crd/bases/acp.humanlayer.dev_agents.yaml b/acp/config/crd/bases/acp.humanlayer.dev_agents.yaml index 7963339..260a298 100644 --- a/acp/config/crd/bases/acp.humanlayer.dev_agents.yaml +++ b/acp/config/crd/bases/acp.humanlayer.dev_agents.yaml @@ -50,6 +50,26 @@ spec: spec: description: AgentSpec defines the desired state of Agent properties: + execute: + description: Execute defines how the agent should execute code + properties: + freestyleApiKeyFrom: + description: APIKeyFrom references the secret containing the API + key + properties: + key: + description: Key is the key in the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - freestyleApiKeyFrom + type: object humanContactChannels: description: HumanContactChannels is a list of ContactChannel resources that can be used for human interactions diff --git a/acp/config/manager/kustomization.yaml b/acp/config/manager/kustomization.yaml index 4c4c609..6ba5d3b 100644 --- a/acp/config/manager/kustomization.yaml +++ b/acp/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: controller - newTag: "202504102058" + newTag: "202504110028" diff --git a/acp/internal/controller/agent/agent_controller.go b/acp/internal/controller/agent/agent_controller.go index 7fa48af..6e83ee6 100644 --- a/acp/internal/controller/agent/agent_controller.go +++ b/acp/internal/controller/agent/agent_controller.go @@ -207,6 +207,22 @@ func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl statusUpdate.Status.ValidMCPServers = validMCPServers } + if agent.Spec.Execute != nil { + secret := &corev1.Secret{} + err = r.Get(ctx, client.ObjectKey{ + Namespace: agent.Namespace, + Name: agent.Spec.Execute.APIKeyFrom.Name, + }, secret) + if err != nil { + return r.setStatusError(ctx, &agent, err, statusUpdate, "ValidationFailed") + } + + if _, ok := secret.Data[agent.Spec.Execute.APIKeyFrom.Key]; !ok { + return r.setStatusError(ctx, &agent, fmt.Errorf("API key secret %q does not have key %q", agent.Spec.Execute.APIKeyFrom.Name, agent.Spec.Execute.APIKeyFrom.Key), statusUpdate, "ValidationFailed") + } + logger.Info("Freestyle API key secret validated") + } + // Validate HumanContactChannel references, if any if len(agent.Spec.HumanContactChannels) > 0 { validHumanContactChannels, err = r.validateHumanContactChannels(ctx, &agent) diff --git a/acp/internal/controller/task/task_controller.go b/acp/internal/controller/task/task_controller.go index 0e2b802..26ef662 100644 --- a/acp/internal/controller/task/task_controller.go +++ b/acp/internal/controller/task/task_controller.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "regexp" "time" "github.com/google/uuid" @@ -361,6 +362,98 @@ func (r *TaskReconciler) collectTools(ctx context.Context, agent *acp.Agent) []l // Get tools from MCP manager mcpTools := r.MCPManager.GetToolsForAgent(agent) + if agent.Spec.Execute != nil { + allowedSecrets := make([]string, 0) + for _, secret := range agent.Spec.Execute.SecretSelectors { + if secret.Name != "" { + secret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Namespace: agent.Namespace, Name: secret.Name}, secret); err != nil { + logger.Error(err, "Failed to get secret", "name", secret.Name) + continue + } + // get the keys that look like env vars + for key := range secret.Data { + if match, _ := regexp.MatchString(`^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$`, key); match { + allowedSecrets = append(allowedSecrets, key) + } + } + } else if secret.MatchLabels != nil { + // use a label selector to get a SecretList that matches the labels + secretList := &corev1.SecretList{} + if err := r.List(ctx, secretList, client.InNamespace(agent.Namespace), client.MatchingLabels(secret.MatchLabels)); err != nil { + logger.Error(err, "Failed to list secrets", "labels", secret.MatchLabels) + continue + } + for _, secret := range secretList.Items { + // get the keys that look like env vars + for key := range secret.Data { + if match, _ := regexp.MatchString(`^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$`, key); match { + allowedSecrets = append(allowedSecrets, key) + } + } + } + } + } + } + tools = append(tools, llmclient.Tool{ + Type: "function", + ACPToolType: acp.ToolTypeExecuteToolType, + Function: llmclient.ToolFunction{ + Name: "execute", + Description: ` + Execute a JavaScript/TypeScript script. + + + The script must be in the format: + + export default () => { + ... your code here ... + + return output; + } + + or for async functions: + + export default async () => { + ... your code here ... + + return output; + }`, + Parameters: llmclient.ToolFunctionParameters{ + Type: "object", + Properties: map[string]llmclient.ToolFunctionParameters{ + "script": { + Type: "string", + }, + "secrets": { + Type: "array", + Items: &llmclient.ToolFunctionParameters{ + Description: "The secrets to use in the script", + Type: "string", + Enum: []string{"secret1", "secret2"}, + }, + }, + "npmPackages": { + Type: "array", + Items: &llmclient.ToolFunctionParameters{ + Type: "object", + Properties: map[string]llmclient.ToolFunctionParameters{ + "name": { + Type: "string", + }, + "version": { + Type: "string", + }, + }, + }, + }, + }, + Required: []string{"script"}, + }, + }, + }) + } + // Convert MCP tools to LLM tools for _, mcpTool := range mcpTools { tools = append(tools, adapters.ConvertMCPToolsToLLMClientTools([]acp.MCPTool{mcpTool}, mcpTool.Name)...) diff --git a/acp/internal/controller/toolcall/toolcall_controller.go b/acp/internal/controller/toolcall/toolcall_controller.go index 161de1e..7f77d90 100644 --- a/acp/internal/controller/toolcall/toolcall_controller.go +++ b/acp/internal/controller/toolcall/toolcall_controller.go @@ -22,6 +22,7 @@ import ( "github.com/google/uuid" acp "github.com/humanlayer/agentcontrolplane/acp/api/v1alpha1" + "github.com/humanlayer/agentcontrolplane/acp/internal/execute" "github.com/humanlayer/agentcontrolplane/acp/internal/humanlayer" "github.com/humanlayer/agentcontrolplane/acp/internal/humanlayerapi" "github.com/humanlayer/agentcontrolplane/acp/internal/mcpmanager" @@ -695,15 +696,88 @@ func (r *ToolCallReconciler) dispatchToolExecution(ctx context.Context, tc *acp. } _, isAgent := isAgentTool(tc) + if isAgent { return r.handleUnsupportedToolType(ctx, tc) } + isExecuteTool := isExecuteTool(tc) + + if isExecuteTool { + return r.handleExecuteTool(ctx, tc, args) + } + // todo handle human contact tool return r.handleUnsupportedToolType(ctx, tc) } +func isExecuteTool(tc *acp.ToolCall) bool { + return tc.Spec.ToolType == acp.ToolTypeExecuteToolType +} + +func (r *ToolCallReconciler) handleExecuteTool(ctx context.Context, tc *acp.ToolCall, args map[string]interface{}) (ctrl.Result, error) { + // fetch the TC's parent Task + taskName := tc.Labels["acp.humanlayer.dev/task"] + var task acp.Task + if err := r.Get(ctx, client.ObjectKey{Namespace: tc.Namespace, Name: taskName}, &task); err != nil { + return ctrl.Result{}, err + } + + var agent acp.Agent + if err := r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: task.Spec.AgentRef.Name}, &agent); err != nil { + return ctrl.Result{}, err + } + + // get the agents execute config + executeConfig := agent.Spec.Execute + if executeConfig == nil { + return ctrl.Result{}, fmt.Errorf("agent %s has no execute config", agent.Name) + } + + // get the execute config's API key + + secretName := executeConfig.APIKeyFrom.Name + secretKey := executeConfig.APIKeyFrom.Key + + // fetch the API key from the secret + secret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: secretName}, secret); err != nil { + return ctrl.Result{}, err + } + + apiKey := secret.Data[secretKey] + if apiKey == nil { + return ctrl.Result{}, fmt.Errorf("secret %s does not have key %s", secretName, secretKey) + } + + client := execute.NewFreestyleClient(string(apiKey)) + + // todo pass erorrs back to the model + resp, err := client.ExecuteScript(ctx, args["script"].(string), execute.FreestyleConfig{}) + if err != nil { + return ctrl.Result{}, err + } + + if resp.Error != "" { + return ctrl.Result{}, fmt.Errorf("tool execution failed: %s", resp.Error) + } + + // set the result on the status + tc.Status.Result = fmt.Sprintf("%v", resp.Result) + tc.Status.Status = acp.ToolCallStatusTypeSucceeded + tc.Status.Phase = acp.ToolCallPhaseSucceeded + + // update the status + if err := r.Status().Update(ctx, tc); err != nil { + return ctrl.Result{}, err + } + + r.recorder.Event(tc, corev1.EventTypeNormal, "ToolCallSucceeded", "Script executed successfully") + + return ctrl.Result{}, nil +} + func isAgentTool(tc *acp.ToolCall) (string, bool) { return "", false } diff --git a/acp/internal/execute/freestyle.go b/acp/internal/execute/freestyle.go new file mode 100644 index 0000000..f69e303 --- /dev/null +++ b/acp/internal/execute/freestyle.go @@ -0,0 +1,145 @@ +package execute + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "time" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + freestyleAPIEndpoint = "https://api.freestyle.sh/execute/v1/script" +) + +// FreestyleConfig represents the configuration for a Freestyle script execution +type FreestyleConfig struct { + EnvVars map[string]string `json:"envVars,omitempty"` + NodeModules map[string]string `json:"nodeModules,omitempty"` + Tags []string `json:"tags,omitempty"` + Timeout *time.Duration `json:"timeout,omitempty"` + PeerDependencyResolution bool `json:"peerDependencyResolution,omitempty"` + NetworkPermissions interface{} `json:"networkPermissions,omitempty"` + CustomHeaders map[string]string `json:"customHeaders,omitempty"` + Proxy string `json:"proxy,omitempty"` +} + +// FreestyleRequest represents the request body for the Freestyle API +type FreestyleRequest struct { + Script string `json:"script"` + Config FreestyleConfig `json:"config"` +} + +// FreestyleResponse represents the response from the Freestyle API +type FreestyleResponse struct { + // Add response fields as needed based on the API response + // This is a placeholder and should be updated with actual response structure + Result interface{} `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// FreestyleClient is a client for interacting with the Freestyle API +type FreestyleClient struct { + apiKey string + client *http.Client + baseURL string +} + +// NewFreestyleClient creates a new Freestyle client +func NewFreestyleClient(apiKey string) *FreestyleClient { + return &FreestyleClient{ + apiKey: apiKey, + client: &http.Client{}, + baseURL: freestyleAPIEndpoint, + } +} + +// ExecuteScript executes a JavaScript script using the Freestyle API +func (c *FreestyleClient) ExecuteScript(ctx context.Context, script string, config FreestyleConfig) (*FreestyleResponse, error) { + req := FreestyleRequest{ + Script: script, + Config: config, + } + + reqBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewBuffer(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var freestyleResp FreestyleResponse + if err := json.NewDecoder(resp.Body).Decode(&freestyleResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &freestyleResp, nil +} + +// SecretSelector represents a selector for Kubernetes secrets +type SecretSelector struct { + Name string `json:"name,omitempty"` + MatchLabels map[string]string `json:"matchLabels,omitempty"` +} + +// CollectAvailableExecuteSecrets collects all available secrets that match the given selectors +// and returns a list of environment variable names that can be used in script execution +func CollectAvailableExecuteSecrets(ctx context.Context, r client.Client, namespace string, secretSelectors []SecretSelector) ([]string, error) { + logger := log.FromContext(ctx) + allowedSecrets := make([]string, 0) + + for _, selector := range secretSelectors { + if selector.Name != "" { + secret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: selector.Name}, secret); err != nil { + logger.Error(err, "Failed to get secret", "name", selector.Name) + continue + } + // get the keys that look like env vars + for key := range secret.Data { + if match, _ := regexp.MatchString(`^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$`, key); match { + allowedSecrets = append(allowedSecrets, key) + } + } + } else if selector.MatchLabels != nil { + // use a label selector to get a SecretList that matches the labels + secretList := &corev1.SecretList{} + if err := r.List(ctx, secretList, client.InNamespace(namespace), client.MatchingLabels(selector.MatchLabels)); err != nil { + logger.Error(err, "Failed to list secrets", "labels", selector.MatchLabels) + continue + } + for _, secret := range secretList.Items { + // get the keys that look like env vars + for key := range secret.Data { + if match, _ := regexp.MatchString(`^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$`, key); match { + allowedSecrets = append(allowedSecrets, key) + } + } + } + } + } + + return allowedSecrets, nil +} diff --git a/acp/internal/execute/freestyle_test.go b/acp/internal/execute/freestyle_test.go new file mode 100644 index 0000000..285c26c --- /dev/null +++ b/acp/internal/execute/freestyle_test.go @@ -0,0 +1,51 @@ +package execute + +import ( + "context" + "fmt" + "testing" + "time" +) + +// ExampleFreestyleClient demonstrates how to use the Freestyle client +func TestExampleFreestyleClient(t *testing.T) { + // Create a new client + client := NewFreestyleClient( + "Bj5Xq4Fa1R8DfdkwC36vHD-6QpekCWgHeJmfyR5JZ7cZJ5Paansm8VZ3km3B9AiLLK1", + ) + + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Example script that calculates factorials + script := `export default () => { + // get the value of the factorials of the numbers from 1 to 10 combined + const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + function factorial(n) { + if (n === 0) { + return 1; + } + throw new Error("test error"); + return n * factorial(n - 1); + } + + const b = a.map(factorial); + + return b.reduce((a, b) => a + b); +};` + + // Create configuration + config := FreestyleConfig{} + + // Execute the script + resp, err := client.ExecuteScript(ctx, script, config) + if err != nil { + fmt.Printf("Error executing script: %v\n", err) + return + } + + // Print the result + fmt.Printf("Script execution result: %v %v\n", resp.Result, resp.Error) +} diff --git a/acp/internal/llmclient/llm_client.go b/acp/internal/llmclient/llm_client.go index 0d87da1..7918da8 100644 --- a/acp/internal/llmclient/llm_client.go +++ b/acp/internal/llmclient/llm_client.go @@ -33,7 +33,7 @@ func (e *LLMRequestError) Unwrap() error { type Tool struct { Type string `json:"type"` Function ToolFunction `json:"function"` - // ACPToolType represents the ACP-specific type of tool (MCP, HumanContact) + // ACPToolType represents the ACP-specific type of tool (MCP, HumanContact, ExecuteTool) // This field is not sent to the LLM API but is used internally for tool identification ACPToolType acp.ToolType `json:"-"` } @@ -52,9 +52,10 @@ type ToolFunctionParameter struct { // ToolFunctionParameters defines the schema for the function parameters type ToolFunctionParameters struct { - Type string `json:"type"` - Properties map[string]ToolFunctionParameter `json:"properties"` - Required []string `json:"required,omitempty"` + Type string `json:"type"` + Properties map[string]ToolFunctionParameters `json:"properties"` + Items *ToolFunctionParameters `json:"items"` + Required []string `json:"required,omitempty"` } // FromContactChannel creates a Tool from a ContactChannel resource