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
81 changes: 73 additions & 8 deletions fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/crossplane/function-sdk-go/response"

"github.com/upbound/function-claude/input/v1alpha1"
"github.com/upbound/function-claude/internal/tool"
)

const (
Expand Down Expand Up @@ -55,12 +56,32 @@ type agentInvoker interface {
Invoke(ctx context.Context, key, system, prompt string) (string, error)
}

// Option modifies the underlying Function.
type Option func(*Function)

// WithLogger overrides the default logger.
func WithLogger(log logging.Logger) Option {
return func(f *Function) {
f.log = log
}
}

// NewFunction creates a new function powered by Claude.
func NewFunction(log logging.Logger) *Function {
return &Function{
ai: &agent{},
log: log,
func NewFunction(opts ...Option) *Function {
f := &Function{
log: logging.NewNopLogger(),
}

for _, o := range opts {
o(f)
}

f.ai = &agent{
log: f.log,
res: tool.NewResolver(tool.WithLogger(f.log)),
}

return f
}

// RunFunction runs the Function.
Expand Down Expand Up @@ -183,6 +204,32 @@ func ComposedFromYAML(y string) (map[string]*fnv1.Resource, error) {
return out, nil
}

// resourceFrom produces a map of resource name to resources derived from the
// given string. If the string is neither JSON nor YAML, an error is returned.
func (f *Function) resourceFrom(i string) (map[string]*fnv1.Resource, error) {
out := make(map[string]*fnv1.Resource)

b := []byte(i)

// Is i YAML?
jb, err := yaml.YAMLToJSON(b)
if err != nil {
f.log.Debug("error seen while attempting to convert YAML to JSON", "error", err)
// i doesn't appear to be YAML, maybe it's JSON...
jb = b
}

s := &structpb.Struct{}
if err := protojson.Unmarshal(jb, s); err != nil {
return nil, errors.Wrap(err, "cannot parse JSON")
}

name := gjson.GetBytes(jb, "metadata.name").String()
out[name] = &fnv1.Resource{Resource: s}

return out, nil
}

// attempts to identify if the function is operating within a composition
// pipeline or not by looking to see if a composite was sent with the request.
func inCompositionPipeline(req *fnv1.RunFunctionRequest) bool {
Expand Down Expand Up @@ -312,12 +359,23 @@ func (f *Function) operationPipeline(ctx context.Context, log logging.Logger, d
return d.rsp, err
}

desired, err := f.resourceFrom(resp)
if err != nil {
// we didn't get a JSON based response from claude
log.Debug("failed to get a JSON response back, no desired resources will be sent back to crossplane")
}

response.ConditionTrue(d.rsp, "FunctionSuccess", "Success").TargetCompositeAndClaim()
response.Normal(d.rsp, resp)

d.rsp.Desired.Resources = desired
return d.rsp, nil
}

type agent struct{}
type agent struct {
log logging.Logger
res *tool.Resolver
}

// Invoke makes an external call to the configured LLM with the supplied
// credential key, system and user prompts.
Expand All @@ -331,9 +389,8 @@ func (a *agent) Invoke(ctx context.Context, key, system, prompt string) (string,

agent := agents.NewOneShotAgent(
model,
// NOTE(tnthornton) Placeholder for future integrations with external Tools.
[]tools.Tool{},
agents.WithMaxIterations(3),
a.tools(ctx),
agents.WithMaxIterations(20),
)

return chains.Run(
Expand All @@ -343,3 +400,11 @@ func (a *agent) Invoke(ctx context.Context, key, system, prompt string) (string,
chains.WithTemperature(float64(0)),
)
}

func (a *agent) tools(ctx context.Context) []tools.Tool {
cfgs := a.res.FromEnvVars()
if len(cfgs) == 0 {
a.log.Debug("no valid mcp server configurations found")
}
return a.res.Resolve(ctx, cfgs)
}
89 changes: 86 additions & 3 deletions fn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestRunFunction(t *testing.T) {
req: &fnv1.RunFunctionRequest{
Meta: &fnv1.RequestMeta{Tag: "hello"},
Input: resource.MustStructJSON(`{
"apiVersion": "openai.fn.upbound.io/v1alpha1",
"apiVersion": "claude.fn.upbound.io/v1alpha1",
"kind": "Prompt",
"systemPrompt": "I'm a system",
"userPrompt": "I'm a user"
Expand Down Expand Up @@ -76,7 +76,7 @@ metadata:
req: &fnv1.RunFunctionRequest{
Meta: &fnv1.RequestMeta{Tag: "hello"},
Input: resource.MustStructJSON(`{
"apiVersion": "openai.fn.upbound.io/v1alpha1",
"apiVersion": "claude.fn.upbound.io/v1alpha1",
"kind": "Prompt",
"systemPrompt": "I'm a system",
"userPrompt": "I'm a user"
Expand Down Expand Up @@ -153,7 +153,7 @@ metadata:
req: &fnv1.RunFunctionRequest{
Meta: &fnv1.RequestMeta{Tag: "hello"},
Input: resource.MustStructJSON(`{
"apiVersion": "openai.fn.upbound.io/v1alpha1",
"apiVersion": "claude.fn.upbound.io/v1alpha1",
"kind": "Prompt",
"systemPrompt": "I'm a system",
"userPrompt": "I'm a user"
Expand All @@ -168,6 +168,7 @@ metadata:
},
},
},
Desired: &fnv1.State{},
},
},
want: want{
Expand All @@ -189,6 +190,7 @@ metadata:
Reason: "Success",
Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(),
}},
Desired: &fnv1.State{},
},
},
},
Expand Down Expand Up @@ -224,6 +226,87 @@ func mockCredentials() map[string]*fnv1.Credentials {
}
}

func TestResourceFrom(t *testing.T) {
type args struct {
resp string
}
type want struct {
resource map[string]*fnv1.Resource
err error
}

cases := map[string]struct {
reason string
args args
want want
}{
"String": {
reason: "We should return an error if we received a string that is neither JSON nor YAML",
args: args{
resp: "some-response",
},
want: want{
err: cmpopts.AnyError,
},
},
"ValidJSON": {
reason: "We should not return an error if we processed valid JSON",
args: args{
resp: `{}`,
},
want: want{
resource: map[string]*fnv1.Resource{
"": {Resource: &structpb.Struct{}},
},
},
},
"ValidYAML": {
reason: "We should not return an error if we processed valid YAML",
args: args{
resp: `a: b`,
},
want: want{
resource: map[string]*fnv1.Resource{
"": {Resource: &structpb.Struct{Fields: map[string]*structpb.Value{"a": structpb.NewStringValue("b")}}},
},
},
},
"InvalidJSON": {
reason: "We should return an error if we attempt to process invalid JSON",
args: args{
resp: `{a: `,
},
want: want{
err: cmpopts.AnyError,
},
},
"InvalidYAML": {
reason: "We should return an error if we attempt to process invalid YAML",
args: args{
resp: ``,
},
want: want{
err: cmpopts.AnyError,
},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
f := &Function{log: logging.NewNopLogger()}
got, err := f.resourceFrom(tc.args.resp)

if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff)
}

if diff := cmp.Diff(tc.want.resource, got, protocmp.Transform()); diff != "" {
t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff)
}
})
}
}

type mockAgentInvoker struct {
InvokeFn func(ctx context.Context, key, system, prompt string) (string, error)
}
Expand Down
9 changes: 8 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ require (
github.com/alecthomas/kong v0.9.0
github.com/crossplane/function-sdk-go v0.5.0-rc.0.0.20250715215746-ca27889cd196
github.com/google/go-cmp v0.6.0
github.com/i2y/langchaingo-mcp-adapter v0.0.0-20250623114610-a01671e1c8df
github.com/mark3labs/mcp-go v0.36.0
github.com/tidwall/gjson v1.14.4
github.com/tidwall/sjson v1.2.5
github.com/tmc/langchaingo v0.1.13
Expand All @@ -22,6 +24,8 @@ require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/crossplane/crossplane-runtime v1.18.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
Expand All @@ -46,6 +50,7 @@ require (
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
Expand All @@ -63,13 +68,15 @@ require (
github.com/shopspring/decimal v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.starlark.net v0.0.0-20230302034142-4b1e35fe2254 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
Expand Down
Loading
Loading