Ask mid-turn steer
You start an investigation. Two minutes later you remember the deploy window, or a new log line shows up in Slack. You do not want a second parallel run that races the first one and burns another approval chain.
That is what mid-turn steer is for. Send another Ask on the same session_id
while work is still going. If the running turn can take the message, the start
reply sets action to steer and gives you that turn’s trace_id. No second
turn. You keep one conversation, one wait, one report.
If the turn already finished, or steering is not available, you get a normal start
instead. Check with isSteerResponse / is_steer_response so your bot or UI can
branch cleanly.
When you need this
- On-call follow-ups. You kicked off “checkout is slow” and then learn it started after the 14:00 deploy. Add that without opening a new session.
- Human-in-the-loop bots. A Slack bot starts Ask for a channel thread, then relays the next human reply into the same session while tools are still running.
- Narrowing a long dig. The agent is deep in Grafana. You tell it “ignore staging, prod only” so it does not finish on the wrong cluster.
- CI that adapts. A pipeline starts Ask, then posts a follow-up when a later job discovers more context (commit SHA, failing test name).
Without steer, each follow-up tends to look like a brand-new Ask. You get duplicate tool calls, duplicate HITL, and two reports for one incident.
How to run
export STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
pip install stackgen-sdk==0.3.0
python examples/ask/ask-steer-followup.pyexport STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
npm install stackgen-sdk@0.3.0 tsx
npx tsx examples/ask/ask-steer-followup.tsexport STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
go run ./examples/go/ask/ask-steer-followup.goMinimal
from stackgen import StackgenClient, StackgenConfig, is_steer_response
client = StackgenClient(
StackgenConfig(
base_url="https://app.stackgen.com",
api_token="stackgen_…",
workspace_id="<workspace-uuid>",
)
)
# Same id for the whole incident thread (Slack thread, ticket id, etc.)
session_id = "ops-thread-1"
start = client.aiden.start_ask(
"Investigate checkout latency in prod",
session_id=session_id,
)
follow = client.aiden.start_ask(
"Also check the 14:00 deploy markers before you finish",
session_id=session_id,
)
if is_steer_response(follow):
# Same turn kept going; wait on that trace only.
print("steered into", follow["trace_id"])
client.aiden.wait_for_subscribe_complete(follow["trace_id"])
else:
# Turn was done or steer unavailable; this is a new/queued turn.
print("new or queued turn", follow.get("trace_id"))
client.aiden.wait_for_subscribe_complete(follow["trace_id"] or start["trace_id"])import {
StackgenClient,
StackgenConfig,
isSteerResponse,
} from "stackgen-sdk";
const client = new StackgenClient(
new StackgenConfig({
baseUrl: "https://app.stackgen.com",
apiToken: "stackgen_…",
workspaceId: "<workspace-uuid>",
}),
);
const sessionId = "ops-thread-1";
const start = await client.aiden.startAsk(
"Investigate checkout latency in prod",
{ sessionId },
);
const follow = await client.aiden.startAsk(
"Also check the 14:00 deploy markers before you finish",
{ sessionId },
);
if (isSteerResponse(follow)) {
console.log("steered into", follow.trace_id);
await client.aiden.waitForSubscribeComplete(String(follow.trace_id));
} else {
console.log("new or queued turn", follow.trace_id);
await client.aiden.waitForSubscribeComplete(
String(follow.trace_id ?? start.trace_id),
);
}sessionID := "ops-thread-1"
start, err := client.Aiden.StartAsk(ctx, "Investigate checkout latency in prod", aiden.StartAskOptions{
SessionID: sessionID,
})
if err != nil { return err }
follow, err := client.Aiden.StartAsk(ctx,
"Also check the 14:00 deploy markers before you finish",
aiden.StartAskOptions{SessionID: sessionID},
)
if err != nil { return err }
traceID := follow.TraceID
if aiden.IsSteerResponse(follow.Raw) {
fmt.Println("steered into", follow.TraceID)
} else {
fmt.Println("new or queued turn", follow.TraceID)
if traceID == "" {
traceID = start.TraceID
}
}
return client.Aiden.SubscribeExecution(ctx, traceID, func(aiden.ExecutionEvent) error {
return nil
})Complete scripts
- Python:
examples/ask/ask-steer-followup.py - TypeScript:
examples/ask/ask-steer-followup.ts - Go:
examples/go/ask/ask-steer-followup.go
#!/usr/bin/env python3
"""Keep talking to a running Ask session (mid-turn steer).
You start an investigation, then send a follow-up on the same session_id
(deploy window, better log line, Slack reply). When the running turn takes it,
the start reply sets action=\"steer\" and you wait on that one trace_id instead
of opening a parallel dig.
Requires STACKGEN_URL, STACKGEN_TOKEN, STACKGEN_WORKSPACE_ID.
Usage:
export STACKGEN_URL=https://app.stackgen.com
export STACKGEN_TOKEN=stackgen_…
export STACKGEN_WORKSPACE_ID=<workspace-uuid>
python examples/ask/ask-steer-followup.py --message "Investigate checkout latency"
"""
from __future__ import annotations
import argparse
import os
import sys
import uuid
from stackgen import StackgenClient, StackgenConfig, is_steer_response
def env(name: str, *fallbacks: str) -> str:
for key in (name, *fallbacks):
value = (os.environ.get(key) or "").strip()
if value:
return value
raise SystemExit(f"missing env {name}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--message",
default="Investigate a synthetic checkout latency spike",
help="Initial Ask message",
)
parser.add_argument(
"--follow-up",
default="Also check recent deploy markers before you finish",
help="Mid-session follow-up text",
)
parser.add_argument(
"--workflow",
default=os.environ.get("STACKGEN_WORKFLOW", ""),
help="Optional workflow entity_ref (CI fixture)",
)
parser.add_argument("--timeout-seconds", type=int, default=600)
args = parser.parse_args()
client = StackgenClient(
StackgenConfig(
base_url=env("STACKGEN_URL"),
api_token=env("STACKGEN_TOKEN"),
workspace_id=env("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT"),
timeout_seconds=args.timeout_seconds,
)
)
session_id = f"sdk-steer-{uuid.uuid4().hex[:12]}"
entity_refs = (
[{"name": args.workflow, "type": "workflow"}] if args.workflow.strip() else None
)
def wait_settled(trace_id: str) -> bool:
try:
done = client.aiden.wait_for_subscribe_complete(
trace_id, timeout_ms=args.timeout_seconds * 1000
)
if done.ok:
print(f"subscribe complete ok=True trace_id={trace_id}")
return True
print(
f"subscribe incomplete ({done}); waiting on execution status",
file=sys.stderr,
)
except Exception as exc: # noqa: BLE001
message = str(exc)
if not any(
token in message.lower()
for token in ("timed out", "subscribeexecution", "aborted", "eof")
):
raise
print(
f"subscribe timed out ({exc}); waiting on execution status",
file=sys.stderr,
)
trace = client.aiden.wait_for_execution(trace_id)
execution = trace.get("execution") if isinstance(trace, dict) else {}
if not isinstance(execution, dict):
execution = {}
status = str(execution.get("status") or "").lower()
ok = status in {"completed", "success", "succeeded"}
print(f"subscribe complete ok={ok} trace_id={trace_id} execution={status}")
return ok
try:
start = client.aiden.start_ask(
args.message, session_id=session_id, entity_refs=entity_refs
)
trace_id = str(start.get("trace_id") or "")
if not trace_id:
raise SystemExit(f"start_ask missing trace_id: {start}")
print(f"started session_id={session_id} trace_id={trace_id}")
follow = client.aiden.start_ask(
args.follow_up, session_id=session_id, entity_refs=entity_refs
)
follow_trace = str(follow.get("trace_id") or "")
steered = is_steer_response(follow)
print(
f"follow-up action={follow.get('action')!r} steered={steered} "
f"trace_id={follow_trace}"
)
wait_trace = follow_trace or trace_id
ok = wait_settled(wait_trace)
if steered and follow_trace and follow_trace != trace_id:
print(
"warning: steer response used a different trace_id than the original start",
file=sys.stderr,
)
return 0 if ok else 1
finally:
try:
client.aiden.terminate_session(session_id, reason="sdk example finished")
except Exception as exc: # noqa: BLE001
print(f"terminate session {session_id}: {exc}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())/**
* Keep talking to a running Ask session (mid-turn steer).
*
* Start an investigation, then send a follow-up on the same sessionId
* (deploy window, better log line, Slack reply). When the running turn takes
* it, action is "steer" and you wait on that one trace_id instead of opening
* a parallel dig.
*
* Usage:
* export STACKGEN_URL=https://app.stackgen.com
* export STACKGEN_TOKEN=stackgen_…
* export STACKGEN_WORKSPACE_ID=<workspace-uuid>
* npx tsx examples/ask/ask-steer-followup.ts
*/
import { parseArgs } from "node:util";
import { randomUUID } from "node:crypto";
import {
StackgenClient,
StackgenConfig,
isSteerResponse,
} from "stackgen-sdk";
function requireEnv(name: string, ...fallbacks: string[]): string {
for (const key of [name, ...fallbacks]) {
const value = (process.env[key] || "").trim();
if (value) {
return value;
}
}
throw new Error(`missing env ${name}`);
}
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
message: {
type: "string",
default: "Investigate a synthetic checkout latency spike",
},
"follow-up": {
type: "string",
default: "Also check recent deploy markers before you finish",
},
workflow: { type: "string", default: process.env.STACKGEN_WORKFLOW || "" },
"timeout-seconds": { type: "string", default: "600" },
},
});
const client = new StackgenClient(
new StackgenConfig({
baseUrl: requireEnv("STACKGEN_URL"),
apiToken: requireEnv("STACKGEN_TOKEN"),
workspaceId: requireEnv("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT"),
timeoutSeconds: Number(values["timeout-seconds"]),
}),
);
const sessionId = `sdk-steer-${randomUUID().slice(0, 12)}`;
const entityRefs = values.workflow
? [{ name: values.workflow, type: "workflow" }]
: undefined;
async function waitSettled(traceId: string): Promise<boolean> {
try {
const done = await client.aiden.waitForSubscribeComplete(traceId, {
timeoutMs: Number(values["timeout-seconds"]) * 1000,
});
if (done.ok) {
console.log(`subscribe complete ok=true trace_id=${traceId}`);
return true;
}
console.error(
`subscribe incomplete (${JSON.stringify(done)}); waiting on execution status`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!/timed out|subscribeExecution|aborted|eof/i.test(message)) {
throw err;
}
console.error(`subscribe timed out (${message}); waiting on execution status`);
}
const trace = await client.aiden.waitForExecution(traceId);
const execution = (trace.execution ?? {}) as Record<string, unknown>;
const status = String(execution.status ?? "").toLowerCase();
const ok = ["completed", "success", "succeeded"].includes(status);
console.log(`subscribe complete ok=${ok} trace_id=${traceId} execution=${status}`);
return ok;
}
try {
const start = await client.aiden.startAsk(values.message!, {
sessionId,
entityRefs,
});
const traceId = String(start.trace_id ?? "");
if (!traceId) {
throw new Error(`startAsk missing trace_id: ${JSON.stringify(start)}`);
}
console.log(`started session_id=${sessionId} trace_id=${traceId}`);
const follow = await client.aiden.startAsk(values["follow-up"]!, {
sessionId,
entityRefs,
});
const followTrace = String(follow.trace_id ?? "");
const steered = isSteerResponse(follow);
console.log(
`follow-up action=${JSON.stringify(follow.action)} steered=${steered} trace_id=${followTrace}`,
);
const waitTrace = followTrace || traceId;
const ok = await waitSettled(waitTrace);
if (steered && followTrace && followTrace !== traceId) {
console.warn(
"warning: steer response used a different trace_id than the original start",
);
}
if (!ok) {
throw new Error(`subscribe complete ok=false`);
}
} finally {
try {
await client.aiden.terminateSession(sessionId, { reason: "sdk example finished" });
} catch (err) {
console.error(`terminate session ${sessionId}:`, err);
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});// Send a follow-up into a running Ask session.
//
// Documented at https://appcd-dev.github.io/stackgen-sdk/code-samples/ask-steer-followup/
//
// go run ./examples/go/ask/ask-steer-followup.go --message "Investigate checkout latency"
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"os"
"strings"
"time"
stackgen "github.com/appcd-dev/stackgen-sdk/clients/go"
"github.com/appcd-dev/stackgen-sdk/clients/go/aiden"
)
// envOr returns the first non-empty environment variable among keys.
func envOr(keys ...string) string {
for _, key := range keys {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
}
return ""
}
func main() {
message := flag.String("message", "Investigate a synthetic checkout latency spike", "Initial Ask message")
followUp := flag.String("follow-up", "Also check recent deploy markers before you finish", "Mid-session follow-up")
workflow := flag.String("workflow", envOr("STACKGEN_WORKFLOW"), "Optional workflow entity_ref")
timeoutSeconds := flag.Int("timeout-seconds", 600, "Subscribe timeout")
flag.Parse()
base, token, org := envOr("STACKGEN_URL"), envOr("STACKGEN_TOKEN"), envOr("STACKGEN_WORKSPACE_ID", "STACKGEN_PROJECT")
if base == "" || token == "" || org == "" {
fmt.Fprintln(os.Stderr, "set STACKGEN_URL, STACKGEN_TOKEN, and STACKGEN_WORKSPACE_ID")
os.Exit(2)
}
client, err := stackgen.NewClient(stackgen.Config{
BaseURL: base,
APIToken: token,
WorkspaceID: org,
TimeoutSeconds: *timeoutSeconds,
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var buf [6]byte
_, _ = rand.Read(buf[:])
sessionID := "sdk-steer-" + hex.EncodeToString(buf[:])
opts := aiden.StartAskOptions{SessionID: sessionID}
if strings.TrimSpace(*workflow) != "" {
opts.EntityRefs = []aiden.EntityRef{{Name: *workflow, Type: "workflow"}}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSeconds)*time.Second)
defer cancel()
start, err := client.Aiden.StartAsk(ctx, *message, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("started session_id=%s trace_id=%s\n", start.SessionID, start.TraceID)
follow, err := client.Aiden.StartAsk(ctx, *followUp, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
traceID := follow.TraceID
if aiden.IsSteerResponse(follow.Raw) {
fmt.Printf("steered into %s\n", traceID)
} else {
fmt.Printf("new or queued turn %s\n", traceID)
if traceID == "" {
traceID = start.TraceID
}
}
if err := client.Aiden.SubscribeExecution(ctx, traceID, func(aiden.ExecutionEvent) error { return nil }); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("subscribe complete ok=true trace_id=%s\n", traceID)
}