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
40 changes: 36 additions & 4 deletions packages/docs/v4/reference/stagehand.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,24 @@ Perform an action described in natural language.
const result = await stagehand.act("Click the sign in button");
```

<ParamField path="input" type="string">
The natural-language action to perform.
<ParamField path="input" type="Action | string">
A natural-language action to perform, or an action returned by `observe()`.

<ParamField path="input.selector" type="string">
The CSS selector or XPath for the action target.
</ParamField>

<ParamField path="input.description" type="string">
A human-readable description of the action.
</ParamField>

<ParamField path="input.method" type="string" optional>
The action method to execute.
</ParamField>

<ParamField path="input.arguments" type="string[]" optional>
Arguments to pass to the action method.
</ParamField>
</ParamField>

<ParamField path="options" type="StagehandClientActOptions" optional>
Expand Down Expand Up @@ -745,8 +761,24 @@ Perform an action described in natural language.
result = await stagehand.act("Click the sign in button")
```

<ParamField path="input" type="str">
The natural-language action to perform.
<ParamField path="input" type="Action | str">
A natural-language action to perform, or an action returned by `observe()`.

<ParamField path="input.selector" type="str">
The CSS selector or XPath for the action target.
</ParamField>

<ParamField path="input.description" type="str">
A human-readable description of the action.
</ParamField>

<ParamField path="input.method" type="str" optional>
The action method to execute.
</ParamField>

<ParamField path="input.arguments" type="list[str]" optional>
Arguments to pass to the action method.
</ParamField>
</ParamField>

