mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-18 21:36:30 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bbccbb0a5 | |||
| 276c787937 | |||
| 40bc710938 | |||
| 888c6c7953 | |||
| a9d808eb9f | |||
| d7948a64f3 |
@@ -691,10 +691,10 @@ host, err := kit.NewAgent(ctx,
|
||||
|
||||
Available options: `WithModel`, `WithSystemPrompt`, `WithStreaming`,
|
||||
`WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`,
|
||||
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, and
|
||||
`Ephemeral`. For advanced configuration not covered by the helpers (custom MCP
|
||||
config, in-process MCP servers, session backends, MCP task tuning) construct an
|
||||
`Options` value explicitly and call `kit.New`.
|
||||
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`,
|
||||
`WithDebugLogger`, and `Ephemeral`. For advanced configuration not covered by
|
||||
the helpers (custom MCP config, in-process MCP servers, session backends, MCP
|
||||
task tuning) construct an `Options` value explicitly and call `kit.New`.
|
||||
|
||||
### Per-instance config isolation
|
||||
|
||||
|
||||
@@ -54,8 +54,12 @@ func Init(api ext.API) {
|
||||
return
|
||||
}
|
||||
|
||||
// NewSession blocks until the TUI completes the switch; run it in
|
||||
// a goroutine so the agent's turn-end pipeline isn't stalled.
|
||||
// NewSession blocks while the agent finishes settling and then while
|
||||
// the TUI completes the switch; run it in a goroutine so the agent's
|
||||
// turn-end pipeline isn't stalled. The internal wait-for-idle (added
|
||||
// in response to issue #63) makes this reliable even when post-turn
|
||||
// tooling (formatters, on-save hooks, hidden tool calls) extends the
|
||||
// busy window past AgentEnd.
|
||||
go func() {
|
||||
if err := ctx.NewSession(HANDOFFPrompt); err != nil {
|
||||
ctx.PrintError("phase-handoff: " + err.Error())
|
||||
|
||||
@@ -5,16 +5,16 @@ go 1.26.4
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/fantasy v0.31.0
|
||||
charm.land/fantasy v0.32.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/alecthomas/chroma/v2 v2.26.1
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1
|
||||
github.com/charmbracelet/x/editor v0.2.0
|
||||
github.com/clipperhouse/displaywidth v0.11.0
|
||||
@@ -23,7 +23,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/indaco/herald v0.13.0
|
||||
github.com/indaco/herald-md v0.3.0
|
||||
github.com/mark3labs/mcp-go v0.54.1
|
||||
github.com/mark3labs/mcp-go v0.55.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
@@ -85,13 +85,13 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.26 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.8.0 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.8.1 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/mango v0.2.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.3.0 // indirect
|
||||
github.com/muesli/mango-pflag v0.2.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
@@ -116,8 +116,8 @@ require (
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.284.0 // indirect
|
||||
google.golang.org/genai v1.60.0 // indirect
|
||||
google.golang.org/api v0.285.0 // indirect
|
||||
google.golang.org/genai v1.61.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
|
||||
@@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/fantasy v0.31.0 h1:ioLVRi7A8lZXR8mrCIeseuCcq0KqAak46revmGumnpc=
|
||||
charm.land/fantasy v0.31.0/go.mod h1:lAE2gO68SrB1S5TrW5g0TRoxz9V+qJcg0Elx/uPWsDI=
|
||||
charm.land/fantasy v0.32.0 h1:tlC1qlOdXi2CkF6KB0x8YAAm3hiarI2/69u6pZmOZk8=
|
||||
charm.land/fantasy v0.32.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
@@ -28,8 +28,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -84,8 +84,8 @@ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0r
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be h1:pg+OWlIkk9HOe/8P5J95aKe2wGDzFUiiyFOUpwR30B4=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
@@ -191,8 +191,8 @@ github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8
|
||||
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
|
||||
github.com/kaptinlin/jsonpointer v0.4.26 h1:tw616yszHek+B3/GtDSia+uzBa3sLXGpmo4tYeMhBZw=
|
||||
github.com/kaptinlin/jsonpointer v0.4.26/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
|
||||
github.com/kaptinlin/jsonschema v0.8.0 h1:GhY966O2q3ZQsg1zkQj988KF2MADJ6EA7pKBMpGmb9A=
|
||||
github.com/kaptinlin/jsonschema v0.8.0/go.mod h1:dxt7s98W5NEuWEwCnAwGrhYGQdaRLqXZImR28DuxcMU=
|
||||
github.com/kaptinlin/jsonschema v0.8.1 h1:Krhuq1HpE+olHoPfcxkohqKKCnXfixUPv+aUYRegBBQ=
|
||||
github.com/kaptinlin/jsonschema v0.8.1/go.mod h1:mCH2W5lXd29tdDjvoFfY32nedPORnlk7pCVrrcs/NkQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -201,8 +201,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
|
||||
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
|
||||
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
@@ -221,8 +221,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
|
||||
github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
@@ -312,14 +312,14 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc=
|
||||
google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado=
|
||||
google.golang.org/genai v1.60.0 h1:uAkea4tYhCz1LlUmxdiOFAmlrLFaLs8PbXucgZHqHVo=
|
||||
google.golang.org/genai v1.60.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/api v0.285.0 h1:B7eHHoKGAX/LrPkQvhQqnGwjgWxofbdGwCTQvpm8FkM=
|
||||
google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
|
||||
google.golang.org/genai v1.61.0 h1:wCyNGiaC9q5A59B80zuEtNBhq3ypEvICFkZYOfK7IO0=
|
||||
google.golang.org/genai v1.61.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad h1:cYL1DPJAQr4JMvhfGao0PDXoaf03ifMljAuDyrbMBd0=
|
||||
google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:cVHIikDNAdx8ISZeW+2rYkEMf3xn0GSaBYmVnWXQBUo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
|
||||
+148
-13
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -24,6 +25,26 @@ type queueItem struct {
|
||||
Files []kit.LLMFilePart
|
||||
}
|
||||
|
||||
// ErrAgentBusy is returned when an operation cannot proceed because the agent
|
||||
// is still processing a turn (including any post-turn extension hooks) and did
|
||||
// not become idle before the operation's deadline.
|
||||
//
|
||||
// This is an alias for extensions.ErrAgentBusy so the extension API and the
|
||||
// app layer share a single sentinel value — callers can detect the condition
|
||||
// with errors.Is(err, app.ErrAgentBusy) without substring-matching the error
|
||||
// message.
|
||||
var ErrAgentBusy = extensions.ErrAgentBusy
|
||||
|
||||
// DefaultNewSessionIdleWait bounds how long RequestNewSessionFromExtension
|
||||
// will block waiting for the agent to settle. It needs to be generous enough
|
||||
// to cover real-world post-turn tooling (project formatters, on-save linters,
|
||||
// hidden tool calls) which routinely hold the busy flag for seconds and
|
||||
// occasionally minutes — yet still short enough to surface a wedged agent.
|
||||
//
|
||||
// Issue #63 reported workloads where the busy window regularly exceeded
|
||||
// 6 seconds; ten minutes is the same bound the workaround in that issue used.
|
||||
const DefaultNewSessionIdleWait = 10 * time.Minute
|
||||
|
||||
// App is the application-layer orchestrator. It owns the agentic loop,
|
||||
// conversation history (via MessageStore), and queue management. It is
|
||||
// designed to be created once per session and reused across multiple prompts.
|
||||
@@ -55,11 +76,25 @@ type App struct {
|
||||
// each new step and called by CancelCurrentStep().
|
||||
cancelStep context.CancelFunc
|
||||
|
||||
// mu protects busy, queue, and cancelStep.
|
||||
// mu protects busy, queue, cancelStep, and idleCh.
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
queue []queueItem
|
||||
|
||||
// idleCh is closed when the agent transitions from busy back to idle.
|
||||
// While the agent is idle the channel is already closed (recv returns
|
||||
// immediately). When busy transitions to true a fresh open channel is
|
||||
// allocated so callers blocked on the previous one are released. All
|
||||
// transitions are funnelled through setBusyLocked to keep the channel
|
||||
// pointer in sync with the busy flag.
|
||||
//
|
||||
// This is the underlying primitive WaitForIdle and
|
||||
// RequestNewSessionFromExtension wait on to fix the AgentEnd→NewSession
|
||||
// race described in issue #63: AgentEnd is emitted from inside the agent
|
||||
// loop, before drainQueue clears busy, so any extension hook that calls
|
||||
// ctx.NewSession synchronously would otherwise observe busy==true.
|
||||
idleCh chan struct{}
|
||||
|
||||
// wg tracks in-flight goroutines; Close() waits on it.
|
||||
wg sync.WaitGroup
|
||||
|
||||
@@ -95,6 +130,10 @@ type App struct {
|
||||
// initialMessages may be nil or empty for a fresh session.
|
||||
func New(opts Options, initialMessages []kit.LLMMessage) *App {
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
// idleCh starts already closed: the freshly constructed App is idle, so
|
||||
// any caller blocking on it via WaitForIdle should be released immediately.
|
||||
idleCh := make(chan struct{})
|
||||
close(idleCh)
|
||||
return &App{
|
||||
opts: opts,
|
||||
store: NewMessageStoreWithMessages(initialMessages),
|
||||
@@ -102,6 +141,90 @@ func New(opts Options, initialMessages []kit.LLMMessage) *App {
|
||||
rootCancel: rootCancel,
|
||||
// cancelStep starts as a no-op so CancelCurrentStep() is always safe.
|
||||
cancelStep: func() {},
|
||||
idleCh: idleCh,
|
||||
}
|
||||
}
|
||||
|
||||
// setBusyLocked is the single chokepoint for mutating a.busy. It keeps the
|
||||
// idleCh signalling channel in sync with the busy flag:
|
||||
//
|
||||
// - false → true: allocate a fresh open channel so future WaitForIdle
|
||||
// callers block until the next idle transition.
|
||||
// - true → false: close the current channel so any waiters wake up.
|
||||
//
|
||||
// No-op when the requested state already matches. The caller must hold a.mu.
|
||||
func (a *App) setBusyLocked(busy bool) {
|
||||
if a.busy == busy {
|
||||
return
|
||||
}
|
||||
a.busy = busy
|
||||
if busy {
|
||||
a.idleCh = make(chan struct{})
|
||||
} else {
|
||||
close(a.idleCh)
|
||||
}
|
||||
}
|
||||
|
||||
// idleSnapshot returns the current busy state and the channel that will be
|
||||
// closed on the next idle transition. The snapshot is taken under a.mu so the
|
||||
// pair is consistent (busy==true ⇒ ch is the open channel for *this* busy
|
||||
// cycle, not a stale one).
|
||||
func (a *App) idleSnapshot() (busy bool, ch chan struct{}) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.busy, a.idleCh
|
||||
}
|
||||
|
||||
// WaitForIdle blocks until the agent is idle, the given timeout elapses, or
|
||||
// the app shuts down. Returns nil on idle, ErrAgentBusy on timeout, or the
|
||||
// rootCtx error if the app is closing.
|
||||
//
|
||||
// A non-positive timeout disables the deadline and waits indefinitely (until
|
||||
// idle or app shutdown). Safe to call from any goroutine, but never from
|
||||
// inside the Bubble Tea Update() loop — it blocks.
|
||||
//
|
||||
// Idiomatic use from extensions:
|
||||
//
|
||||
// if err := app.WaitForIdle(0); err != nil { /* shutdown */ }
|
||||
//
|
||||
// The loop guards against the agent re-arming itself between wakeups: if
|
||||
// another prompt is queued (or a steer message lands) while we're waiting,
|
||||
// setBusyLocked allocates a fresh idleCh and we wait again.
|
||||
func (a *App) WaitForIdle(timeout time.Duration) error {
|
||||
var deadline time.Time
|
||||
if timeout > 0 {
|
||||
deadline = time.Now().Add(timeout)
|
||||
}
|
||||
for {
|
||||
busy, ch := a.idleSnapshot()
|
||||
if !busy {
|
||||
return nil
|
||||
}
|
||||
var timer *time.Timer
|
||||
var timerCh <-chan time.Time
|
||||
if timeout > 0 {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return ErrAgentBusy
|
||||
}
|
||||
timer = time.NewTimer(remaining)
|
||||
timerCh = timer.C
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
// Idle transition observed — loop and re-check under the
|
||||
// mutex in case a new busy cycle started immediately after.
|
||||
case <-timerCh:
|
||||
return ErrAgentBusy
|
||||
case <-a.rootCtx.Done():
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return a.rootCtx.Err()
|
||||
}
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +278,7 @@ func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
|
||||
return qLen
|
||||
}
|
||||
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
@@ -235,7 +358,7 @@ func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as RunWithFiles().
|
||||
item := queueItem{Prompt: prompt, Files: files}
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
@@ -271,7 +394,7 @@ func (a *App) InterruptAndSend(prompt string) {
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
@@ -470,7 +593,7 @@ func (a *App) CompactConversation(customInstructions string) error {
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("SDK instance not available")
|
||||
}
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
|
||||
@@ -532,7 +655,7 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("SDK instance not available")
|
||||
}
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
|
||||
@@ -621,7 +744,7 @@ func (a *App) releaseBusyAfterCompact() {
|
||||
// in just before closed was set.
|
||||
if a.closed {
|
||||
a.queue = a.queue[:0]
|
||||
a.busy = false
|
||||
a.setBusyLocked(false)
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -633,7 +756,7 @@ func (a *App) releaseBusyAfterCompact() {
|
||||
a.queue = a.queue[:0]
|
||||
|
||||
if len(pending) == 0 {
|
||||
a.busy = false
|
||||
a.setBusyLocked(false)
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -850,7 +973,7 @@ func (a *App) drainQueue(first queueItem) {
|
||||
|
||||
// Mark as no longer busy
|
||||
a.mu.Lock()
|
||||
a.busy = false
|
||||
a.setBusyLocked(false)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -1233,8 +1356,17 @@ func (a *App) SetEditorTextFromExtension(text string) {
|
||||
// RequestNewSessionFromExtension sends a NewSessionRequestEvent to the TUI
|
||||
// to end the current session and start a fresh one. If initialPrompt is
|
||||
// non-empty it is submitted as the first user turn of the new session.
|
||||
// Returns an error when running headless (no TUI attached), when the agent
|
||||
// is busy, or when a BeforeSessionSwitch extension hook cancels the switch.
|
||||
//
|
||||
// If the agent is currently busy (e.g. the caller is an OnAgentEnd hook that
|
||||
// fires before drainQueue clears the busy flag, or there are queued prompts
|
||||
// still being processed) the call blocks until the agent becomes idle, up to
|
||||
// DefaultNewSessionIdleWait. If that deadline elapses, ErrAgentBusy is
|
||||
// returned and callers can detect it with errors.Is. This wait-then-send
|
||||
// behavior fixes the v0.79.0 phase-handoff race documented in issue #63.
|
||||
//
|
||||
// Returns an error when running headless (no TUI attached), when the wait
|
||||
// for idle times out (ErrAgentBusy), when the app is shutting down, or when
|
||||
// a BeforeSessionSwitch extension hook cancels the switch.
|
||||
//
|
||||
// This is the implementation behind ctx.NewSession(prompt) for the
|
||||
// interactive TUI. It blocks the caller until the TUI processes the
|
||||
@@ -1246,8 +1378,11 @@ func (a *App) RequestNewSessionFromExtension(initialPrompt string) error {
|
||||
if prog == nil {
|
||||
return fmt.Errorf("new session unavailable: no interactive TUI attached")
|
||||
}
|
||||
if a.IsBusy() {
|
||||
return fmt.Errorf("cannot start new session while agent is busy")
|
||||
if err := a.WaitForIdle(DefaultNewSessionIdleWait); err != nil {
|
||||
if errors.Is(err, ErrAgentBusy) {
|
||||
return fmt.Errorf("cannot start new session: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
ch := make(chan error, 1)
|
||||
prog.Send(NewSessionRequestEvent{InitialPrompt: initialPrompt, ResponseCh: ch})
|
||||
|
||||
+283
-5
@@ -794,7 +794,7 @@ func TestReleaseBusyAfterCompact_flushesQueuedMessages(t *testing.T) {
|
||||
// summarising. (Run() would have appended them and returned a queue
|
||||
// length > 0 to the caller.)
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "queued during compact #1"},
|
||||
queueItem{Prompt: "queued during compact #2"},
|
||||
@@ -834,7 +834,7 @@ func TestReleaseBusyAfterCompact_idleWhenQueueEmpty(t *testing.T) {
|
||||
defer app.Close()
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.mu.Unlock()
|
||||
|
||||
app.releaseBusyAfterCompact()
|
||||
@@ -901,7 +901,7 @@ func TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue(t *testing.T) {
|
||||
// Simulate the state at the end of compaction: busy is set and a couple
|
||||
// of regular Run() prompts have piled up after the steer messages.
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "queued-1"},
|
||||
queueItem{Prompt: "queued-2"},
|
||||
@@ -950,7 +950,7 @@ func TestReleaseBusyAfterCompact_dropsQueueWhenClosed(t *testing.T) {
|
||||
app := newTestApp(stub)
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.queue = append(app.queue, queueItem{Prompt: "would have run"})
|
||||
app.closed = true
|
||||
app.mu.Unlock()
|
||||
@@ -999,7 +999,7 @@ func TestPopLastUserMessage_WhileBusy(t *testing.T) {
|
||||
defer app.Close()
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.mu.Unlock()
|
||||
|
||||
_, _, err := app.PopLastUserMessage()
|
||||
@@ -1115,3 +1115,281 @@ func TestPopLastUserMessage_NoUserOnBranch(t *testing.T) {
|
||||
t.Fatalf("expected error mentioning missing user message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// WaitForIdle / RequestNewSessionFromExtension (issue #63)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestWaitForIdle_AlreadyIdle verifies the fast path: a freshly constructed
|
||||
// App is idle and WaitForIdle returns immediately without consulting the
|
||||
// timeout.
|
||||
func TestWaitForIdle_AlreadyIdle(t *testing.T) {
|
||||
app := newTestApp(newStub())
|
||||
defer app.Close()
|
||||
|
||||
start := time.Now()
|
||||
if err := app.WaitForIdle(2 * time.Second); err != nil {
|
||||
t.Fatalf("WaitForIdle on idle app: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("WaitForIdle blocked for %s on already-idle app", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_BlocksUntilDrain reproduces the issue #63 race: while
|
||||
// drainQueue holds busy==true the call should block, then return nil as soon
|
||||
// as the drain completes.
|
||||
func TestWaitForIdle_BlocksUntilDrain(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
var gateOnce sync.Once
|
||||
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
t.Cleanup(func() {
|
||||
closeGate()
|
||||
app.Close()
|
||||
})
|
||||
|
||||
app.Run("hello")
|
||||
|
||||
// Confirm the agent is busy before we start waiting.
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- app.WaitForIdle(5 * time.Second)
|
||||
}()
|
||||
|
||||
// Should not return while the stub is blocked.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("WaitForIdle returned early (err=%v) while agent still busy", err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
closeGate()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForIdle: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("WaitForIdle did not return after drain completed")
|
||||
}
|
||||
|
||||
if app.IsBusy() {
|
||||
t.Fatal("app still reports busy after WaitForIdle returned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_TimeoutReturnsErrAgentBusy verifies that a slow turn yields
|
||||
// ErrAgentBusy (detectable via errors.Is) when the deadline elapses.
|
||||
func TestWaitForIdle_TimeoutReturnsErrAgentBusy(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
// Release the stub before Close so wg.Wait() can return.
|
||||
t.Cleanup(func() {
|
||||
close(gate)
|
||||
app.Close()
|
||||
})
|
||||
|
||||
app.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
err := app.WaitForIdle(50 * time.Millisecond)
|
||||
if !errors.Is(err, ErrAgentBusy) {
|
||||
t.Fatalf("expected ErrAgentBusy on timeout, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_ZeroTimeoutWaitsIndefinitely verifies that a non-positive
|
||||
// timeout still blocks until idle (or shutdown) — not an instant ErrAgentBusy.
|
||||
func TestWaitForIdle_ZeroTimeoutWaitsIndefinitely(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
var gateOnce sync.Once
|
||||
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
t.Cleanup(func() {
|
||||
closeGate()
|
||||
app.Close()
|
||||
})
|
||||
|
||||
app.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.WaitForIdle(0) }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("WaitForIdle(0) returned early with %v while agent was busy", err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
closeGate()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForIdle(0) returned %v after idle", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("WaitForIdle(0) did not return after drain completed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_AppClose verifies that shutting down the app while a
|
||||
// caller is blocked in WaitForIdle releases the wait.
|
||||
func TestWaitForIdle_AppClose(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
|
||||
app.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.WaitForIdle(5 * time.Second) }()
|
||||
|
||||
// Give the goroutine a moment to enter the wait.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// rootCancel is called by Close, which should release the waiter
|
||||
// before drainQueue itself observes the cancellation and clears busy.
|
||||
go func() {
|
||||
// Unblock the stub so Close() can proceed past wg.Wait().
|
||||
close(gate)
|
||||
}()
|
||||
app.Close()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
// Either rootCtx cancellation propagated first (err = context.Canceled)
|
||||
// or the drain finished cleanly first (err == nil); both are
|
||||
// acceptable terminations. The key invariant is that WaitForIdle
|
||||
// does not hang past Close.
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("WaitForIdle returned unexpected error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("WaitForIdle did not return after Close()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestNewSessionFromExtension_NoTUI verifies the headless guard: with
|
||||
// no Bubble Tea program registered the call fails fast (no busy-wait).
|
||||
func TestRequestNewSessionFromExtension_NoTUI(t *testing.T) {
|
||||
app := newTestApp(newStub())
|
||||
defer app.Close()
|
||||
|
||||
err := app.RequestNewSessionFromExtension("hello")
|
||||
if err == nil {
|
||||
t.Fatal("expected error in headless mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no interactive TUI") {
|
||||
t.Fatalf("expected 'no interactive TUI' error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBusyTransitionsSignalIdleCh exercises the setBusyLocked invariants
|
||||
// directly: a fresh App is idle (closed channel); Run() opens a new channel
|
||||
// that is then closed when drainQueue exits.
|
||||
func TestBusyTransitionsSignalIdleCh(t *testing.T) {
|
||||
app := newTestApp(newStub("ok"))
|
||||
defer app.Close()
|
||||
|
||||
// Initial state: closed channel, busy==false.
|
||||
busy, ch := app.idleSnapshot()
|
||||
if busy {
|
||||
t.Fatal("freshly constructed App should not be busy")
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
t.Fatal("initial idleCh should already be closed")
|
||||
}
|
||||
|
||||
gate := make(chan struct{})
|
||||
var gateOnce sync.Once
|
||||
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
|
||||
stub := newStubWithFuncs(func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("ok"), nil
|
||||
})
|
||||
app2 := newTestApp(stub)
|
||||
t.Cleanup(func() {
|
||||
closeGate()
|
||||
app2.Close()
|
||||
})
|
||||
|
||||
app2.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app2.IsBusy() }) {
|
||||
t.Fatal("app2 never became busy")
|
||||
}
|
||||
|
||||
_, ch2 := app2.idleSnapshot()
|
||||
select {
|
||||
case <-ch2:
|
||||
t.Fatal("idleCh should be open while busy")
|
||||
default:
|
||||
}
|
||||
|
||||
closeGate()
|
||||
|
||||
select {
|
||||
case <-ch2:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("idleCh was never closed after drain completed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrAgentBusy is returned (wrapped) when an extension API call that requires
|
||||
// the agent to be idle cannot proceed because the agent is still processing a
|
||||
// turn or post-turn hooks. Most notably, ctx.NewSession waits for idle
|
||||
// internally; if its wait deadline elapses it returns an error that wraps
|
||||
// this sentinel.
|
||||
//
|
||||
// Extensions can detect the condition with errors.Is:
|
||||
//
|
||||
// if err := ctx.NewSession(prompt); err != nil {
|
||||
// if errors.Is(err, ext.ErrAgentBusy) {
|
||||
// // agent never settled — fall back to a queued message instead
|
||||
// }
|
||||
// }
|
||||
var ErrAgentBusy = errors.New("agent is busy")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types (used by runner, NOT exposed to Yaegi)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -130,10 +149,24 @@ type Context struct {
|
||||
// expanded the same way they are for normal user input. Pass an empty
|
||||
// string to start an empty session.
|
||||
//
|
||||
// Returns an error if the agent is currently busy, if a registered
|
||||
// BeforeSessionSwitch handler cancels the switch, or if the new
|
||||
// session file cannot be created. In non-interactive (ACP / headless)
|
||||
// mode this is a no-op that returns an error.
|
||||
// If the agent is currently busy when NewSession is called (for example,
|
||||
// from an OnAgentEnd hook that fires before the agent fully settles, or
|
||||
// while post-turn formatters/linters are still running), the call blocks
|
||||
// until the agent transitions to idle. This avoids the v0.79.0
|
||||
// phase-handoff race where NewSession from OnAgentEnd would fail with
|
||||
// "agent is busy" because TurnEnd fires before the busy flag clears.
|
||||
// The wait has a generous internal timeout; if it elapses the returned
|
||||
// error wraps ErrAgentBusy (detectable with errors.Is).
|
||||
//
|
||||
// Returns an error if the agent does not become idle within the wait
|
||||
// window, if a registered BeforeSessionSwitch handler cancels the
|
||||
// switch, or if the new session file cannot be created. In
|
||||
// non-interactive (ACP / headless) mode this is a no-op that returns
|
||||
// an error.
|
||||
//
|
||||
// Because NewSession may block, call it from a goroutine — not
|
||||
// directly from inside an event handler that the agent loop is waiting
|
||||
// on.
|
||||
//
|
||||
// Typical pattern — start a fresh session at the end of a phase by
|
||||
// reading a handoff file:
|
||||
@@ -145,7 +178,9 @@ type Context struct {
|
||||
// }
|
||||
// last := msgs[len(msgs)-1].Content
|
||||
// if strings.Contains(last, "<HANDOFF_READY>") {
|
||||
// _ = ctx.NewSession("Read @HANDOFF.md and continue the next phase.")
|
||||
// go func() {
|
||||
// _ = ctx.NewSession("Read @HANDOFF.md and continue the next phase.")
|
||||
// }()
|
||||
// }
|
||||
// })
|
||||
NewSession func(prompt string) error
|
||||
|
||||
@@ -28,6 +28,11 @@ func Symbols() interp.Exports {
|
||||
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
|
||||
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
|
||||
|
||||
// Sentinel errors. Extensions detect them with errors.Is:
|
||||
//
|
||||
// if errors.Is(err, ext.ErrAgentBusy) { ... }
|
||||
"ErrAgentBusy": reflect.ValueOf(&ErrAgentBusy).Elem(),
|
||||
|
||||
// Session types
|
||||
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
|
||||
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
|
||||
|
||||
@@ -53,6 +53,11 @@ type AgentSetupOptions struct {
|
||||
// Debug enables debug logging. When zero-value, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
Debug bool
|
||||
// DebugLogger, if non-nil, is used directly as the engine/MCP debug
|
||||
// logger — overriding the built-in SimpleDebugLogger / BufferedDebugLogger
|
||||
// selected by Debug + UseBufferedLogger. Callers supply this when they
|
||||
// want to route debug output into their own logging system.
|
||||
DebugLogger tools.DebugLogger
|
||||
// NoExtensions skips extension loading. When false, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
NoExtensions bool
|
||||
@@ -192,7 +197,12 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
// Create the appropriate debug logger.
|
||||
var debugLogger tools.DebugLogger
|
||||
var bufferedLogger *tools.BufferedDebugLogger
|
||||
if debugEnabled {
|
||||
switch {
|
||||
case opts.DebugLogger != nil:
|
||||
// Caller-supplied logger wins unconditionally. Its IsDebugEnabled()
|
||||
// is the source of truth for whether downstream code emits messages.
|
||||
debugLogger = opts.DebugLogger
|
||||
case debugEnabled:
|
||||
if opts.UseBufferedLogger {
|
||||
bufferedLogger = tools.NewBufferedDebugLogger(true)
|
||||
debugLogger = bufferedLogger
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-3
@@ -74,7 +74,8 @@ host, err := kit.NewAgent(ctx,
|
||||
|
||||
Helpers: `WithModel`, `WithSystemPrompt`, `WithStreaming`, `WithMaxTokens`,
|
||||
`WithThinkingLevel`, `WithTools`, `WithExtraTools`, `WithProviderAPIKey`,
|
||||
`WithProviderURL`, `WithConfigFile`, `WithDebug`, and `Ephemeral`. `Option` is
|
||||
`WithProviderURL`, `WithConfigFile`, `WithDebug`, `WithDebugLogger`, and
|
||||
`Ephemeral`. `Option` is
|
||||
a plain `func(*Options)`, so you can define your own. For fields without a
|
||||
`With*` helper (`MCPConfig`, `InProcessMCPServers`, `SessionManager`, MCP task
|
||||
tuning) construct an `Options` value and call `kit.New`.
|
||||
@@ -329,7 +330,6 @@ kit.LLMFilePart // {Filename, Data []byte, MediaType}
|
||||
// Agent configuration — concrete Kit-owned structs and function types.
|
||||
// All fields use SDK types (e.g. `[]kit.Tool`), so consumers can construct
|
||||
// these without importing any LLM-provider package.
|
||||
kit.AgentConfig // Lower-level agent config — prefer Options unless you need direct control
|
||||
kit.DebugLogger // Interface: LogDebug(string) / IsDebugEnabled() bool
|
||||
kit.MCPTaskConfig // Task-aware MCP tools/call config (modes, polling, progress)
|
||||
kit.ToolCallHandler // func(toolCallID, toolName, toolArgs string)
|
||||
@@ -403,7 +403,8 @@ Key `Options` fields for SDK usage:
|
||||
| `SessionPath` | Open specific session file |
|
||||
| `Continue` | Resume most recent session |
|
||||
| `InProcessMCPServers` | Map of name → `*kit.MCPServer` for in-process MCP servers |
|
||||
| `Debug` | Enable debug logging |
|
||||
| `Debug` | Enable debug logging via the built-in console logger (ignored when `DebugLogger` is set) |
|
||||
| `DebugLogger` | Custom `DebugLogger` implementation — routes engine + MCP debug output into your own logging system |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
)
|
||||
|
||||
// TestAgentConfigToInternal verifies that the SDK-side AgentConfig converts
|
||||
// faithfully to the internal agent.AgentConfig representation, preserving
|
||||
// every field consumed by the internal agent layer.
|
||||
//
|
||||
// Regression test for https://github.com/mark3labs/kit/issues/30.
|
||||
func TestAgentConfigToInternal(t *testing.T) {
|
||||
t.Run("nil receiver returns nil", func(t *testing.T) {
|
||||
var c *AgentConfig
|
||||
if got := c.toInternal(); got != nil {
|
||||
t.Errorf("nil.toInternal() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scalar fields round-trip", func(t *testing.T) {
|
||||
c := &AgentConfig{
|
||||
SystemPrompt: "sys",
|
||||
MaxSteps: 7,
|
||||
StreamingEnabled: true,
|
||||
DisableCoreTools: true,
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got == nil {
|
||||
t.Fatal("toInternal() = nil")
|
||||
}
|
||||
if got.SystemPrompt != "sys" {
|
||||
t.Errorf("SystemPrompt = %q, want %q", got.SystemPrompt, "sys")
|
||||
}
|
||||
if got.MaxSteps != 7 {
|
||||
t.Errorf("MaxSteps = %d, want 7", got.MaxSteps)
|
||||
}
|
||||
if !got.StreamingEnabled {
|
||||
t.Error("StreamingEnabled = false, want true")
|
||||
}
|
||||
if !got.DisableCoreTools {
|
||||
t.Error("DisableCoreTools = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tool slices propagate without conversion", func(t *testing.T) {
|
||||
// Tool is a type alias for the underlying LLM-tool type, so the
|
||||
// SDK []Tool and internal []fantasy.AgentTool slices share the
|
||||
// same backing array after conversion.
|
||||
tool := NewTool[struct{}]("noop", "noop", nil)
|
||||
c := &AgentConfig{
|
||||
CoreTools: []Tool{tool},
|
||||
ExtraTools: []Tool{tool, tool},
|
||||
}
|
||||
got := c.toInternal()
|
||||
if len(got.CoreTools) != 1 {
|
||||
t.Errorf("CoreTools len = %d, want 1", len(got.CoreTools))
|
||||
}
|
||||
if len(got.ExtraTools) != 2 {
|
||||
t.Errorf("ExtraTools len = %d, want 2", len(got.ExtraTools))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tool wrapper is invoked through internal config", func(t *testing.T) {
|
||||
called := false
|
||||
c := &AgentConfig{
|
||||
ToolWrapper: func(in []Tool) []Tool {
|
||||
called = true
|
||||
return in
|
||||
},
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got.ToolWrapper == nil {
|
||||
t.Fatal("internal ToolWrapper is nil")
|
||||
}
|
||||
_ = got.ToolWrapper(nil)
|
||||
if !called {
|
||||
t.Error("SDK ToolWrapper was not invoked through the internal config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OnMCPServerLoaded propagates", func(t *testing.T) {
|
||||
var captured string
|
||||
wantErr := errors.New("boom")
|
||||
c := &AgentConfig{
|
||||
OnMCPServerLoaded: func(name string, _ int, _ error) {
|
||||
captured = name
|
||||
},
|
||||
}
|
||||
got := c.toInternal()
|
||||
got.OnMCPServerLoaded("svr", 3, wantErr)
|
||||
if captured != "svr" {
|
||||
t.Errorf("OnMCPServerLoaded captured = %q, want %q", captured, "svr")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DebugLogger propagates", func(t *testing.T) {
|
||||
dl := &fakeDebugLogger{enabled: true}
|
||||
c := &AgentConfig{DebugLogger: dl}
|
||||
got := c.toInternal()
|
||||
if got.DebugLogger == nil {
|
||||
t.Fatal("internal DebugLogger is nil")
|
||||
}
|
||||
if !got.DebugLogger.IsDebugEnabled() {
|
||||
t.Error("IsDebugEnabled = false, want true")
|
||||
}
|
||||
got.DebugLogger.LogDebug("hello")
|
||||
if len(dl.messages) != 1 || dl.messages[0] != "hello" {
|
||||
t.Errorf("messages = %v, want [hello]", dl.messages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCPTaskConfig propagates with mode + progress", func(t *testing.T) {
|
||||
c := &AgentConfig{
|
||||
MCPTaskConfig: MCPTaskConfig{
|
||||
PerServerMode: map[string]MCPTaskMode{
|
||||
"build-svr": MCPTaskModeAlways,
|
||||
},
|
||||
DefaultTTL: 30 * time.Second,
|
||||
PollInterval: 250 * time.Millisecond,
|
||||
MaxPollInterval: 2 * time.Second,
|
||||
Timeout: 5 * time.Minute,
|
||||
Progress: func(_ MCPTaskProgress) {},
|
||||
},
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got.MCPTaskConfig.DefaultTTL != 30*time.Second {
|
||||
t.Errorf("DefaultTTL = %v, want 30s", got.MCPTaskConfig.DefaultTTL)
|
||||
}
|
||||
if got.MCPTaskConfig.PollInterval != 250*time.Millisecond {
|
||||
t.Errorf("PollInterval = %v, want 250ms", got.MCPTaskConfig.PollInterval)
|
||||
}
|
||||
if got.MCPTaskConfig.MaxPollInterval != 2*time.Second {
|
||||
t.Errorf("MaxPollInterval = %v, want 2s", got.MCPTaskConfig.MaxPollInterval)
|
||||
}
|
||||
if got.MCPTaskConfig.Timeout != 5*time.Minute {
|
||||
t.Errorf("Timeout = %v, want 5m", got.MCPTaskConfig.Timeout)
|
||||
}
|
||||
mode, ok := got.MCPTaskConfig.PerServerMode["build-svr"]
|
||||
if !ok {
|
||||
t.Fatal("PerServerMode missing 'build-svr'")
|
||||
}
|
||||
if string(mode) != string(MCPTaskModeAlways) {
|
||||
t.Errorf("mode = %q, want %q", mode, MCPTaskModeAlways)
|
||||
}
|
||||
if got.MCPTaskConfig.Progress == nil {
|
||||
t.Fatal("internal Progress handler is nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auth and token store factories are wired", func(t *testing.T) {
|
||||
auth := &fakeAuthHandler{}
|
||||
tokenCalls := 0
|
||||
var tokenServer string
|
||||
factory := MCPTokenStoreFactory(func(server string) (MCPTokenStore, error) {
|
||||
tokenCalls++
|
||||
tokenServer = server
|
||||
return nil, nil
|
||||
})
|
||||
c := &AgentConfig{
|
||||
AuthHandler: auth,
|
||||
TokenStoreFactory: factory,
|
||||
}
|
||||
got := c.toInternal()
|
||||
if got.AuthHandler == nil {
|
||||
t.Fatal("internal AuthHandler is nil")
|
||||
}
|
||||
if got.TokenStoreFactory == nil {
|
||||
t.Fatal("internal TokenStoreFactory is nil")
|
||||
}
|
||||
_, _ = got.TokenStoreFactory("https://example.test")
|
||||
if tokenCalls != 1 {
|
||||
t.Errorf("token factory call count = %d, want 1", tokenCalls)
|
||||
}
|
||||
if tokenServer != "https://example.test" {
|
||||
t.Errorf("token factory server arg = %q", tokenServer)
|
||||
}
|
||||
if got.AuthHandler.RedirectURI() != "redirect" {
|
||||
t.Errorf("RedirectURI = %q, want %q", got.AuthHandler.RedirectURI(), "redirect")
|
||||
}
|
||||
})
|
||||
|
||||
// Compile-time check that the internal type is what we expect.
|
||||
//nolint:staticcheck // QF1011: explicit type asserts the conversion target.
|
||||
var _ *agent.AgentConfig = (&AgentConfig{}).toInternal()
|
||||
}
|
||||
|
||||
// fakeAuthHandler implements both kit.MCPAuthHandler and the structurally
|
||||
// identical tools.MCPAuthHandler used by the internal layer.
|
||||
type fakeAuthHandler struct{}
|
||||
|
||||
func (f *fakeAuthHandler) RedirectURI() string { return "redirect" }
|
||||
func (f *fakeAuthHandler) HandleAuth(_ context.Context, _ string, _ string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// fakeDebugLogger implements kit.DebugLogger for tests.
|
||||
type fakeDebugLogger struct {
|
||||
enabled bool
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *fakeDebugLogger) LogDebug(m string) { f.messages = append(f.messages, m) }
|
||||
func (f *fakeDebugLogger) IsDebugEnabled() bool { return f.enabled }
|
||||
+18
-1
@@ -1047,9 +1047,25 @@ type Options struct {
|
||||
AutoCompact bool // Auto-compact when near context limit
|
||||
CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults)
|
||||
|
||||
// Debug enables debug logging for the SDK.
|
||||
// Debug enables debug logging for the SDK. When DebugLogger is nil this
|
||||
// flag selects between the default no-op SimpleDebugLogger (Debug=false)
|
||||
// and the built-in console/buffered logger (Debug=true). When DebugLogger
|
||||
// is non-nil this flag is ignored — the supplied logger's
|
||||
// IsDebugEnabled() controls whether downstream code emits messages.
|
||||
Debug bool
|
||||
|
||||
// DebugLogger, if non-nil, routes low-level debug output from the engine
|
||||
// and the MCP tool plumbing to a caller-supplied implementation. This is
|
||||
// the SDK escape hatch for embedders that want to forward debug output
|
||||
// into their own logging system (zap, slog, log/charm, an in-app TUI
|
||||
// panel, etc.) instead of the built-in console logger.
|
||||
//
|
||||
// When nil (default) the Debug bool controls whether the built-in logger
|
||||
// is installed. When non-nil this logger is used unconditionally and the
|
||||
// Debug bool is ignored; the supplied logger's IsDebugEnabled() reports
|
||||
// whether downstream code should bother formatting messages.
|
||||
DebugLogger DebugLogger
|
||||
|
||||
// MCPAuthHandler handles OAuth authorization for remote MCP servers.
|
||||
// When set, remote transports (streamable HTTP, SSE) are configured
|
||||
// with OAuth support. If the server returns a 401, the handler is
|
||||
@@ -1514,6 +1530,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
ProviderConfig: providerConfig,
|
||||
Debug: debug,
|
||||
DebugLogger: opts.DebugLogger,
|
||||
NoExtensions: noExtensions,
|
||||
MaxSteps: maxSteps,
|
||||
StreamingEnabled: streaming,
|
||||
|
||||
+5
-33
@@ -102,10 +102,11 @@ type MCPTaskProgressHandler func(MCPTaskProgress)
|
||||
// are optional; the zero value disables progress callbacks and applies
|
||||
// sensible polling defaults inside the engine.
|
||||
//
|
||||
// For most consumers, the flat [Options] fields (`MCPTaskMode`,
|
||||
// `MCPTaskTTL`, `MCPTaskPollInterval`, `MCPTaskMaxPollInterval`,
|
||||
// `MCPTaskTimeout`, `MCPTaskProgress`) are the preferred entry point.
|
||||
// MCPTaskConfig is exposed for the low-level [AgentConfig] path.
|
||||
// Most consumers configure these via the flat [Options] fields
|
||||
// (`MCPTaskMode`, `MCPTaskTTL`, `MCPTaskPollInterval`,
|
||||
// `MCPTaskMaxPollInterval`, `MCPTaskTimeout`, `MCPTaskProgress`). The
|
||||
// MCPTaskConfig type itself is retained for downstream consumers that
|
||||
// receive it on engine-facing call sites.
|
||||
type MCPTaskConfig struct {
|
||||
// PerServerMode overrides the per-server task mode resolved from
|
||||
// [MCPServerConfig]. Keys are server names. Missing entries fall back
|
||||
@@ -133,35 +134,6 @@ type MCPTaskConfig struct {
|
||||
Progress MCPTaskProgressHandler
|
||||
}
|
||||
|
||||
// toToolsConfig converts the SDK-level [MCPTaskConfig] to the internal
|
||||
// tools-package representation. Keeps the dependency arrow internal-only.
|
||||
func (c MCPTaskConfig) toToolsConfig() tools.MCPTaskConfig {
|
||||
cfg := tools.MCPTaskConfig{
|
||||
DefaultTTL: c.DefaultTTL,
|
||||
PollInterval: c.PollInterval,
|
||||
MaxPollInterval: c.MaxPollInterval,
|
||||
Timeout: c.Timeout,
|
||||
}
|
||||
if len(c.PerServerMode) > 0 {
|
||||
cfg.PerServerMode = make(map[string]tools.MCPTaskMode, len(c.PerServerMode))
|
||||
for k, v := range c.PerServerMode {
|
||||
cfg.PerServerMode[k] = tools.MCPTaskMode(v)
|
||||
}
|
||||
}
|
||||
if c.Progress != nil {
|
||||
h := c.Progress
|
||||
cfg.Progress = func(p tools.MCPTaskProgress) {
|
||||
h(MCPTaskProgress{
|
||||
Server: p.Server,
|
||||
TaskID: p.TaskID,
|
||||
Status: MCPTaskStatus(p.Status),
|
||||
Message: p.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// mcpTaskOptions carries SDK consumer configuration into the agent setup.
|
||||
// Stored on Options as a single value so the public surface stays compact;
|
||||
// individual fields are exposed via WithMCP* builder functions.
|
||||
|
||||
@@ -83,6 +83,17 @@ func WithConfigFile(path string) Option { return func(o *Options) { o.ConfigFile
|
||||
// WithDebug enables SDK debug logging.
|
||||
func WithDebug() Option { return func(o *Options) { o.Debug = true } }
|
||||
|
||||
// WithDebugLogger installs a caller-supplied [DebugLogger] for low-level
|
||||
// engine and MCP tool plumbing output. When set this overrides the built-in
|
||||
// logger selected by [WithDebug] — messages flow into the supplied logger
|
||||
// unconditionally, and the logger's IsDebugEnabled reports whether downstream
|
||||
// code should bother formatting them. Use this to forward Kit's debug output
|
||||
// into your application's logging system (slog, zap, charm/log, an in-app
|
||||
// panel, etc.).
|
||||
func WithDebugLogger(l DebugLogger) Option {
|
||||
return func(o *Options) { o.DebugLogger = l }
|
||||
}
|
||||
|
||||
// Ephemeral configures an in-memory session with no persistence (equivalent to
|
||||
// Options.NoSession = true).
|
||||
func Ephemeral() Option { return func(o *Options) { o.NoSession = true } }
|
||||
|
||||
+4
-108
@@ -5,13 +5,11 @@ import (
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/compaction"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
@@ -83,9 +81,10 @@ type MCPServerConfig = config.MCPServerConfig
|
||||
// concurrent use.
|
||||
//
|
||||
// Most consumers do not need to provide one; pass [Options.Debug] = true
|
||||
// to use the default logger. DebugLogger is exposed for the low-level
|
||||
// [AgentConfig] path and for embedders that want to route debug output
|
||||
// into their own logging system.
|
||||
// (or use [WithDebug]) to install the built-in console logger. DebugLogger
|
||||
// is the escape hatch for embedders that want to route debug output into
|
||||
// their own logging system — install one via [Options.DebugLogger] or
|
||||
// [WithDebugLogger].
|
||||
type DebugLogger interface {
|
||||
// LogDebug records a single debug message. Implementations may drop,
|
||||
// buffer, or render the message however they choose.
|
||||
@@ -95,109 +94,6 @@ type DebugLogger interface {
|
||||
IsDebugEnabled() bool
|
||||
}
|
||||
|
||||
// AgentConfig holds configuration options for constructing an agent at the
|
||||
// SDK boundary. All fields use SDK-owned types, so consumers can populate
|
||||
// this struct without importing any underlying LLM-provider package.
|
||||
//
|
||||
// For most use cases, prefer the high-level [New] entry point with
|
||||
// [Options]. AgentConfig is exposed for advanced consumers that need
|
||||
// direct access to the lower-level agent configuration shape.
|
||||
type AgentConfig struct {
|
||||
// ModelConfig holds the LLM provider configuration. A nil value means
|
||||
// that the default provider/model resolution will be used.
|
||||
ModelConfig *ProviderConfig
|
||||
|
||||
// MCPConfig describes any MCP servers whose tools should be loaded
|
||||
// alongside core tools.
|
||||
MCPConfig *Config
|
||||
|
||||
// SystemPrompt is the system prompt sent to the LLM.
|
||||
SystemPrompt string
|
||||
|
||||
// MaxSteps caps the number of LLM iterations per turn. A value of
|
||||
// zero means no cap is applied at this layer.
|
||||
MaxSteps int
|
||||
|
||||
// StreamingEnabled controls whether the agent streams responses.
|
||||
StreamingEnabled bool
|
||||
|
||||
// AuthHandler handles OAuth authorization for remote MCP servers.
|
||||
// When nil, remote MCP servers requiring OAuth will fail to connect.
|
||||
AuthHandler MCPAuthHandler
|
||||
|
||||
// TokenStoreFactory, if non-nil, creates a custom token store for each
|
||||
// remote MCP server's OAuth tokens. When nil, the default file-based
|
||||
// token store is used.
|
||||
TokenStoreFactory MCPTokenStoreFactory
|
||||
|
||||
// CoreTools overrides the default core tool set. If empty, [AllTools]
|
||||
// is used. Provide a custom tool set (e.g. [CodingTools] or tools
|
||||
// built with a custom WorkDir) to scope agent capabilities.
|
||||
CoreTools []Tool
|
||||
|
||||
// DisableCoreTools, when true, prevents loading any core tools.
|
||||
// Combined with empty CoreTools this yields a chat-only agent with
|
||||
// no built-in tools.
|
||||
DisableCoreTools bool
|
||||
|
||||
// ExtraTools are additional tools loaded alongside core and MCP tools.
|
||||
ExtraTools []Tool
|
||||
|
||||
// ToolWrapper, if non-nil, wraps the combined tool list before it is
|
||||
// handed to the LLM. Used to intercept tool calls or results.
|
||||
ToolWrapper func([]Tool) []Tool
|
||||
|
||||
// OnMCPServerLoaded, if non-nil, is invoked once for each MCP server
|
||||
// when its tools have finished loading (or failed). Called from a
|
||||
// background goroutine.
|
||||
OnMCPServerLoaded func(serverName string, toolCount int, err error)
|
||||
|
||||
// DebugLogger receives low-level debug output from the engine and the
|
||||
// MCP tool plumbing. Nil means no debug output is emitted at this
|
||||
// layer (regardless of [Options.Debug], which feeds the higher-level
|
||||
// [New] entry point). Pass an implementation here when wiring a custom
|
||||
// logger through the lower-level AgentConfig path.
|
||||
DebugLogger DebugLogger
|
||||
|
||||
// MCPTaskConfig configures task-aware MCP tools/call execution — mode
|
||||
// overrides, polling intervals, timeouts, and the progress handler.
|
||||
// The zero value preserves historical synchronous-only behaviour for
|
||||
// any server that didn't advertise task support during initialize.
|
||||
MCPTaskConfig MCPTaskConfig
|
||||
}
|
||||
|
||||
// toInternal converts an AgentConfig to its internal representation.
|
||||
// Slice and function fields convert without allocation because [Tool]
|
||||
// is a type alias for the underlying LLM-tool type.
|
||||
func (c *AgentConfig) toInternal() *agent.AgentConfig {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := &agent.AgentConfig{
|
||||
ModelConfig: c.ModelConfig,
|
||||
MCPConfig: c.MCPConfig,
|
||||
SystemPrompt: c.SystemPrompt,
|
||||
MaxSteps: c.MaxSteps,
|
||||
StreamingEnabled: c.StreamingEnabled,
|
||||
CoreTools: c.CoreTools,
|
||||
DisableCoreTools: c.DisableCoreTools,
|
||||
ExtraTools: c.ExtraTools,
|
||||
ToolWrapper: c.ToolWrapper,
|
||||
OnMCPServerLoaded: c.OnMCPServerLoaded,
|
||||
}
|
||||
if c.AuthHandler != nil {
|
||||
out.AuthHandler = c.AuthHandler
|
||||
}
|
||||
if c.TokenStoreFactory != nil {
|
||||
out.TokenStoreFactory = tools.TokenStoreFactory(c.TokenStoreFactory)
|
||||
}
|
||||
if c.DebugLogger != nil {
|
||||
out.DebugLogger = c.DebugLogger
|
||||
}
|
||||
out.MCPTaskConfig = c.MCPTaskConfig.toToolsConfig()
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolCallHandler is invoked when the LLM produces a tool call. It receives
|
||||
// the call ID, tool name, and the JSON-encoded input arguments.
|
||||
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
|
||||
|
||||
+35
-41
@@ -264,30 +264,31 @@ func TestConvertFromLLMMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentConfigNoFantasyImport verifies AgentConfig can be populated with
|
||||
// every field — including CoreTools, ExtraTools, and ToolWrapper — using
|
||||
// only SDK-owned types. This test deliberately does not import
|
||||
// "charm.land/fantasy"; the package compiling at all is the proof that the
|
||||
// SDK no longer leaks the dependency name through AgentConfig.
|
||||
// TestOptionsNoFantasyImport verifies Options can be populated with the
|
||||
// tool-related fields — Tools and ExtraTools — using only SDK-owned types.
|
||||
// This test deliberately does not import "charm.land/fantasy"; the package
|
||||
// compiling at all is the proof that the SDK no longer leaks the dependency
|
||||
// name through the Options surface.
|
||||
//
|
||||
// Tool-call interception (formerly the AgentConfig.ToolWrapper escape hatch)
|
||||
// is covered by the hook system — [Kit.OnBeforeToolCall] /
|
||||
// [Kit.OnAfterToolResult] — whose hook payload types also use only
|
||||
// SDK-owned identifiers; see hooks_test.go.
|
||||
//
|
||||
// Regression test for https://github.com/mark3labs/kit/issues/30.
|
||||
func TestAgentConfigNoFantasyImport(t *testing.T) {
|
||||
func TestOptionsNoFantasyImport(t *testing.T) {
|
||||
myTool := kit.NewTool[struct{}]("noop", "does nothing", func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
|
||||
return kit.TextResult("ok"), nil
|
||||
})
|
||||
|
||||
wrapperCalled := false
|
||||
cfg := kit.AgentConfig{
|
||||
SystemPrompt: "you are a tester",
|
||||
MaxSteps: 5,
|
||||
StreamingEnabled: true,
|
||||
CoreTools: []kit.Tool{myTool},
|
||||
ExtraTools: []kit.Tool{myTool},
|
||||
DisableCoreTools: false,
|
||||
ToolWrapper: func(in []kit.Tool) []kit.Tool {
|
||||
wrapperCalled = true
|
||||
return in
|
||||
},
|
||||
streaming := true
|
||||
cfg := kit.Options{
|
||||
SystemPrompt: "you are a tester",
|
||||
MaxSteps: 5,
|
||||
Streaming: &streaming,
|
||||
Tools: []kit.Tool{myTool},
|
||||
ExtraTools: []kit.Tool{myTool},
|
||||
DisableCoreTools: false,
|
||||
OnMCPServerLoaded: func(_ string, _ int, _ error) {},
|
||||
}
|
||||
|
||||
@@ -297,36 +298,29 @@ func TestAgentConfigNoFantasyImport(t *testing.T) {
|
||||
if cfg.MaxSteps != 5 {
|
||||
t.Errorf("MaxSteps = %d, want 5", cfg.MaxSteps)
|
||||
}
|
||||
if !cfg.StreamingEnabled {
|
||||
t.Error("StreamingEnabled = false, want true")
|
||||
if cfg.Streaming == nil || !*cfg.Streaming {
|
||||
t.Error("Streaming = false/nil, want true")
|
||||
}
|
||||
if len(cfg.CoreTools) != 1 {
|
||||
t.Errorf("CoreTools len = %d, want 1", len(cfg.CoreTools))
|
||||
if len(cfg.Tools) != 1 {
|
||||
t.Errorf("Tools len = %d, want 1", len(cfg.Tools))
|
||||
}
|
||||
if len(cfg.ExtraTools) != 1 {
|
||||
t.Errorf("ExtraTools len = %d, want 1", len(cfg.ExtraTools))
|
||||
}
|
||||
|
||||
// Exercise the wrapper to confirm the func type is usable.
|
||||
out := cfg.ToolWrapper(cfg.CoreTools)
|
||||
if !wrapperCalled {
|
||||
t.Error("ToolWrapper was not invoked")
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Errorf("wrapped tool list len = %d, want 1", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentConfigToolWrapperSignature documents that AgentConfig.ToolWrapper
|
||||
// uses kit.Tool (not the underlying provider type) in its signature.
|
||||
func TestAgentConfigToolWrapperSignature(t *testing.T) {
|
||||
//nolint:staticcheck // QF1011: explicit type asserts the SDK-side func signature.
|
||||
var _ func([]kit.Tool) []kit.Tool = func(in []kit.Tool) []kit.Tool { return in }
|
||||
cfg := kit.AgentConfig{
|
||||
ToolWrapper: func(in []kit.Tool) []kit.Tool { return in },
|
||||
}
|
||||
if cfg.ToolWrapper == nil {
|
||||
t.Fatal("ToolWrapper assignment failed")
|
||||
// TestToolSliceSignature documents that the kit.Tool alias — used by every
|
||||
// SDK tool-related surface (Options.Tools, Options.ExtraTools, WithTools,
|
||||
// WithExtraTools, hook payloads) — is referenced under its SDK-owned name
|
||||
// in user code, without any fantasy import.
|
||||
func TestToolSliceSignature(t *testing.T) {
|
||||
var tools []kit.Tool
|
||||
tools = append(tools, kit.NewTool[struct{}]("noop", "",
|
||||
func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
|
||||
return kit.TextResult("ok"), nil
|
||||
}))
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("unexpected tool slice length: %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,52 @@ func TestOptionFunctionsPlumbing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// recordingDebugLogger is a kit.DebugLogger used to verify WithDebugLogger
|
||||
// plumbs the supplied logger into Options. It records each LogDebug call.
|
||||
type recordingDebugLogger struct {
|
||||
enabled bool
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (l *recordingDebugLogger) LogDebug(m string) { l.messages = append(l.messages, m) }
|
||||
func (l *recordingDebugLogger) IsDebugEnabled() bool { return l.enabled }
|
||||
|
||||
// TestWithDebugLoggerPlumbing verifies that kit.WithDebugLogger assigns the
|
||||
// supplied logger to Options.DebugLogger. End-to-end propagation into the
|
||||
// engine is covered indirectly by the existing kitsetup tests; this test
|
||||
// pins the SDK-surface contract.
|
||||
func TestWithDebugLoggerPlumbing(t *testing.T) {
|
||||
l := &recordingDebugLogger{enabled: true}
|
||||
o := &kit.Options{}
|
||||
kit.WithDebugLogger(l)(o)
|
||||
if o.DebugLogger == nil {
|
||||
t.Fatal("WithDebugLogger: expected Options.DebugLogger to be set")
|
||||
}
|
||||
if o.DebugLogger != l {
|
||||
t.Error("WithDebugLogger: expected the supplied logger to be installed verbatim")
|
||||
}
|
||||
// Sanity: the installed logger satisfies the SDK interface contract.
|
||||
if !o.DebugLogger.IsDebugEnabled() {
|
||||
t.Error("installed logger IsDebugEnabled() returned false")
|
||||
}
|
||||
o.DebugLogger.LogDebug("hello")
|
||||
if len(l.messages) != 1 || l.messages[0] != "hello" {
|
||||
t.Errorf("LogDebug not forwarded; got %v", l.messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithDebugLoggerNilClears verifies that passing a nil logger to
|
||||
// WithDebugLogger clears any previously-installed logger. This lets later
|
||||
// options override earlier ones the same way WithModel / WithStreaming do.
|
||||
func TestWithDebugLoggerNilClears(t *testing.T) {
|
||||
o := &kit.Options{}
|
||||
kit.WithDebugLogger(&recordingDebugLogger{enabled: true})(o)
|
||||
kit.WithDebugLogger(nil)(o)
|
||||
if o.DebugLogger != nil {
|
||||
t.Errorf("WithDebugLogger(nil): expected DebugLogger to be cleared; got %#v", o.DebugLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptionOrderingOverrides verifies later options override earlier ones.
|
||||
func TestOptionOrderingOverrides(t *testing.T) {
|
||||
o := &kit.Options{}
|
||||
|
||||
@@ -31,6 +31,7 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
Streaming: ptrBool(true), // *bool: nil = unset (default true), &false = off
|
||||
Quiet: true,
|
||||
Debug: true,
|
||||
DebugLogger: myLogger, // optional; overrides Debug + built-in logger when non-nil
|
||||
|
||||
// Generation parameters (override env/config/per-model defaults)
|
||||
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
|
||||
@@ -103,7 +104,8 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
| `MaxSteps` | `int` | `0` | Max agent steps (0 = unlimited) |
|
||||
| `Streaming` | `*bool` | `nil` | Enable streaming output. `nil` leaves it to the precedence chain (env → config → default `true`); `&true`/`&false` forces it. Pointer so unset is distinct from explicit `false`. |
|
||||
| `Quiet` | `bool` | `false` | Suppress output |
|
||||
| `Debug` | `bool` | `false` | Enable debug logging |
|
||||
| `Debug` | `bool` | `false` | Enable debug logging via the built-in console / buffered logger. Ignored when `DebugLogger` is non-nil. |
|
||||
| `DebugLogger` | `DebugLogger` | `nil` | Caller-supplied logger that receives low-level engine + MCP tool plumbing debug output. When non-nil this overrides `Debug` — the supplied logger's `IsDebugEnabled()` controls downstream emission. See [Custom debug logger](#custom-debug-logger). |
|
||||
|
||||
### Generation parameters
|
||||
|
||||
@@ -346,6 +348,45 @@ loaded MCP server that advertises the corresponding capability.
|
||||
Context cancellation also works end-to-end: cancelling the `ctx` passed to a
|
||||
tool execution triggers a best-effort `tasks/cancel` before the call returns.
|
||||
|
||||
## Custom debug logger
|
||||
|
||||
Kit's engine and MCP tool plumbing emit low-level debug output through a
|
||||
`DebugLogger` interface. By default, setting `Debug: true` (or calling
|
||||
`WithDebug()`) installs the built-in console logger. To route the same output
|
||||
into your application's logging system instead, provide a custom
|
||||
implementation via `Options.DebugLogger` or `WithDebugLogger`.
|
||||
|
||||
```go
|
||||
type DebugLogger interface {
|
||||
LogDebug(message string)
|
||||
IsDebugEnabled() bool
|
||||
}
|
||||
```
|
||||
|
||||
When `DebugLogger` is non-nil it takes precedence over `Debug` — the
|
||||
supplied logger's `IsDebugEnabled()` reports whether downstream code should
|
||||
bother formatting messages.
|
||||
|
||||
**Example: forward to `log/slog`:**
|
||||
|
||||
```go
|
||||
import "log/slog"
|
||||
|
||||
type slogDebugLogger struct{ l *slog.Logger }
|
||||
|
||||
func (s *slogDebugLogger) LogDebug(m string) { s.l.Debug(m) }
|
||||
func (s *slogDebugLogger) IsDebugEnabled() bool { return true }
|
||||
|
||||
host, _ := kit.NewAgent(ctx,
|
||||
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
|
||||
kit.WithDebugLogger(&slogDebugLogger{l: slog.Default()}),
|
||||
)
|
||||
```
|
||||
|
||||
Implementations must be safe for concurrent use — messages can arrive
|
||||
from the engine goroutine, MCP connection pool, and tool execution paths
|
||||
simultaneously.
|
||||
|
||||
## Precedence
|
||||
|
||||
For any given generation or provider field, the effective value is resolved
|
||||
|
||||
@@ -80,6 +80,7 @@ Available options:
|
||||
| `WithProviderURL(string)` | `Options.ProviderURL` |
|
||||
| `WithConfigFile(string)` | `Options.ConfigFile` |
|
||||
| `WithDebug()` | `Options.Debug = true` |
|
||||
| `WithDebugLogger(DebugLogger)` | `Options.DebugLogger` (route engine + MCP debug output into a custom logger; overrides `WithDebug` when set) |
|
||||
| `Ephemeral()` | `Options.NoSession = true` |
|
||||
|
||||
Options are applied in order, so later options override earlier ones. `Option`
|
||||
|
||||
Reference in New Issue
Block a user