diff --git a/internal/generator/templates/profile.go.tmpl b/internal/generator/templates/profile.go.tmpl index 6049eed35..54f1f7391 100644 --- a/internal/generator/templates/profile.go.tmpl +++ b/internal/generator/templates/profile.go.tmpl @@ -17,8 +17,8 @@ import ( ) // Profile is a named set of flag values saved for reuse across invocations. -// HeyGen's "Beacon" pattern: one named context that a scheduled agent reuses -// day after day with the same voice/format but different input each run. +// Use a named profile when a scheduled or recurring workflow reuses the same +// saved flags while providing different input each run. type Profile struct { Name string `json:"name"` Description string `json:"description,omitempty"` diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl index 86c8a18d9..da2490e16 100644 --- a/internal/generator/templates/skill.md.tmpl +++ b/internal/generator/templates/skill.md.tmpl @@ -567,7 +567,7 @@ Unknown schemes are refused with a structured error naming the supported set. We ## Named Profiles -A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern. +A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run. ``` {{.Name}}-pp-cli profile save briefing --json diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go index 0fef47246..a9425a767 100644 --- a/internal/openapi/parser.go +++ b/internal/openapi/parser.go @@ -4398,6 +4398,7 @@ func mapParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []spec.P if schema != nil && schema.Default != nil { param.Default = schema.Default } + setParamMaximum(¶m, schema) if param.Positional { param.Required = true } @@ -4412,6 +4413,36 @@ func mapParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []spec.P return params } +// setParamMaximum records a numeric upper-bound constraint from the schema onto +// the param, normalizing the two OpenAPI encodings of an exclusive bound. +// OpenAPI 3.1 writes `exclusiveMaximum: N` as a number; OpenAPI 3.0 writes +// `maximum: N` plus `exclusiveMaximum: true`. Either way the largest legal value +// is strictly below N, so it lands in ExclusiveMaximum; a plain inclusive +// `maximum` lands in Maximum. Downstream (the sync profiler) turns whichever is +// set into an effective integer page-size cap. +func setParamMaximum(param *spec.Param, schema *openapi3.Schema) { + if param == nil || schema == nil { + return + } + // OpenAPI 3.0: `exclusiveMaximum: true` turns `maximum` into an exclusive + // bound — it is not also an inclusive one. + if schema.ExclusiveMax.IsTrue() { + if schema.Max != nil { + param.ExclusiveMaximum = schema.Max + } + return + } + // OpenAPI 3.1 (or no exclusive modifier): `maximum` is inclusive, and a + // numeric `exclusiveMaximum` is an independent assertion. Both may be + // present; capture each so the profiler can take the most restrictive. + if schema.Max != nil { + param.Maximum = schema.Max + } + if schema.ExclusiveMax.Value != nil { + param.ExclusiveMaximum = schema.ExclusiveMax.Value + } +} + func readParamURLNameOverrides(pathItem *openapi3.PathItem, op *openapi3.Operation) map[string]string { var out map[string]string add := func(extensions map[string]any, context string) { @@ -4827,6 +4858,7 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string if paramSchema != nil && paramSchema.Default != nil { param.Default = paramSchema.Default } + setParamMaximum(¶m, paramSchema) // For array types, propagate item-level enum as a Fields entry // so downstream consumers (profiler) can access it. if paramSchema != nil && paramSchema.Type != nil && paramSchema.Type.Is(openapi3.TypeArray) && diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go index 390d74a5d..612252495 100644 --- a/internal/openapi/parser_test.go +++ b/internal/openapi/parser_test.go @@ -11838,3 +11838,53 @@ paths: t.Fatalf("TenantScopeColumn = %q, want %q", got, "workspace") } } + +// setParamMaximum must normalize both OpenAPI encodings of an upper bound: a +// plain inclusive `maximum` into Maximum, and either exclusive form (3.1 +// numeric `exclusiveMaximum`, or 3.0 `maximum` + `exclusiveMaximum: true`) into +// ExclusiveMaximum. Only one field is ever set. Guards the sync page-size clamp +// (mvanhorn/cli-printing-press#3440) at the parse boundary. +func TestSetParamMaximumNormalizesBounds(t *testing.T) { + f := func(v float64) *float64 { return &v } + tru := true + + t.Run("inclusive maximum", func(t *testing.T) { + var p spec.Param + setParamMaximum(&p, &openapi3.Schema{Max: f(30)}) + require.NotNil(t, p.Maximum) + assert.Equal(t, 30.0, *p.Maximum) + assert.Nil(t, p.ExclusiveMaximum) + }) + + t.Run("openapi 3.1 numeric exclusiveMaximum", func(t *testing.T) { + var p spec.Param + setParamMaximum(&p, &openapi3.Schema{ExclusiveMax: openapi3.ExclusiveBound{Value: f(30)}}) + require.NotNil(t, p.ExclusiveMaximum) + assert.Equal(t, 30.0, *p.ExclusiveMaximum) + assert.Nil(t, p.Maximum) + }) + + t.Run("openapi 3.0 maximum plus exclusiveMaximum true", func(t *testing.T) { + var p spec.Param + setParamMaximum(&p, &openapi3.Schema{Max: f(30), ExclusiveMax: openapi3.ExclusiveBound{Bool: &tru}}) + require.NotNil(t, p.ExclusiveMaximum) + assert.Equal(t, 30.0, *p.ExclusiveMaximum) + assert.Nil(t, p.Maximum, "an exclusive maximum must not also populate the inclusive field") + }) + + t.Run("openapi 3.1 both maximum and numeric exclusiveMaximum", func(t *testing.T) { + var p spec.Param + setParamMaximum(&p, &openapi3.Schema{Max: f(30), ExclusiveMax: openapi3.ExclusiveBound{Value: f(100)}}) + require.NotNil(t, p.Maximum, "an inclusive maximum must survive alongside a numeric exclusiveMaximum") + assert.Equal(t, 30.0, *p.Maximum) + require.NotNil(t, p.ExclusiveMaximum) + assert.Equal(t, 100.0, *p.ExclusiveMaximum) + }) + + t.Run("no bound", func(t *testing.T) { + var p spec.Param + setParamMaximum(&p, &openapi3.Schema{}) + assert.Nil(t, p.Maximum) + assert.Nil(t, p.ExclusiveMaximum) + }) +} diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go index c6fb11e00..b1fb2dfae 100644 --- a/internal/profiler/profiler.go +++ b/internal/profiler/profiler.go @@ -3,6 +3,7 @@ package profiler import ( "fmt" "maps" + "math" "os" "regexp" "slices" @@ -2394,6 +2395,12 @@ func detectIDWalkParams(endpoint spec.Endpoint) (string, string, int) { if defaultSize, ok := paginationLimitDefault(endpoint, resolvedLimitParam); ok { pageSize = defaultSize } + // Clamp to the body limit param's declared maximum, same as the cursor/page + // sync path — an ID-walk POST search endpoint that caps its limit below 100 + // would otherwise be rejected with a validation error on every page. + if maxSize, ok := paginationLimitMaximum(endpoint, resolvedLimitParam); ok && pageSize > maxSize { + pageSize = maxSize + } return filterParam, resolvedLimitParam, pageSize } @@ -2429,6 +2436,42 @@ func paginationLimitDefault(endpoint spec.Endpoint, limitParam string) (int, boo return 0, false } +// paginationLimitMaximum returns the largest page size the pagination limit +// param permits, if it declares an upper bound. Sync uses it to clamp the +// requested page size below an API-enforced ceiling. An inclusive `maximum: N` +// yields floor(N); an exclusive bound (OpenAPI 3.1 `exclusiveMaximum: N`, or +// 3.0 `maximum: N` + `exclusiveMaximum: true`) yields ceil(N)-1 so the returned +// value is always the largest legal integer strictly below the bound. +func paginationLimitMaximum(endpoint spec.Endpoint, limitParam string) (int, bool) { + if strings.TrimSpace(limitParam) == "" { + return 0, false + } + limitName := strings.ToLower(limitParam) + params := append(append([]spec.Param{}, endpoint.Params...), endpoint.Body...) + for _, param := range params { + if strings.ToLower(param.Name) != limitName { + continue + } + // A param may declare both an inclusive `maximum` and an exclusive bound + // (independent assertions in OpenAPI 3.1). Take the most restrictive. + effMax, have := 0, false + if param.Maximum != nil { + if m := int(math.Floor(*param.Maximum)); m > 0 { + effMax, have = m, true + } + } + if param.ExclusiveMaximum != nil { + if m := int(math.Ceil(*param.ExclusiveMaximum)) - 1; m > 0 && (!have || m < effMax) { + effMax, have = m, true + } + } + if have { + return effMax, true + } + } + return 0, false +} + func syncPaginationDefaultsFromEndpoint(endpoint spec.Endpoint) (string, string, string, int) { cursorParam := "" cursorType := "" @@ -2457,6 +2500,13 @@ func syncPaginationDefaultsFromEndpoint(endpoint spec.Endpoint) (string, string, if defaultSize, ok := paginationLimitDefault(endpoint, limitParam); ok { pageSize = defaultSize } + // Clamp to the limit param's declared maximum so sync never requests a + // page size the API rejects with a validation error. An API-declared cap + // always wins over the default (e.g. Granola's public API caps page_size + // at 30, and a spec may declare a maximum without any default). + if maxSize, ok := paginationLimitMaximum(endpoint, limitParam); ok && pageSize > maxSize { + pageSize = maxSize + } return cursorParam, cursorType, limitParam, pageSize } diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go index 597738235..c3689b8b0 100644 --- a/internal/profiler/profiler_test.go +++ b/internal/profiler/profiler_test.go @@ -3035,6 +3035,137 @@ func TestProfilePagination_InfersFromPlainParamsWhenNoExplicitBlock(t *testing.T "inferred limit param must still read the spec-declared default") } +// A limit param's declared `maximum` must cap the sync page size: the 100 +// fallback (and any larger default) would otherwise trip an API validation +// error. Regression guard for the Granola public API, which caps page_size +// at 30 with no default. See mvanhorn/cli-printing-press#3440. +func TestProfilePagination_ClampsToParamMaximum(t *testing.T) { + max30 := 30.0 + excl30 := 30.0 + excl100 := 100.0 + s := &spec.APISpec{ + Name: "capped-pagination", + Resources: map[string]spec.Resource{ + // maximum only, no default: must clamp the 100 fallback to 30. + "notes": { + Endpoints: map[string]spec.Endpoint{ + "list": { + Method: "GET", + Path: "/notes", + Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Maximum: &max30}}, + Response: spec.ResponseDef{Type: "array"}, + }, + }, + }, + // default above the maximum: the API-declared cap must win. + "folders": { + Endpoints: map[string]spec.Endpoint{ + "list": { + Method: "GET", + Path: "/folders", + Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Default: 50, Maximum: &max30}}, + Response: spec.ResponseDef{Type: "array"}, + }, + }, + }, + // default under the maximum: no clamp, the default stands. + "tags": { + Endpoints: map[string]spec.Endpoint{ + "list": { + Method: "GET", + Path: "/tags", + Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Default: 20, Maximum: &max30}}, + Response: spec.ResponseDef{Type: "array"}, + }, + }, + }, + // exclusive maximum: the largest legal value is 29, not 30. + "events": { + Endpoints: map[string]spec.Endpoint{ + "list": { + Method: "GET", + Path: "/events", + Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", ExclusiveMaximum: &excl30}}, + Response: spec.ResponseDef{Type: "array"}, + }, + }, + }, + // both bounds present (OpenAPI 3.1): the stricter inclusive maximum + // (30) wins over the looser exclusive bound (100 -> 99). + "webhooks": { + Endpoints: map[string]spec.Endpoint{ + "list": { + Method: "GET", + Path: "/webhooks", + Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Maximum: &max30, ExclusiveMaximum: &excl100}}, + Response: spec.ResponseDef{Type: "array"}, + }, + }, + }, + }, + } + + profile := Profile(s) + byName := map[string]SyncableResource{} + for _, resource := range profile.SyncableResources { + byName[resource.Name] = resource + } + require.Contains(t, byName, "notes") + require.Contains(t, byName, "folders") + require.Contains(t, byName, "tags") + require.Contains(t, byName, "events") + require.Contains(t, byName, "webhooks") + assert.Equal(t, 30, byName["notes"].PaginationPageSize, + "maximum must clamp the 100 fallback when no default is declared") + assert.Equal(t, 30, byName["folders"].PaginationPageSize, + "maximum must win over a larger declared default") + assert.Equal(t, 20, byName["tags"].PaginationPageSize, + "a default under the maximum must stand unchanged") + assert.Equal(t, 29, byName["events"].PaginationPageSize, + "an exclusive maximum must clamp to the largest value strictly below it") + assert.Equal(t, 30, byName["webhooks"].PaginationPageSize, + "the most restrictive of a co-declared inclusive and exclusive bound must win") +} + +// The ID-walk sync path (pagination.type: id_walk over a POST search endpoint) +// resolves its page size independently via detectIDWalkParams, so the maximum +// clamp must apply there too. Regression guard for #3440. +func TestProfilePagination_IDWalkClampsToBodyParamMaximum(t *testing.T) { + max25 := 25.0 + s := &spec.APISpec{ + Name: "id-walk-capped", + Resources: map[string]spec.Resource{ + "records": { + Endpoints: map[string]spec.Endpoint{ + "search": { + Method: "POST", + Path: "/records/search", + IDField: "id", + Pagination: &spec.Pagination{ + Type: spec.PaginationTypeIDWalk, + LimitParam: "limit", + }, + Body: []spec.Param{ + {Name: "filter", Type: "array"}, + {Name: "limit", Type: "int", Maximum: &max25}, + }, + Response: spec.ResponseDef{Type: "array"}, + }, + }, + }, + }, + } + + profile := Profile(s) + byName := map[string]SyncableResource{} + for _, resource := range profile.SyncableResources { + byName[resource.Name] = resource + } + require.Contains(t, byName, "records") + assert.Equal(t, 25, byName["records"].IDWalkPageSize, + "ID-walk page size must clamp to the body limit param's maximum") +} + // Explicit pagination: blocks must continue to win over plain-param inference. // Mixing the two on the same endpoint would otherwise double-count or let // inference shadow the author's deliberate choice. diff --git a/internal/spec/spec.go b/internal/spec/spec.go index 4a258d44d..5a6c1b4e0 100644 --- a/internal/spec/spec.go +++ b/internal/spec/spec.go @@ -2259,6 +2259,18 @@ type Param struct { Fields []Param `yaml:"fields" json:"fields"` // for nested objects Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"` // enum constraints for the parameter Format string `yaml:"format,omitempty" json:"format,omitempty"` // OpenAPI format hints (date-time, email, uri, etc.) + // Maximum captures an inclusive numeric `maximum` schema constraint on the + // parameter. Sync uses it to clamp the page size it requests so a generated + // CLI never sends a page size the API rejects (e.g. a page_size param with + // maximum: 30). + Maximum *float64 `yaml:"maximum,omitempty" json:"maximum,omitempty"` + // ExclusiveMaximum captures an *exclusive* upper bound: the largest legal + // value is strictly below it. It normalizes both OpenAPI styles — + // 3.1's numeric `exclusiveMaximum: N`, and 3.0's `maximum: N` + + // `exclusiveMaximum: true` (which makes `maximum` exclusive). In OpenAPI + // 3.1 `maximum` and `exclusiveMaximum` are independent assertions, so both + // this and Maximum can be set; consumers take the most restrictive. + ExclusiveMaximum *float64 `yaml:"exclusive_maximum,omitempty" json:"exclusive_maximum,omitempty"` // DispatchParam marks a fixed discriminator such as type=domain_rank. // Generated runnable examples keep its default instead of substituting // synthetic dogfood values that would address a different upstream route. diff --git a/testdata/golden/expected/generate-golden-api-oauth2-device-code/printing-press-oauth2-device-code/SKILL.md b/testdata/golden/expected/generate-golden-api-oauth2-device-code/printing-press-oauth2-device-code/SKILL.md index 085b29af2..e3371773d 100644 --- a/testdata/golden/expected/generate-golden-api-oauth2-device-code/printing-press-oauth2-device-code/SKILL.md +++ b/testdata/golden/expected/generate-golden-api-oauth2-device-code/printing-press-oauth2-device-code/SKILL.md @@ -148,7 +148,7 @@ Unknown schemes are refused with a structured error naming the supported set. We ## Named Profiles -A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern. +A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run. ``` printing-press-oauth2-pp-cli profile save briefing --json diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md index 0486b076a..6970ef61e 100644 --- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md +++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md @@ -146,7 +146,7 @@ Unknown schemes are refused with a structured error naming the supported set. We ## Named Profiles -A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern. +A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run. ``` printing-press-rich-pp-cli profile save briefing --json diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md index ae2cfce01..09698b3ec 100644 --- a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md +++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md @@ -172,7 +172,7 @@ Unknown schemes are refused with a structured error naming the supported set. We ## Named Profiles -A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern. +A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run. ``` printing-press-golden-pp-cli profile save briefing --json diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.go index 2c06c9ede..53452b7c7 100644 --- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.go +++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.go @@ -17,8 +17,8 @@ import ( ) // Profile is a named set of flag values saved for reuse across invocations. -// HeyGen's "Beacon" pattern: one named context that a scheduled agent reuses -// day after day with the same voice/format but different input each run. +// Use a named profile when a scheduled or recurring workflow reuses the same +// saved flags while providing different input each run. type Profile struct { Name string `json:"name"` Description string `json:"description,omitempty"` diff --git a/testdata/golden/expected/generate-learn-loop-api/learn-loop-example/SKILL.md b/testdata/golden/expected/generate-learn-loop-api/learn-loop-example/SKILL.md index 0397a47bc..5251d13d7 100644 --- a/testdata/golden/expected/generate-learn-loop-api/learn-loop-example/SKILL.md +++ b/testdata/golden/expected/generate-learn-loop-api/learn-loop-example/SKILL.md @@ -323,7 +323,7 @@ Unknown schemes are refused with a structured error naming the supported set. We ## Named Profiles -A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern. +A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run. ``` learn-loop-example-pp-cli profile save briefing --json diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.md b/testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.md index 573ee3d3b..9bc348bef 100644 --- a/testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.md +++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.md @@ -138,7 +138,7 @@ Unknown schemes are refused with a structured error naming the supported set. We ## Named Profiles -A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern. +A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run. ``` public-param-golden-pp-cli profile save briefing --json