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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ You can help with:

Use a tool only when the user's request clearly matches it. For a general question you can answer from the campaign brief and phases already shown below, answer directly without a tool. In particular, questions about the phases themselves (their names or purpose) can be answered from the context below without a tool; only call getCampaignOverview when you need the post counts / distribution.

Act ONLY on the current message. The conversation history above is background for continuity — it is NOT a queue of instructions to replay. Decide what to do from THIS message alone:
- A question — anything phrased as "tell me about…", "what…", "which…", "how many…", "summarise…", "is the brief…", "do the posts…" — is READ-ONLY. Answer it (from the context below, or with a read tool: getCampaignOverview, listCampaignPosts, checkBrief, checkPostsConsistency, or askCampaignAssets). NEVER call a write/generation tool (runContentPlan, generatePosts, draftPost, enrichBrief, setCampaignDates, redistributePosts) to answer a question.
- Call a write/generation tool ONLY when the current message explicitly asks for that change. Do not re-run an action from an earlier turn just because it appears above, and do not carry a previous request forward onto an unrelated one.
- Resolve every date from the CURRENT request against today's date shown below. NEVER reuse a date mentioned in an earlier message. If the current message names no date, omit the date and let the tool default — do not borrow one from history.

Run at most ONE generation or review action per message — one of runContentPlan, generatePosts, draftPost, enrichBrief, checkBrief, or checkPostsConsistency. After it returns, stop calling action tools and reply. If the user asked for several, do the most important one now and offer the rest as a follow-up. (Read tools like getCampaignOverview, listCampaignPosts, and askCampaignAssets may be used first to inform that one action.) If an action tool returns a `note` saying one already ran this turn, do not retry — just reply with what was done.

## Response format
Expand Down
23 changes: 17 additions & 6 deletions src/genkit/flows/campaign_assistant/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,20 +528,16 @@ func toolGetCampaignOverview(ctx context.Context) (*overview.Overview, error) {

func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePostsOutput, error) {
st := getRequestState(ctx)
if !st.reserveHeavyAction() {
return &GeneratePostsOutput{Note: heavySkipNote}, nil
}
if st.generatePosts == nil {
return nil, fmt.Errorf("targeted post generation is not available")
}
// CON-118: generate from the campaign's attached assets when it has any.
ensureCampaignAssetUse(ctx, st)
campaign := st.campaign
now := time.Now().UTC()

// Resolve platform names/ids against the campaign's target platforms
// (CON-114 "stay in scope"). A non-target platform is user-correctable, so it
// fails soft (CON-215) rather than aborting the whole turn.
// fails soft (CON-215) rather than aborting the whole turn. Resolved BEFORE the
// heavy-action reservation so a user-correctable decline never burns the slot.
platformIDs, platformNames, err := resolveTargetPlatforms(campaign, in.Platforms)
if err != nil {
return softGenerateFailure(err)
Expand Down Expand Up @@ -572,6 +568,21 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos
// so validation pins the publish date exactly (CON-114).
windowEnd = singlePostWindowEnd(windowStart, windowEnd, in.WindowEnd, count)

// Reserve the turn's single heavy-action slot only now — after every
// user-correctable validation has passed — so a mis-routed or invalid
// generatePosts that fails soft above never burns the slot for a legitimate
// heavy tool in the same turn (CON-216, mirroring draftPost's late
// reservation). The reservation still precedes st.generatePosts, so two heavy
// tools dispatched in parallel can never both generate.
if !st.reserveHeavyAction() {
return &GeneratePostsOutput{Note: heavySkipNote}, nil
}

// CON-118: generate from the campaign's attached assets when it has any. After
// the reservation so a soft-failed or slot-skipped turn never flips UseAssets
// in the DB for work that isn't going to run.
ensureCampaignAssetUse(ctx, st)

emit(st.onEvent, SSEEventGeneratePostsStarted, GeneratePostsStartedEventPayload{
PlatformIDs: platformIDs,
PhaseID: phaseID,
Expand Down
59 changes: 59 additions & 0 deletions src/genkit/flows/campaign_assistant/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,65 @@ func TestGeneratePosts_SoftFailsUserInput(t *testing.T) {
}
}

// TestGeneratePosts_HeavySlotOnlyBurnedOnSuccess guards the CON-216 fix: the
// heavy-action reservation moved to AFTER generatePosts' user-correctable
// validations (mirroring draftPost). So a generatePosts that fails soft on a
// past date / non-target platform / unknown phase — including a mis-routed one
// where the planner replayed a stale past-date instruction onto a read-only
// question — must NOT consume the turn's single heavy slot, leaving it free for
// a legitimate heavy action in the same turn. A successful generation still
// claims the slot so a second heavy tool is turned away.
func TestGeneratePosts_HeavySlotOnlyBurnedOnSuccess(t *testing.T) {
t.Run("soft failure leaves the slot free", func(t *testing.T) {
st := &requestState{
campaignID: "c1",
campaign: timelineCampaign(), // no repos → ensureCampaignAssetUse no-ops
maxGeneratePosts: 10,
generatePosts: func(context.Context, content_plan.GeneratePostsRequest, content_plan.OnEventFunc) (*content_plan.ContentPlanResponse, error) {
t.Fatal("generation engine must not run for invalid input")
return nil, nil
},
}
ctx := withRequestState(context.Background(), st)
// 2020 is unambiguously before any real "today", so this is clock-safe.
if _, err := toolGeneratePosts(ctx, GeneratePostsInput{Platforms: []string{"Threads"}, WindowStart: "2020-01-01", WindowEnd: "2020-01-01"}); err != nil {
t.Fatalf("want soft failure (nil error), got %v", err)
}
if st.heavyReserved {
t.Fatal("a soft-failed generatePosts must not reserve the heavy slot")
}
if !st.reserveHeavyAction() {
t.Fatal("the heavy slot must still be claimable after a soft failure")
}
})

t.Run("success claims the slot", func(t *testing.T) {
st := &requestState{
campaignID: "c1",
campaign: timelineCampaign(),
maxGeneratePosts: 10,
generatePosts: func(context.Context, content_plan.GeneratePostsRequest, content_plan.OnEventFunc) (*content_plan.ContentPlanResponse, error) {
return &content_plan.ContentPlanResponse{Posts: []content_plan.DraftPost{{Title: "t", PublishDate: "2026-01-15"}}}, nil
},
}
ctx := withRequestState(context.Background(), st)
// Omit the window → defaults to the next 14 days from today (clock-safe).
out, err := toolGeneratePosts(ctx, GeneratePostsInput{Platforms: []string{"Threads"}})
if err != nil {
t.Fatalf("valid generation: %v", err)
}
if out == nil || out.PostCount != 1 {
t.Fatalf("want one generated post, got %+v", out)
}
if !st.heavyReserved {
t.Fatal("a successful generatePosts must reserve the heavy slot")
}
if st.reserveHeavyAction() {
t.Fatal("a second heavy reservation must be turned away after a successful generation")
}
})
}

func TestResolveWindow(t *testing.T) {
today := day("2026-02-10")

Expand Down
Loading