<ParamField path="page" type="Page | None" optional>
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1347,7 +1347,7 @@ export const RuntimeConfigureParamsSchema = z
export const StagehandActParamsSchema = z
.strictObject({
pageId: z.string().min(1),
input: z.string().min(1),
input: z.union([z.string().min(1), ActionSchema]),
options: ActOptionsSchema.optional(),
})
.meta({ id: "StagehandActParams" });
Expand Down
73 changes: 40 additions & 33 deletions packages/protocol/stagehand.v4.json
Original file line number Diff line number Diff line change
Expand Up @@ -1623,8 +1623,15 @@
"minLength": 1
},
"input": {
"type": "string",
"minLength": 1
"anyOf": [
{
"type": "string",
"minLength": 1
},
{
"$ref": "#/$defs/Action"
}
]
},
"options": {
"$ref": "#/$defs/ActOptions"
Expand All @@ -1633,6 +1640,37 @@
"required": ["page_id", "input"],
"additionalProperties": false
},
"Action": {
"type": "object",
"properties": {
"selector": {
"type": "string",
"description": "CSS selector or XPath for the element",
"example": "[data-testid='submit-button']"
},
"description": {
"type": "string",
"description": "Human-readable description of the action",
"example": "Click the submit button"
},
"method": {
"description": "The method to execute (click, fill, etc.)",
"example": "click",
"type": "string"
},
"arguments": {
"description": "Arguments to pass to the method",
"example": ["Hello World"],
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["selector", "description"],
"additionalProperties": false,
"description": "Action object returned by observe and used by act"
},
"ActOptions": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1770,37 +1808,6 @@
"required": ["success", "message", "action_description", "actions"],
"additionalProperties": false
},
"Action": {
"type": "object",
"properties": {
"selector": {
"type": "string",
"description": "CSS selector or XPath for the element",
"example": "[data-testid='submit-button']"
},
"description": {
"type": "string",
"description": "Human-readable description of the action",
"example": "Click the submit button"
},
"method": {
"description": "The method to execute (click, fill, etc.)",
"example": "click",
"type": "string"
},
"arguments": {
"description": "Arguments to pass to the method",
"example": ["Hello World"],
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["selector", "description"],
"additionalProperties": false,
"description": "Action object returned by observe and used by act"
},
"StagehandResultMetadata": {
"type": "object",
"properties": {
Expand Down
33 changes: 33 additions & 0 deletions packages/sdk-go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,39 @@ func TestClientSerializesConcurrentInitAndClose(t *testing.T) {
}
}

func TestActAcceptsObservedAction(t *testing.T) {
t.Parallel()

rpc := &recordingProtocolClient{responses: map[string]any{
"runtime.configure": RuntimeConfigureResult{Configured: true},
"stagehand.init": StagehandInitResult{Initialized: true},
"context.active_page": PageRef{PageID: "page-1"},
"stagehand.act": ActResult{Data: ActResultData{
Success: true, Message: "clicked", ActionDescription: "Submit button", Actions: []Action{},
}},
}}
client := newStagehandWithClient(StagehandClientInitParams{}, rpc)
action := Action{
Selector: "xpath=/html/body/button",
Description: "Submit button",
}

if err := client.Init(context.Background()); err != nil {
t.Fatalf("Init() error = %v", err)
}
if _, err := client.Act(context.Background(), action, nil); err != nil {
t.Fatalf("Act() error = %v", err)
}
params, ok := rpc.calls[3].params.(StagehandActParams)
if !ok {
t.Fatalf("stagehand.act params = %T", rpc.calls[3].params)
}
got, ok := params.Input.AsAction()
if !ok || !reflect.DeepEqual(got, action) {
t.Fatalf("Act() input = %#v, want %#v", got, action)
}
}

func TestClientCloseWaitsForInFlightInit(t *testing.T) {
t.Parallel()

Expand Down
Binary file modified packages/sdk-go/internal/extensionassets/stagehand-extension.zip
Binary file not shown.
1 change: 1 addition & 0 deletions packages/sdk-go/internal/generator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ var customProperties = map[string]string{
"$defs/LLMMessage/properties/content": "LLMMessageContent",
"$defs/LocatorScrollToParams/properties/percent": "ScrollPercent",
"$defs/LocatorSelectOptionParams/properties/values": "StringList",
"$defs/StagehandActParams/properties/input": "ActInput",
"$defs/StagehandInitParams/properties/model": "StagehandInitModel",
}

Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-go/models.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions packages/sdk-go/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ func TestObjectUnionsRoundTrip(t *testing.T) {
new func() any
check func(*testing.T, any)
}{
{
name: "act instruction",
value: ActInstruction("click the link"),
new: func() any { return new(ActInput) },
check: func(t *testing.T, value any) {
if instruction, ok := value.(*ActInput).AsInstruction(); !ok || instruction != "click the link" {
t.Fatal("decoded the wrong act input variant")
}
},
},
{
name: "observed action",
value: ObservedAction(Action{
Selector: "xpath=/html/body/button",
Description: "Submit button",
}),
new: func() any { return new(ActInput) },
check: func(t *testing.T, value any) {
if action, ok := value.(*ActInput).AsAction(); !ok || action.Description != "Submit button" {
t.Fatal("decoded the wrong act input variant")
}
},
},
{
name: "known model",
value: KnownModel(KnownModelConfig{ModelName: ModelName("openai/gpt-5.6")}),
Expand Down
60 changes: 60 additions & 0 deletions packages/sdk-go/object_unions.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,66 @@ const (
outputFormatJSONSchema = "json_schema"
)

type actInputValue interface {
isActInput()
}

type actInstruction string

func (actInstruction) isActInput() {}
func (Action) isActInput() {}

// ActInput is either a natural-language instruction or an observed action.
type ActInput struct {
value actInputValue
}

// ActInstruction constructs a natural-language act input.
func ActInstruction(value string) ActInput {
return ActInput{value: actInstruction(value)}
}

// ObservedAction constructs an act input from an action returned by Observe.
func ObservedAction(value Action) ActInput {
return ActInput{value: value}
}

// AsInstruction returns the instruction variant, if present.
func (value ActInput) AsInstruction() (string, bool) {
instruction, ok := value.value.(actInstruction)
return string(instruction), ok
}

// AsAction returns the observed-action variant, if present.
func (value ActInput) AsAction() (Action, bool) {
action, ok := value.value.(Action)
return action, ok
}

func (value ActInput) MarshalJSON() ([]byte, error) {
if value.value == nil {
return nil, errors.New("stagehand.ActInput is unset")
}
return json.Marshal(value.value)
}

func (value *ActInput) UnmarshalJSON(data []byte) error {
if value == nil {
return errors.New("stagehand.ActInput: UnmarshalJSON on nil pointer")
}
var instruction string
if err := json.Unmarshal(data, &instruction); err == nil {
*value = ActInstruction(instruction)
return nil
}
var action Action
if err := json.Unmarshal(data, &action); err != nil {
return fmt.Errorf("decode act input: %w", err)
}
*value = ObservedAction(action)
return nil
}

type modelConfigValue interface {
isModelConfig()
}
Expand Down
13 changes: 11 additions & 2 deletions packages/sdk-go/stagehand.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (s *Stagehand) Metrics(ctx context.Context) (StagehandMetrics, error) {
// Act performs an AI-guided action on the selected or active page.
func (s *Stagehand) Act(
ctx context.Context,
input string,
input any,
options *StagehandClientActOptions,
) (ActResult, error) {
rpc, err := s.connectedProtocol()
Expand All @@ -101,7 +101,16 @@ func (s *Stagehand) Act(
if err != nil {
return ActResult{}, err
}
params := StagehandActParams{PageID: page.PageID(), Input: input}
var actInput ActInput
switch value := input.(type) {
case string:
actInput = ActInstruction(value)
case Action:
actInput = ObservedAction(value)
default:
return ActResult{}, fmt.Errorf("act input must be a string or stagehand.Action, got %T", input)
}
params := StagehandActParams{PageID: page.PageID(), Input: actInput}
if options != nil {
params.Options = &options.ActOptions
}
Expand Down
6 changes: 5 additions & 1 deletion packages/sdk-python/src/stagehand/_generated/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,10 @@ class ImplementationInfo(WireModel):
version: Annotated[StrictStr, Field(min_length=1)]


class Input(RootModel[StrictStr]):
root: Annotated[StrictStr, Field(min_length=1)]


class JSONRPCErrorObject(WireModel):
model_config = ConfigDict(
extra="forbid",
Expand Down Expand Up @@ -1830,7 +1834,7 @@ class StagehandActParams(WireModel):
validate_by_name=True,
)
page_id: Annotated[StrictStr, Field(min_length=1)]
input: Annotated[StrictStr, Field(min_length=1)]
input: Union[Input, Action]
options: Optional[ActOptions] = None


Expand Down
5 changes: 3 additions & 2 deletions packages/sdk-python/src/stagehand/stagehand.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pydantic import BaseModel

from ._generated.models import (
Action,
ActOptions,
ActResult,
BrowserbaseBrowserSettings,
Expand Down Expand Up @@ -463,7 +464,7 @@ async def generate(params: LLMGenerateParams) -> LLMGenerateResult:

async def act(
self,
input: str,
input: str | Action,
*,
page: Page | None = None,
model: ModelConfig | None = None,
Expand All @@ -486,7 +487,7 @@ async def act(
target_page = page or await self.context.active_page()
if target_page is None:
raise RuntimeError("Stagehand has no active page")
params = StagehandActParams(page_id=target_page.page_id, input=input)
params = StagehandActParams.model_validate({"page_id": target_page.page_id, "input": input})
if options.model_fields_set:
params.options = options
result = await self._connected_rpc_client.send("stagehand.act", params, ActResult)
Expand Down
Loading
Loading